diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs index fbf80ed9e..58edc6aa4 100644 --- a/crates/engine/src/cmd/book.rs +++ b/crates/engine/src/cmd/book.rs @@ -28,7 +28,7 @@ fn no_book() -> EngineError { #[cfg(not(target_arch = "wasm32"))] fn save(b: &Book) -> Result<()> { let bytes = serde_json::to_vec_pretty(b).map_err(|e| EngineError::Other(e.to_string()))?; - crate::atomic::write_file(&b.path, &bytes) + crate::io::write_file(&b.path, &bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] @@ -38,14 +38,14 @@ fn save(_: &Book) -> Result<()> { fn load(path: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let bytes: Vec = { let _ = path; vec![] }; if path.to_lowercase().ends_with(".idml") { - return designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); } super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -248,7 +248,7 @@ pub fn specs() -> Vec { cmd!(noundo "book.open", "Open Book", [], None, "{path} → {documents}", always, |s, p| { let path = str_param(p, "path").ok_or_else(|| bad("book.open", "`path` required"))?.to_string(); #[cfg(not(target_arch = "wasm32"))] - let text = std::fs::read_to_string(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let text = crate::io::read_to_string(&path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let text = String::new(); let mut b: Book = serde_json::from_str(&text).map_err(|e| bad("book.open", format!("not a book: {e}")))?; @@ -331,7 +331,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages})), diff --git a/crates/engine/src/cmd/colorsettings.rs b/crates/engine/src/cmd/colorsettings.rs index c62b393e9..fc1d062a5 100644 --- a/crates/engine/src/cmd/colorsettings.rs +++ b/crates/engine/src/cmd/colorsettings.rs @@ -45,7 +45,8 @@ pub fn specs() -> Vec { let path = str_param(p, "path").ok_or_else(|| bad("color.loadProfile", "`path` required"))?; #[cfg(not(target_arch = "wasm32"))] { - let info = cms::load_icc_file(std::path::Path::new(path)).map_err(|e| bad("color.loadProfile", e.to_string()))?; + let bytes = crate::io::read_file(path).map_err(|e| bad("color.loadProfile", e))?; + let info = cms::register_icc(&bytes, None).map_err(|e| bad("color.loadProfile", e.to_string()))?; Ok(json!(info)) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index 89dd0c74e..6ecb1470d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -340,11 +340,11 @@ fn place_image( let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { // Refuse huge files before reading them; a too-large image counts as missing. - let len = std::fs::metadata(p).ok()?.len(); + let len = crate::io::metadata(p).ok()?.len(); if len == 0 || len > super::MAX_IMAGE_FILE_BYTES { return None; } - std::fs::read(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) + crate::io::read_file(p).ok().filter(|b| !b.is_empty()).map(|b| (p.clone(), b)) }); let Some((path, bytes)) = found else { if let Some(it) = doc.item_mut(item) { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 3b3e1dd3b..eda169b12 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -619,7 +619,7 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) { /// again as `relative_path` beside the document. pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) { for src in &mut doc.data_merge.sources { - let abs_exists = src.path.as_deref().is_some_and(|p| Path::new(p).is_file()); + let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file); if abs_exists { if let Some(path) = src.path.clone() { src.status = status_of(Path::new(&path), src.fingerprint); @@ -630,7 +630,7 @@ pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path && let Some(dir) = doc_path.parent() { let candidate = dir.join(&rel); - if candidate.is_file() { + if crate::io::is_file(&candidate) { let saved = candidate.to_string_lossy().into_owned(); src.fingerprint = fingerprint_of(Path::new(&saved)); src.status = SourceStatus::Ok; @@ -768,12 +768,14 @@ fn read_path(path: &str) -> std::result::Result, String> { } fn read_path_capped(path: &str, cap: u64) -> std::result::Result, String> { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Ok(m) if m.len() > cap => return Err(format!("{path}: the data file is larger than {} MiB", cap / (1024 * 1024))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(format!("{path}: the data file is missing")), _ => {} } - match std::fs::read(path) { + // The roots' own message when they refuse the path. + crate::file_access::check_read(path)?; + match crate::io::read(path) { Ok(b) => Ok(b), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(format!("{path}: the data file is missing")), Err(e) => Err(format!("{path}: {e}")), diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index e65493dfc..689f45ca6 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String { } pub fn fingerprint_of(path: &Path) -> Option { - let meta = std::fs::metadata(path).ok()?; + let meta = crate::io::metadata(path).ok()?; let mtime = meta .modified() .ok() diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 8e0f04d69..967349a4a 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &r.bytes)?; + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) } None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), @@ -59,7 +59,7 @@ fn export_html(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -91,7 +91,7 @@ fn export_text(s: &mut Session, p: &Value) -> Result { match path { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"bytes": text.len(), "text": text})), @@ -160,7 +160,7 @@ fn export_epub(s: &mut Session, p: &Value) -> Result { match p.get("path").and_then(Value::as_str) { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; Ok(serde_json::json!({"path": path, "bytes": bytes.len()})) } None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len()})), @@ -598,7 +598,7 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::atomic::write_file(path, bytes) + crate::io::write_file(path, bytes).map_err(EngineError::Other) } #[cfg(target_arch = "wasm32")] fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 692c363e8..587855415 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -29,20 +29,21 @@ pub fn specs() -> Vec { super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); let bytes = to_bytes(©); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) }), cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p)?; + let dir = recovery_dir(s, p, FolderAccess::Read)?; Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) }), cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save(s, &dir)?})) }), cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - let dir = recovery_dir(s, p)?; + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; Ok(json!({"opened": crate::recovery::open(s, &dir)?})) }), cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { @@ -164,7 +165,7 @@ fn file_open(s: &mut Session, p: &Value) -> Result { if path.to_ascii_lowercase().ends_with(".idml") { return super::interchange::open_idml(s, p); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; let mut d = from_bytes(&bytes)?; super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent()); super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); @@ -189,6 +190,11 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option (Option std::path::PathBuf { use designcraft_fonts::DOCUMENT_FONTS_FOLDER; let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if exact.is_dir() { + if crate::io::is_dir(&exact) { return exact; } - std::fs::read_dir(dir) + crate::io::read_dir(dir) .into_iter() .flatten() .flatten() - .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && e.path().is_dir()) + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) .map_or(exact, |e| e.path()) } @@ -239,6 +245,8 @@ fn file_save(s: &mut Session, p: &Value) -> Result { .map(str::to_string) .or_else(|| st.path.clone()) .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; // Prepare paths for the destination without changing the live document until persistence succeeds. let mut saved = (*st.doc).clone(); super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); @@ -246,7 +254,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result { let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; let bytes = to_bytes(&saved); #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(&path, &bytes)?; + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; if paths_changed { st.doc = saved; st.revision = st.revision.saturating_add(1); @@ -268,7 +276,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result { (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -435,7 +443,7 @@ pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option) } let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = vec![]; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); @@ -522,7 +530,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), @@ -534,7 +542,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result { fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { const ID: &str = "file.place"; let src = if name.to_lowercase().ends_with(".idml") { - designcraft_idml::import_idml(bytes).map_err(|e| bad(ID, e.to_string()))? + super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))? } else { from_bytes(bytes)? }; @@ -612,17 +620,36 @@ pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { }) } -fn recovery_dir(s: &Session, p: &Value) -> Result { +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { #[cfg(target_arch = "wasm32")] { - let _ = (s, p); + let _ = (s, p, access); Err(EngineError::Other("no recovery folder on the web".into())) } #[cfg(not(target_arch = "wasm32"))] - str_param(p, "dir") - .map(std::path::PathBuf::from) - .or_else(|| s.recovery_dir.clone()) - .ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")) + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } } fn can_revert(s: &Session) -> std::result::Result<(), String> { @@ -639,10 +666,10 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] { let mut d = if path.to_ascii_lowercase().ends_with(".idml") { - let bytes = std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - designcraft_idml::import_idml(&bytes).map_err(|e| EngineError::Other(e.to_string()))? + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))? } else { - from_bytes(&std::fs::read(&path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?)? + from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? }; // As opened again: the fonts beside it now (unchanged files are shared, not read again). let (fonts, _, _) = load_document_fonts(&mut d, &path); diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index 96498e863..9b9520662 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, &bytes)?; + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), } } +/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the +/// automation read root applies to the paths the package names. +pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result { + designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok()) +} + /// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup). pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result { import_report(bytes, dir).map(|(document, _)| document) @@ -112,10 +118,10 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: let Some(dir) = dir else { return }; for asset in document.assets.values_mut() { let Some(link) = asset.link.as_deref() else { continue }; - if link.len() > MAX_LINK_PATH_BYTES || std::path::Path::new(link).is_file() { + if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) { continue; } - let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| p.is_file()) else { continue }; + let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue }; // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI. let path = std::path::absolute(&path).unwrap_or(path); std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned()); @@ -125,7 +131,7 @@ pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std:: #[cfg(not(target_arch = "wasm32"))] fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> { for path in packaged_link_candidates(link, dir) { - if let Ok(bytes) = std::fs::read(&path) { + if let Ok(bytes) = crate::io::read_file(&path) { // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize // would introduce a verbatim prefix that is not an IDML file URI. let path = std::path::absolute(&path).unwrap_or(path); @@ -140,7 +146,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result { (base64_decode(b), None, str_param(p, "name").map(|n| n.trim_end_matches(".idml").to_string())) } else if let Some(path) = str_param(p, "path") { #[cfg(not(target_arch = "wasm32"))] - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; #[cfg(target_arch = "wasm32")] let b: Vec = Vec::new(); let pp = std::path::Path::new(path); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 384b4f827..9897885d2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -29,7 +29,7 @@ impl Library { #[cfg(not(target_arch = "wasm32"))] if let Some(p) = &self.path { let bytes = serde_json::to_vec_pretty(self).map_err(|e| crate::EngineError::Other(e.to_string()))?; - crate::atomic::write_file(p, &bytes)?; + crate::io::write_file(p, &bytes).map_err(crate::EngineError::Other)?; } Ok(()) } @@ -56,7 +56,7 @@ pub fn specs() -> Vec { (Some(j), _) => (j.to_string(), None), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = String::new(); (t, Some(path.to_string())) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 73de3ea7e..0effead6b 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -93,11 +93,11 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str { let Some(path) = &a.link else { return "embedded" }; #[cfg(not(target_arch = "wasm32"))] { - match std::fs::metadata(path) { + match crate::io::metadata(path) { Err(_) => "missing", Ok(m) if m.len() as usize != a.data.len() => "modified", // Same size: compare the bytes (cheap for the sizes involved, and exact). - Ok(_) => match std::fs::read(path) { + Ok(_) => match crate::io::read_file(path) { Ok(b) if b == *a.data => "ok", Ok(_) => "modified", Err(_) => "missing", @@ -145,7 +145,7 @@ fn list(d: &Document) -> Vec { #[cfg(not(target_arch = "wasm32"))] fn reload(path: &str) -> Result<(Vec, (u32, u32))> { - let b = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let b = crate::io::read_file(path).map_err(EngineError::Other)?; let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?; Ok((b, px)) } @@ -174,6 +174,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { fn relink_folder(s: &mut Session, p: &Value) -> Result { let dir = str_param(p, "dir").map(std::path::PathBuf::from); + if let Some(dir) = &dir { + // Refused outright: candidates outside the automation read root would only look missing. + crate::file_access::check_read(dir).map_err(EngineError::Other)?; + } let ext = str_param(p, "extension").map(|e| e.trim_start_matches('.').to_string()); if dir.is_none() && ext.is_none() { return Err(bad("links.relinkFolder", "give `dir` and/or `extension`")); @@ -194,7 +198,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result { } let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default(); let cand = folder.join(&name); - if cand.exists() { + if crate::io::exists(&cand) { todo.push((a.id, cand)); } else { not_found.push(name.to_string_lossy().to_string()); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 25f09537c..300e7619d 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -74,6 +74,8 @@ struct OutputRoot { impl OutputRoot { fn new(path: PathBuf) -> Result { + // Before anything is created: the folder must be inside the automation write root. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; ensure_real_directory(&path, false)?; let canonical = std::fs::canonicalize(&path).map_err(|e| path_error(&path, e))?; Ok(Self { path, canonical }) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 4bf69ec41..c6ca1156c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -809,7 +809,7 @@ fn value_name(color: designcraft_color::Color) -> String { fn load_swatches(s: &mut Session, p: &Value) -> Result { let bytes = match (str_param(p, "path"), str_param(p, "base64")) { (_, Some(b)) => super::file::base64_decode(b), - (Some(path), None) => std::fs::read(path).map_err(|e| bad("swatch.load", format!("{path}: {e}")))?, + (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, _ => return Err(bad("swatch.load", "give `path` or `base64`")), }; let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; @@ -844,11 +844,7 @@ fn save_swatches(s: &mut Session, p: &Value) -> Result { let bytes = designcraft_color::ase::write(&chosen); match str_param(p, "path") { Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - let written = crate::atomic::write_atomic(std::path::Path::new(path), &bytes); - #[cfg(target_arch = "wasm32")] - let written = std::fs::write(path, &bytes); - written.map_err(|e| bad("swatch.save", format!("{path}: {e}")))?; + crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; Ok(json!({"path": path, "bytes": bytes.len()})) } None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), diff --git a/crates/engine/src/cmd/xml.rs b/crates/engine/src/cmd/xml.rs index 6dab205fa..0b3f631e9 100644 --- a/crates/engine/src/cmd/xml.rs +++ b/crates/engine/src/cmd/xml.rs @@ -305,7 +305,7 @@ pub fn specs() -> Vec { match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] - crate::atomic::write_file(path, text.as_bytes())?; + crate::io::write_file(path, text.as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path, "bytes": text.len()})) } None => Ok(json!({"text": text, "bytes": text.len()})), @@ -385,7 +385,7 @@ fn text_or_path(p: &Value, id: &str) -> Result { (Some(t), _) => t.to_string(), (None, Some(path)) => { #[cfg(not(target_arch = "wasm32"))] - let t = std::fs::read_to_string(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + let t = crate::io::read_to_string(path).map_err(crate::EngineError::Other)?; #[cfg(target_arch = "wasm32")] let t = { let _ = path; diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 000000000..03ffd7c94 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,515 @@ +//! The folders automation may read and write. +//! +//! `designcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command +//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`designcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { + let path = path.as_ref(); + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path, Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display())); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display())); + } + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display())); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.designcraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(s(base.join("in/sub/new.designcraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(s(base.join("in/a.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.designcraft"), "x").unwrap(); + assert!(roots.check(s(base.join("in2/b.designcraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(format!("{}2/c.designcraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.designcraft", s(base.join("in"))); + assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.designcraft", s(base.join("in/sub"))); + assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.designcraft", s(base.join("out"))); + let e = roots.check(&missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.designcraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.designcraft"), &base.join("in/leak.designcraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(s(base.join("in/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(s(base.join("out/away/new.png")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.designcraft"), &base.join("out/dangling.png"))); + let e = roots.check(s(base.join("out/dangling.png")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.designcraft")); + assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(s(base.join("In/Sub/New.designcraft")), Access::Read), Ok(())); + let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.designcraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.designcraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.designcraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, + /// `write_file`, `metadata`, `read_dir`...), which check the roots. Engine code that reaches + /// for `std::fs` itself would bypass them, so none may: every file under `src/` (commands, + /// `lib.rs`, `guard.rs`...) except + /// + /// - `io.rs` and this module, the checked gateway, + /// - `recovery.rs`, which keeps the unsaved documents in a folder the host application + /// chooses (a command's own `dir` is checked in `cmd/file.rs` before it gets there), + /// - `cmd/package.rs`, whose `OutputRoot` checks the folder against the write root before it + /// creates anything and then only writes inside it. Its reads are of the font files that + /// draw the document's text (installed fonts and the `Document Fonts` folder), never of a + /// path a command names, + /// - the test files. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + /// The file's lines but its test modules (which build their own fixtures): a module is + /// skipped from its `#[cfg(test)]` to the closing brace at the same indent. + fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { + let lines: Vec<&str> = text.lines().collect(); + let mut code = vec![]; + let mut i = 0; + while i < lines.len() { + let attr = lines[i].trim(); + let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); + if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let header = lines[i + 1]; + i += 2; + if header.trim_end().ends_with('{') { + let indent = &header[..header.len() - header.trim_start().len()]; + let close = format!("{indent}}}"); + while i < lines.len() && lines[i].trim_end() != close { + i += 1; + } + i += 1; + } + continue; + } + code.push((i + 1, lines[i])); + i += 1; + } + code + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let rel = path.strip_prefix(&src).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + if matches!(rel.as_str(), "io.rs" | "file_access.rs" | "recovery.rs" | "cmd/package.rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for (n, line) in code_outside_tests(&text) { + let line = line.trim_start(); + let touches = + ["std::fs", "File::open", "File::create", "OpenOptions", ".is_file()", ".is_dir()", ".exists()"].iter().any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use crate::io (read_file, write_file, metadata, read_dir...): {offenders:#?}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 000000000..4fa214750 --- /dev/null +++ b/crates/engine/src/io.rs @@ -0,0 +1,71 @@ +//! Reading and writing the files a command names by path. +//! +//! Every such read and write goes through this module, so the automation roots +//! ([`crate::file_access`]) are checked in one place. A command module that called `std::fs` +//! itself would bypass them: a test in `file_access` keeps that from happening. +//! +//! A path the roots refuse reads as a file that isn't there where the caller only asks whether +//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where +//! it asks for the bytes. + +use std::fs::Metadata; +use std::path::Path; + +/// The bytes of the file at `path`, inside the automation read root when there is one. +pub fn read_file(path: impl AsRef) -> Result, String> { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// [`read_file`] with the file system's own error, for callers that tell "missing" from other +/// failures. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn read(path: impl AsRef) -> std::io::Result> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read(path) +} + +/// The text of the file at `path`, inside the automation read root when there is one. +pub fn read_to_string(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::file_access::check_read(path)?; + std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> { + let path = path.as_ref(); + crate::file_access::check_write(path)?; + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +/// What the file system says about `path` (links followed), inside the automation read root when +/// there is one. A refused path is a `PermissionDenied` error carrying the roots' message. +pub fn metadata(path: impl AsRef) -> std::io::Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::metadata(path) +} + +/// Is there a file at `path` that automation may read? +pub fn is_file(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_file()) +} + +/// Is there a folder at `path` that automation may read? +pub fn is_dir(path: impl AsRef) -> bool { + metadata(path).is_ok_and(|m| m.is_dir()) +} + +/// Is there anything at `path` that automation may read? +pub fn exists(path: impl AsRef) -> bool { + metadata(path).is_ok() +} + +/// The entries of the folder at `path`, inside the automation read root when there is one. +pub fn read_dir(path: impl AsRef) -> std::io::Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + std::fs::read_dir(path) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2165eebc4..1f06cbade 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,7 +11,9 @@ pub mod atomic; pub mod cmd; pub mod dtd; +pub mod file_access; pub mod guard; +pub mod io; pub mod links; pub mod math; pub mod recovery; @@ -558,4 +560,6 @@ mod tests; #[cfg(test)] mod tests_idml; #[cfg(test)] +mod tests_roots; +#[cfg(test)] mod tests_table; diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs new file mode 100644 index 000000000..443403b0a --- /dev/null +++ b/crates/engine/src/tests_roots.rs @@ -0,0 +1,212 @@ +//! The automation roots at the engine: the files a document names (links, data sources, book +//! chapters, packaged fonts) are read and written through the same checks as the paths a command +//! is given. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::file_access::{AutomationRoots, confine}; +use crate::{Session, cmd}; + +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, + 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]; + +/// A fresh folder with `in` (the read root), `out` (the write root) and `outside`, and the roots. +fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("dc-engine-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) +} + +fn p(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +/// A document with `pic.png` placed from `dir`, so the asset is embedded and linked to the file. +fn linked_picture(dir: &Path) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": p(&dir.join("pic.png")), "x": 36, "y": 36, "width": 72})).unwrap(); + s +} + +fn link_status(s: &mut Session) -> Value { + s.execute("links.list", &json!({})).unwrap()[0]["status"].clone() +} + +#[test] +fn a_link_outside_the_read_root_reads_as_missing() { + let (base, roots) = folders("links"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut outside = linked_picture(&base.join("outside")); + let mut inside = linked_picture(&base.join("in")); + assert_eq!(link_status(&mut outside), "ok"); + confine(Some(&roots), || { + assert_eq!(link_status(&mut outside), "missing", "the file is not the agent's to look at, not even to compare"); + assert_eq!(link_status(&mut inside), "ok"); + assert!(outside.execute("links.relinkFolder", &json!({"dir": p(&base.join("outside"))})).is_err()); + // A relink to a link that leads out is refused too, and the asset keeps what it had. + let asset = outside.execute("links.list", &json!({})).unwrap()[0]["asset"].clone(); + let e = outside.execute("links.relink", &json!({"asset": asset, "path": p(&base.join("outside/pic.png"))})).unwrap_err().to_string(); + assert!(e.contains("outside the read root"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn idml_links_are_read_through_the_roots() { + let (base, roots) = folders("idml"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("outside")); + // Linked, not embedded: opening the package has to read the file its link names. + let idml = base64_idml(&mut s); + let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count(); + let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap(); + assert_eq!(linked(&unconfined), 1, "without roots the link is followed"); + let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap(); + assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read"); + // The same through the commands that open or place a package. + let b64 = cmd::base64_encode(&idml); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + confine(Some(&roots), || { + t.execute("file.openBytes", &json!({"name": "x.idml", "base64": b64})).unwrap(); + let doc = &t.doc().unwrap().doc; + assert_eq!(linked(doc), 0); + t.execute("file.place", &json!({"base64": b64, "name": "x.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(linked(&t.doc().unwrap().doc), 0); + }); + let _ = std::fs::remove_dir_all(base); +} + +fn base64_idml(s: &mut Session) -> Vec { + let r = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap(); + cmd::base64_decode(r["base64"].as_str().unwrap()) +} + +#[test] +fn a_merge_does_not_read_image_files_outside_the_read_root() { + let (base, roots) = folders("merge"); + std::fs::write(base.join("outside/pic.png"), PNG).unwrap(); + std::fs::write(base.join("in/data.csv"), format!("@Photo\n{}\n", base.join("outside/pic.png").display())).unwrap(); + let merge = |s: &mut Session| { + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("in/data.csv"))})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field": "Photo", "item": frame["id"]})).unwrap(); + s.execute("data.merge", &json!({})).unwrap()["missingImages"].as_array().unwrap().len() + }; + assert_eq!(merge(&mut Session::new()), 0, "without roots the picture is found"); + assert_eq!(confine(Some(&roots), || merge(&mut Session::new())), 1, "with roots it reads as missing"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_data_source_path_stored_in_a_document_is_read_through_the_roots() { + let (base, roots) = folders("source"); + std::fs::write(base.join("outside/data.csv"), "name\nTOP SECRET\n").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("data.source.select", &json!({"path": p(&base.join("outside/data.csv"))})).unwrap(); + confine(Some(&roots), || { + let e = s.execute("data.source.update", &json!({})).unwrap_err().to_string(); + assert!(e.contains("outside the read root") && !e.contains("TOP SECRET"), "{e}"); + }); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn a_book_writes_its_chapters_only_inside_the_write_root() { + let (base, roots) = folders("book"); + let chapter = base.join("in/a.designcraft"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": p(&chapter)})).unwrap(); + let second = base.join("in/b.designcraft"); + s.execute("file.saveAs", &json!({"path": p(&second)})).unwrap(); + let before = (std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()); + confine(Some(&roots), || { + s.execute("book.new", &json!({"path": p(&base.join("out/b.dcbook"))})).unwrap(); + s.execute("book.add", &json!({"path": p(&chapter)})).unwrap(); + s.execute("book.add", &json!({"path": p(&second)})).unwrap(); + for command in ["book.paginate", "book.syncStyles", "book.exportPdf"] { + let r = s.execute(command, &json!({"path": p(&base.join("outside/book.pdf"))})); + let e = r.unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + let e = s.execute("book.new", &json!({"path": p(&base.join("outside/b.dcbook"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + }); + assert_eq!((std::fs::read(&chapter).unwrap(), std::fs::read(&second).unwrap()), before, "no chapter was rewritten"); + assert!(!base.join("outside/b.dcbook").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn package_and_copy_links_create_nothing_outside_the_write_root() { + let (base, roots) = folders("package"); + std::fs::write(base.join("in/pic.png"), PNG).unwrap(); + let mut s = linked_picture(&base.join("in")); + confine(Some(&roots), || { + for command in ["file.package", "links.copyTo"] { + let e = s.execute(command, &json!({"dir": p(&base.join("outside/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + // The read root is not writable either. + let e = s.execute(command, &json!({"dir": p(&base.join("in/pkg"))})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{command}: {e}"); + } + s.execute("file.package", &json!({"dir": p(&base.join("out/pkg"))})).unwrap(); + s.execute("links.copyTo", &json!({"dir": p(&base.join("out/copies"))})).unwrap(); + }); + assert!(!base.join("outside/pkg").exists() && !base.join("in/pkg").exists()); + assert!(base.join("out/pkg/Instructions.txt").exists() && base.join("out/copies/pic.png").exists()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn recovery_folders_named_by_a_command_are_checked() { + let (base, roots) = folders("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + confine(Some(&roots), || { + let out = p(&base.join("outside")); + let inside = p(&base.join("in")); + let to_write = p(&base.join("out")); + let e = s.execute("file.recovery.save", &json!({"dir": out})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + let e = s.execute("file.recovery.save", &json!({"dir": inside})).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(s.execute("file.recovery.list", &json!({"dir": out})).unwrap_err().to_string().contains("outside the read root")); + // Reopening removes what it read, so it needs both. + assert!(s.execute("file.recovery.open", &json!({"dir": inside})).unwrap_err().to_string().contains("outside the write root")); + assert!(s.execute("file.recovery.open", &json!({"dir": to_write})).unwrap_err().to_string().contains("outside the read root")); + assert_eq!(s.execute("file.recovery.save", &json!({"dir": to_write})).unwrap()["saved"], 1); + }); + assert!(std::fs::read_dir(base.join("outside")).unwrap().next().is_none()); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn fonts_beside_a_document_outside_the_read_root_are_not_loaded() { + let (base, roots) = folders("fonts"); + std::fs::create_dir_all(base.join("outside/Document Fonts")).unwrap(); + std::fs::write(base.join("outside/Document Fonts/x.ttf"), b"not a font").unwrap(); + let mut d = designcraft_doc::Document::new(&designcraft_doc::build::NewDocument::default()); + let (_, faces, warnings) = confine(Some(&roots), || cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft")))); + assert_eq!(faces, 0); + assert!(warnings.iter().any(|w| w.contains("outside the read root")), "{warnings:?}"); + let (_, _, warnings) = cmd::load_document_fonts(&mut d, &p(&base.join("outside/doc.designcraft"))); + assert!(warnings.iter().any(|w| w.contains("x.ttf")), "without roots the folder is read and the bad font reported: {warnings:?}"); + let _ = std::fs::remove_dir_all(base); +}