diff --git a/crates/io/src/recovery.rs b/crates/io/src/recovery.rs index 97f3b43..e45dae6 100644 --- a/crates/io/src/recovery.rs +++ b/crates/io/src/recovery.rs @@ -32,26 +32,36 @@ pub fn automation_dir() -> PathBuf { /// Set by [`use_automation_dir`]. static AUTOMATED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); -/// This process is driven by automation (a control channel): unless `FIELDCRAFT_RECOVERY_DIR` -/// names a folder, [`default_dir`] is [`automation_dir`] from now on. +/// This process is driven by automation (a control channel): [`data_dir`] is the temporary +/// `fieldcraft-automation` folder from now on, so unless `FIELDCRAFT_RECOVERY_DIR` names a +/// folder, [`default_dir`] is [`automation_dir`]. pub fn use_automation_dir() { let _ = AUTOMATED.set(()); } -/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else [`automation_dir`] in automated runs -/// ([`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); +/// The recovery folder: `FIELDCRAFT_RECOVERY_DIR`, else `recovery` in [`data_dir`]; /// [`BROWSER_DIR`] in the browser. pub fn default_dir() -> Option { if cfg!(target_arch = "wasm32") { 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) = env("FIELDCRAFT_RECOVERY_DIR") { - return Some(d); + if let Some(d) = std::env::var_os("FIELDCRAFT_RECOVERY_DIR").filter(|d| !d.is_empty()) { + return Some(PathBuf::from(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 { + 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() { - return Some(automation_dir()); + return automation_dir().parent().map(Path::to_path_buf); } let (root, name) = if cfg!(windows) { (env("LOCALAPPDATA")?, "FieldCraft") @@ -60,7 +70,7 @@ pub fn default_dir() -> Option { } else { (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`]). @@ -338,6 +348,11 @@ impl Slot { 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). pub fn clear(&mut self) { if self.has_entry { @@ -458,6 +473,7 @@ mod tests { use_automation_dir(); assert_eq!(default_dir(), Some(automation_dir())); assert!(automation_dir().starts_with(std::env::temp_dir())); + assert_eq!(data_dir().map(|d| d.join("recovery")), Some(automation_dir())); } #[test] diff --git a/crates/io/src/roots.rs b/crates/io/src/roots.rs index 5b3d9d1..e434422 100644 --- a/crates/io/src/roots.rs +++ b/crates/io/src/roots.rs @@ -12,7 +12,9 @@ //! - The result is compared with the root by whole folder names: `/work2/x` is not inside //! `/work`. //! - 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 //! 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. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Roots { - /// `None` while unconfined; otherwise the resolved read and write roots (each `None` when - /// that access is not granted). - confined: Option<[Option; 2]>, + /// `None` while unconfined; otherwise what each of read and write access is granted. + confined: Option<[Grant; 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 { + 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 { @@ -55,16 +76,15 @@ impl Roots { /// 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. pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result { - let resolve = |p: Option<&Path>, access: Access| -> Result> { - let Some(p) = p else { return Ok(None) }; - 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)) + let grant = |p: Option<&Path>, access: Access| -> Result { + Ok(p.map(|p| resolve_root(p, access)).transpose()?.map_or(Grant::None, Grant::Within)) }; - 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 { + Ok(Roots { confined: Some([Grant::Anywhere, Grant::Within(resolve_root(write, Access::Write)?)]) }) } /// Whether any access is confined. @@ -72,10 +92,12 @@ impl Roots { 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> { - let roots = self.confined.as_ref()?; - roots.get(access as usize).and_then(|r| r.as_deref()) + match self.confined.as_ref()?.get(access as usize)? { + 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 @@ -84,8 +106,12 @@ impl Roots { pub fn check(&self, path: &Path, access: Access) -> Result { let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) }; let shown = path.display(); - let Some(root) = roots.get(access as usize).and_then(|r| r.as_deref()) else { - return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name()))); + let root = match roots.get(access as usize) { + 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}")))?; 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"); } + #[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)] #[test] fn links_are_followed() {