mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Fix up #247: route main's other file access through the checked io layer
design.browseTheme reads its path with io::read_file, and File › Info sizes the file with io::file_len (which checks the read root), so commands_do_not_touch_the_file_system_themselves passes. spell.rs is allow-listed beside recovery.rs: it touches only the app's own dictionary folders. main's render_slide MCP tool refuses a `path` argument (#371), so the root tests render to a file through call_app ui.render instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9fc910886c
commit
0dfc9ccf6f
@@ -152,7 +152,7 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?;
|
||||
std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?
|
||||
crate::io::read_file(path).map_err(crate::EngineError::Other)?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("design.browseTheme", "give `data`"));
|
||||
|
||||
@@ -476,7 +476,7 @@ fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
|
||||
let bytes = st.path.as_deref().and_then(crate::io::file_len);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
|
||||
@@ -460,7 +460,11 @@ mod tests {
|
||||
for path in files {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
|
||||
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs") || name.starts_with("tests") {
|
||||
// `recovery` and `spell` touch only the app's own folders (AutoRecover files, the
|
||||
// system and user spelling dictionaries), never a path a command names.
|
||||
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs" | "spell.rs")
|
||||
|| name.starts_with("tests")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
@@ -33,3 +33,16 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
|
||||
Err(format!("{path}: saving to a path is not available in the browser"))
|
||||
}
|
||||
|
||||
/// The size in bytes of the file at `path`; `None` when it can't be read (or is outside the
|
||||
/// automation read root, so File › Info doesn't reveal files the agent may not read).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn file_len(path: &str) -> Option<u64> {
|
||||
crate::file_access::check_read(path).ok()?;
|
||||
std::fs::metadata(path).ok().map(|m| m.len())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ fn every_writing_command_is_refused_outside_the_write_root() {
|
||||
refused(&mut server, "export", json!({"path": outside("x.png")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("x.txt")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("all.png"), "all": true}), "outside the write root");
|
||||
refused(&mut server, "render_slide", json!({"path": outside("r.png")}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": outside("r.png")}}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "app.save", "params": {"path": outside("y.deckcraft")}}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "app.export", "params": {"path": outside("y.pdf")}}), "outside the write root");
|
||||
for command in ["file.save", "file.saveAs", "file.export"] {
|
||||
@@ -162,7 +162,7 @@ fn every_writing_command_is_refused_outside_the_write_root() {
|
||||
ok(&mut server, "export", json!({"path": inside("x.pptx")}));
|
||||
ok(&mut server, "export", json!({"path": inside("x.pdf")}));
|
||||
ok(&mut server, "export", json!({"path": inside("all.png"), "all": true}));
|
||||
ok(&mut server, "render_slide", json!({"path": inside("r.png")}));
|
||||
ok(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": inside("r.png")}}));
|
||||
for f in ["x.deckcraft", "x.pptx", "x.pdf", "all1.png", "all2.png", "r.png"] {
|
||||
assert!(base.join("out").join(f).exists(), "{f}");
|
||||
}
|
||||
@@ -176,7 +176,7 @@ fn one_root_grants_only_its_own_access() {
|
||||
ok(&mut read_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
refused(&mut read_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}), "write authority is absent");
|
||||
refused(&mut read_only, "export", json!({"path": s(&base.join("out/x.pdf"))}), "write authority is absent");
|
||||
refused(&mut read_only, "render_slide", json!({"path": s(&base.join("out/r.png"))}), "write authority is absent");
|
||||
refused(&mut read_only, "call_app", json!({"method": "ui.render", "params": {"path": s(&base.join("out/r.png"))}}), "write authority is absent");
|
||||
// Calls that return bytes instead of writing a path keep working.
|
||||
let (error, _) = call(&mut read_only, "render_slide", json!({"scale": 0.25}));
|
||||
assert!(!error);
|
||||
|
||||
Reference in New Issue
Block a user