mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Engine: check automation read and write roots on every path a command reads or writes
This commit is contained in:
@@ -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<designcraft_doc::Document> {
|
||||
#[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<u8> = {
|
||||
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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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})),
|
||||
|
||||
@@ -45,7 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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")]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn read_path_capped(path: &str, cap: u64) -> std::result::Result<Vec<u8>, 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}")),
|
||||
|
||||
@@ -330,7 +330,7 @@ pub fn relative_between(from_dir: &Path, target: &Path) -> String {
|
||||
}
|
||||
|
||||
pub fn fingerprint_of(path: &Path) -> Option<Fingerprint> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
let meta = crate::io::metadata(path).ok()?;
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
|
||||
@@ -44,7 +44,7 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
#[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<()> {
|
||||
|
||||
@@ -29,20 +29,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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<Arc<c
|
||||
{
|
||||
use designcraft_fonts::DOCUMENT_FONTS_FOLDER;
|
||||
let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) };
|
||||
// A document's folder is the agent's to name (a recovery entry or a book lists paths too):
|
||||
// fonts outside the automation read root stay unloaded.
|
||||
if let Err(e) = crate::file_access::check_read(&folder) {
|
||||
return (None, 0, vec![format!("{DOCUMENT_FONTS_FOLDER}: {e}")]);
|
||||
}
|
||||
let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder);
|
||||
d.font_scope = r.scope;
|
||||
let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope)));
|
||||
@@ -207,14 +213,14 @@ pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option<Arc<c
|
||||
fn document_fonts_folder(dir: &std::path::Path) -> 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<Value> {
|
||||
.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<Value> {
|
||||
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<Value> {
|
||||
(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<u8> = 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<u8>, String, Option<String>)
|
||||
}
|
||||
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<u8> = 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<Value> {
|
||||
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<Value> {
|
||||
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result<Value> {
|
||||
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<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn recovery_dir(s: &Session, p: &Value) -> Result<std::path::PathBuf> {
|
||||
/// 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<std::path::PathBuf> {
|
||||
#[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<Value> {
|
||||
#[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);
|
||||
|
||||
@@ -25,13 +25,19 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Document> {
|
||||
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<Document> {
|
||||
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<u8>)> {
|
||||
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<Value> {
|
||||
(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<u8> = Vec::new();
|
||||
let pp = std::path::Path::new(path);
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
(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()))
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reload(path: &str) -> Result<(Vec<u8>, (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<Value> {
|
||||
|
||||
fn relink_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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());
|
||||
|
||||
@@ -74,6 +74,8 @@ struct OutputRoot {
|
||||
|
||||
impl OutputRoot {
|
||||
fn new(path: PathBuf) -> Result<Self> {
|
||||
// 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 })
|
||||
|
||||
@@ -809,7 +809,7 @@ fn value_name(color: designcraft_color::Color) -> String {
|
||||
fn load_swatches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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)})),
|
||||
|
||||
@@ -305,7 +305,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<String> {
|
||||
(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;
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `designcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` 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<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[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<Self, String> {
|
||||
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<Option<Arc<Self>>, 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<Path>, 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<AutomationRoots>),
|
||||
Unconfined,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
||||
static PROCESS: OnceLock<Arc<AutomationRoots>> = 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<AutomationRoots>) -> bool {
|
||||
PROCESS.set(roots).is_ok()
|
||||
}
|
||||
|
||||
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_scope<T>(scope: Option<Scope>, 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<T>(roots: Option<&Arc<AutomationRoots>>, 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<T>(f: impl FnOnce() -> T) -> T {
|
||||
with_scope(Some(Scope::Unconfined), f)
|
||||
}
|
||||
|
||||
/// The roots in force on this thread, if any.
|
||||
pub fn current() -> Option<Arc<AutomationRoots>> {
|
||||
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<Path>) -> 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<Path>) -> 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<PathBuf, String> {
|
||||
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<OsString> = 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<AutomationRoots>) {
|
||||
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<PathBuf>) {
|
||||
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:#?}");
|
||||
}
|
||||
}
|
||||
@@ -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<Path>) -> Result<Vec<u8>, 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<Path>) -> std::io::Result<Vec<u8>> {
|
||||
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<Path>) -> Result<String, String> {
|
||||
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<Path>, 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<Path>) -> std::io::Result<Metadata> {
|
||||
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<Path>) -> 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<Path>) -> 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<Path>) -> 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<Path>) -> std::io::Result<std::fs::ReadDir> {
|
||||
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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<AutomationRoots>) {
|
||||
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<u8> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user