From 4d0ecb264cc9eab413d1ffeef2dc0052b7dffd09 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 11:43:07 +0000 Subject: [PATCH] fix(engine): link watch and Link Info read within the asking caller's roots --- crates/engine/src/cmd/links.rs | 6 ++++-- crates/engine/src/link_watch.rs | 12 ++++++++++-- crates/engine/src/tests/linkspanel.rs | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 2e8d974d6..441249b05 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -1338,8 +1338,10 @@ fn placement_json(o: PlacementOptions) -> Value { /// Link Info's fields of the linked file `l` (of object `id` of `st`) into `out`. fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { - // Within what the caller that opened the document may read, whoever asks. - crate::file_access::confine(st.opened_by.as_ref(), || file_fields_here(st, id, l, out)); + // A confined caller reads within its own roots; anyone else within what the caller that + // opened the document may read (never wider than the caller asking now). + let roots = crate::file_access::current().or_else(|| st.opened_by.clone()); + crate::file_access::confine(roots.as_ref(), || file_fields_here(st, id, l, out)); } fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) { diff --git a/crates/engine/src/link_watch.rs b/crates/engine/src/link_watch.rs index 94fc568f4..51ffbd098 100644 --- a/crates/engine/src/link_watch.rs +++ b/crates/engine/src/link_watch.rs @@ -69,6 +69,9 @@ impl Session { } let Some(st) = self.active() else { return }; let (uid, files, doc_path) = (st.uid, crate::cmd::links::linked_groups(&st.doc), st.path.clone()); + // A confined caller's roots, else what the caller that opened the document may read (the + // desktop frame is unconfined). + let opened_by = st.opened_by.clone(); if files.is_empty() { return; } @@ -76,7 +79,11 @@ impl Session { let out = slot.clone(); let scope = crate::file_access::ScopeHandle::capture(); let spawned = std::thread::Builder::new().name("link-watch".into()).spawn(move || { - let stamps = scope.run(|| files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect()); + let stamps = scope.run(|| { + crate::file_access::confine(crate::file_access::current().or(opened_by).as_ref(), || { + files.into_iter().map(|(p, ids)| (p.clone(), ids, stamp(&p, doc_path.as_deref()))).collect() + }) + }); *out.lock().unwrap_or_else(PoisonError::into_inner) = Some((uid, stamps)); }); if spawned.is_ok() { @@ -141,7 +148,8 @@ impl Session { let mode = if update { "automatically" } else { self.prefs.update_links.as_str() }; match mode { "automatically" => { - let r = self.execute("links.update", &json!({"ids": ids}))?; + let roots = crate::file_access::current().or_else(|| self.active().and_then(|d| d.opened_by.clone())); + let r = crate::file_access::confine(roots.as_ref(), || self.execute("links.update", &json!({"ids": ids})))?; // Not readable yet (still being written): the next look tries again. let unread: BTreeSet = r["missing"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); for (path, ids) in &changed { diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index b08d9acab..56784b69d 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -278,6 +278,24 @@ fn link_info_has_the_file_and_placement_details() { assert_eq!((i["status"].as_str(), i["linked"].as_bool(), i["fileName"].as_str()), (Some("embedded"), Some(false), None)); } +/// Link Info reads within the opener's roots for an unconfined caller, and never past a confined +/// caller's own roots. +#[test] +fn link_info_stays_within_the_asking_callers_roots() { + let dir = Folder::new("info-roots"); + let other = Folder::new("info-other"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let roots = |p: &std::path::Path| crate::file_access::AutomationRoots::new(&[p], &[]).unwrap().unwrap(); + s.active_mut().unwrap().opened_by = Some(roots(&dir.0)); + assert_eq!(run(&mut s, "links.info", json!({"id": id.0}))["status"], "ok"); + let narrow = roots(&other.0); + let i = crate::file_access::confine(Some(&narrow), || run(&mut s, "links.info", json!({"id": id.0}))); + assert_ne!(i["status"], "ok", "{i}"); +} + /// A 600×300 pt image at the centre of the artboard, its placement options set to `options`, /// relinked to a 300×100 px file → the image's id and session. fn relinked(name: &str, options: Value) -> (Session, NodeId, Folder) {