io: the app's data folder, and confining writes alone

recovery::data_dir() names the per-user data folder (the temporary
fieldcraft-automation folder in automated runs) that the recovery, crash
and log folders live in. Roots::writes_within lets reads go anywhere
while writes stay in one folder, for replaying a crash report safely.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 00:36:18 -04:00
1 parent d3ed8ffb05
commit 3d51bde9dd
2 files changed
+82 -28

No files matched your search

+26 -10
View File
@@ -32,26 +32,36 @@ pub fn automation_dir() -> PathBuf {
/// Set by [`use_automation_dir`]. /// Set by [`use_automation_dir`].
static AUTOMATED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); static AUTOMATED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// This process is driven by automation (a control channel): unless `FIELDCRAFT_RECOVERY_DIR` /// This process is driven by automation (a control channel): [`data_dir`] is the temporary
/// names a folder, [`default_dir`] is [`automation_dir`] from now on. /// `fieldcraft-automation` folder from now on, so unless `FIELDCRAFT_RECOVERY_DIR` names a
/// folder, [`default_dir`] is [`automation_dir`].
pub fn use_automation_dir() { pub fn use_automation_dir() {
let _ = AUTOMATED.set(()); let _ = AUTOMATED.set(());
} }
/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else [`automation_dir`] in automated runs /// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `recovery` in [`data_dir`];
/// ([`use_automation_dir`], or the `CI` environment variable set), else `recovery` in
/// the platform's per-user data folder (`FieldCraft/recovery`, `fieldcraft/recovery` on Linux);
/// [`BROWSER_DIR`] in the browser. /// [`BROWSER_DIR`] in the browser.
pub fn default_dir() -> Option<PathBuf> { pub fn default_dir() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
return Some(PathBuf::from(BROWSER_DIR)); return Some(PathBuf::from(BROWSER_DIR));
} }
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from); if let Some(d) = std::env::var_os("FIELDCRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) {
if let Some(d) = env("FIELDCRAFT_RECOVERY_DIR") { return Some(PathBuf::from(d));
return Some(d);
} }
data_dir().map(|d| d.join("recovery"))
}
/// The app's per-user data folder, which holds the recovery, crash and log folders: the
/// platform's (`FieldCraft`, `fieldcraft` on Linux), or `fieldcraft-automation` in the temporary
/// folder in automated runs ([`use_automation_dir`], or the `CI` environment variable set).
/// `None` in the browser and where the platform names no such folder.
pub fn data_dir() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return None;
}
let env = |k: &str| std::env::var_os(k).filter(|d| !d.is_empty()).map(PathBuf::from);
if AUTOMATED.get().is_some() || env("CI").is_some() { if AUTOMATED.get().is_some() || env("CI").is_some() {
return Some(automation_dir()); return automation_dir().parent().map(Path::to_path_buf);
} }
let (root, name) = if cfg!(windows) { let (root, name) = if cfg!(windows) {
(env("LOCALAPPDATA")?, "FieldCraft") (env("LOCALAPPDATA")?, "FieldCraft")
@@ -60,7 +70,7 @@ pub fn default_dir() -> Option<PathBuf> {
} else { } else {
(env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?, "fieldcraft") (env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share")))?, "fieldcraft")
}; };
Some(root.join(name).join("recovery")) Some(root.join(name))
} }
/// Entries older than this many days are pruned ([`prune`]). /// Entries older than this many days are pruned ([`prune`]).
@@ -338,6 +348,11 @@ impl Slot {
Ok(()) Ok(())
} }
/// The autosaved model's file (there once [`Slot::write`] has written it).
pub fn file(&self) -> PathBuf {
file(&self.dir, &self.id, EXTENSION)
}
/// Drop the autosave (nothing is unsaved any more). /// Drop the autosave (nothing is unsaved any more).
pub fn clear(&mut self) { pub fn clear(&mut self) {
if self.has_entry { if self.has_entry {
@@ -458,6 +473,7 @@ mod tests {
use_automation_dir(); use_automation_dir();
assert_eq!(default_dir(), Some(automation_dir())); assert_eq!(default_dir(), Some(automation_dir()));
assert!(automation_dir().starts_with(std::env::temp_dir())); assert!(automation_dir().starts_with(std::env::temp_dir()));
assert_eq!(data_dir().map(|d| d.join("recovery")), Some(automation_dir()));
} }
#[test] #[test]
+56 -18
View File
@@ -12,7 +12,9 @@
//! - The result is compared with the root by whole folder names: `/work2/x` is not inside //! - The result is compared with the root by whole folder names: `/work2/x` is not inside
//! `/work`. //! `/work`.
//! - Read and write access are separate; a root left out grants none of its access. Without //! - Read and write access are separate; a root left out grants none of its access. Without
//! either root ([`Roots::default`]) nothing is confined. //! either root ([`Roots::default`]) nothing is confined. [`Roots::writes_within`] confines
//! writes alone (a crash replay may read the model's geometry wherever it is, and writes
//! nothing outside a scratch folder).
//! //!
//! A path is checked, then opened: another program that swaps a link in between can still win //! A path is checked, then opened: another program that swaps a link in between can still win
//! that race, so the roots keep an agent to the files it was given rather than guarding against //! that race, so the roots keep an agent to the files it was given rather than guarding against
@@ -41,9 +43,28 @@ impl Access {
/// The folders file access is confined to; the default confines nothing. /// The folders file access is confined to; the default confines nothing.
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Roots { pub struct Roots {
/// `None` while unconfined; otherwise the resolved read and write roots (each `None` when /// `None` while unconfined; otherwise what each of read and write access is granted.
/// that access is not granted). confined: Option<[Grant; 2]>,
confined: Option<[Option<PathBuf>; 2]>, }
/// What one kind of access is granted while confined.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Grant {
/// Not granted.
None,
/// Granted anywhere.
Anywhere,
/// Granted inside this resolved folder.
Within(PathBuf),
}
fn resolve_root(p: &Path, access: Access) -> Result<PathBuf> {
let shown = p.display();
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
if !dir.is_dir() {
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
}
Ok(dir)
} }
impl Roots { impl Roots {
@@ -55,16 +76,15 @@ impl Roots {
/// Confine reads to `read` and writes to `write`. A root left out grants none of its access. /// Confine reads to `read` and writes to `write`. A root left out grants none of its access.
/// Each root must be an existing folder; it is resolved (links followed) once, here. /// Each root must be an existing folder; it is resolved (links followed) once, here.
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Roots> { pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Roots> {
let resolve = |p: Option<&Path>, access: Access| -> Result<Option<PathBuf>> { let grant = |p: Option<&Path>, access: Access| -> Result<Grant> {
let Some(p) = p else { return Ok(None) }; Ok(p.map(|p| resolve_root(p, access)).transpose()?.map_or(Grant::None, Grant::Within))
let shown = p.display();
let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?;
if !dir.is_dir() {
return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name())));
}
Ok(Some(dir))
}; };
Ok(Roots { confined: Some([resolve(read, Access::Read)?, resolve(write, Access::Write)?]) }) Ok(Roots { confined: Some([grant(read, Access::Read)?, grant(write, Access::Write)?]) })
}
/// Reads anywhere, writes only inside `write` (an existing folder, resolved here).
pub fn writes_within(write: &Path) -> Result<Roots> {
Ok(Roots { confined: Some([Grant::Anywhere, Grant::Within(resolve_root(write, Access::Write)?)]) })
} }
/// Whether any access is confined. /// Whether any access is confined.
@@ -72,10 +92,12 @@ impl Roots {
self.confined.is_some() self.confined.is_some()
} }
/// The root of `access`, when confined and granted. /// The root of `access`, when confined to one.
pub fn root(&self, access: Access) -> Option<&Path> { pub fn root(&self, access: Access) -> Option<&Path> {
let roots = self.confined.as_ref()?; match self.confined.as_ref()?.get(access as usize)? {
roots.get(access as usize).and_then(|r| r.as_deref()) Grant::Within(root) => Some(root),
Grant::None | Grant::Anywhere => None,
}
} }
/// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path /// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path
@@ -84,8 +106,12 @@ impl Roots {
pub fn check(&self, path: &Path, access: Access) -> Result<PathBuf> { pub fn check(&self, path: &Path, access: Access) -> Result<PathBuf> {
let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) }; let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) };
let shown = path.display(); let shown = path.display();
let Some(root) = roots.get(access as usize).and_then(|r| r.as_deref()) else { let root = match roots.get(access as usize) {
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name()))); Some(Grant::Anywhere) => return Ok(path.to_path_buf()),
Some(Grant::Within(root)) => root,
Some(Grant::None) | None => {
return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name())));
}
}; };
let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?; let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?;
if !resolved.starts_with(root) { if !resolved.starts_with(root) {
@@ -209,6 +235,18 @@ mod tests {
assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder"); assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder");
} }
#[test]
fn writes_within_reads_anywhere() {
let d = Dir::new("writes");
let r = Roots::writes_within(&d.0).unwrap();
assert!(r.is_confined());
assert_eq!(r.check(Path::new("/etc/passwd"), Access::Read).unwrap(), Path::new("/etc/passwd"));
assert!(r.check(Path::new("/etc/passwd"), Access::Write).is_err());
assert_eq!(r.check(&d.0.join("x.inp"), Access::Write).unwrap(), d.0.join("x.inp"));
assert_eq!(r.root(Access::Write), Some(d.0.as_path()));
assert_eq!(r.root(Access::Read), None);
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn links_are_followed() { fn links_are_followed() {