Security: file authority for stored paths, linked files, placed documents, font folders, recovery areas and atomic saves

CodeRabbit findings #7, #72, #92, #94, #95, #99, #100, #140, #158, #174:

- Link search hashes at most the size it looked at (a growing file no longer matches).
- Resolving links reads, decodes and previews each linked file once, shared by every key.
- Placed documents read inside one another resolve links at most 8 deep, never one already
  being read.
- A data set's image path and the placed-document rereader follow stored paths through
  `document_path` (no other computer's share).
- The Links panel looks at a linked file within the roots of the automation caller that
  opened the document.
- A fonts folder set by a confined caller is scanned without following links out of it.
- Recovery refuses an area folder that is a link or another account's.
- Atomic saves keep the replaced file's group and Linux access ACL, or make the replacement
  owner-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-24 17:19:59 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 8a73ed9963
commit 03d2f0e83c
14 changed files with 199 additions and 26 deletions
+9 -1
View File
@@ -96,7 +96,15 @@ impl Visitor for LinkFiles {
if let Some((Some(saved_size), Some(saved_hash))) = self.saved.get(i)
&& *saved_size == size
&& size <= MAX_HASHED
&& hash.get_or_insert_with(|| std::fs::File::open(path).and_then(vectorcraft_doc::links::hash_reader).ok()).as_ref()
&& hash
.get_or_insert_with(|| {
// Only the size looked at is read: a file growing meanwhile no longer
// matches, rather than being hashed to its new end.
let file = std::fs::File::open(path).ok()?;
let (hash, read) = vectorcraft_doc::links::hash_reader_within(file, size).ok()?;
(read == size).then_some(hash)
})
.as_ref()
== Some(saved_hash)
{
items.push(n + i);
+32 -5
View File
@@ -231,7 +231,12 @@ impl Found {
/// `base` pointing at this file as read now (`bytes`).
fn link(&self, bytes: &[u8], base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash_bytes(bytes)), ..base.clone() }
self.link_hashed(hash_bytes(bytes), base)
}
/// [`Self::link`] with the bytes' hash already taken.
fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo {
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() }
}
}
@@ -542,8 +547,11 @@ fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> {
/// Replace image `id` with the raster at `path`, keeping the object's bounds (what an image
/// variable does on `dataset.select`). The file is linked; a missing or unreadable path is an
/// error so the dataset application can skip that binding.
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str) -> Result<()> {
let path = absolute_path(path);
pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_path: Option<&str>) -> Result<()> {
// A path stored in the document (a data set's value) is followed as its other stored paths
// are: on this computer or the document's own share only, so choosing a data set can't make
// the app connect to another computer.
let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?;
let bytes = read_file(&path)?;
let read = (link_info(&path, &bytes), fileio::raster_image(&bytes)?);
install(d, &[id], read, Some(Preserve::Bounds))
@@ -618,6 +626,10 @@ impl Resolved {
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
let mut out = Resolved::default();
// The image (with its preview) and hash of each file read for a preview here, by path: images
// of other keys linking to the same file share them, rather than each reading, decoding and
// keeping a copy of its own.
let mut read_images: BTreeMap<String, (ImageBlob, String)> = BTreeMap::new();
for g in groups(d, None) {
let row = rows(std::iter::once(&g), doc_path);
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
@@ -655,14 +667,24 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
}
(Status::Ok, Some(mut f)) => {
let preview = previewing(d, &g.key);
if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() {
store_image(d, &g.key, blob, true);
let link = f.link_hashed(hash, &g.link);
if link != g.link {
set_links(d, &g.ids, &link);
}
continue;
}
// Read when the images show the preview, or when the file's details changed.
let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None };
if preview && !eps_preview {
let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else {
let Some((b, img)) = bytes.as_deref().and_then(|b| Some((b, fileio::raster_image(b).ok()?))) else {
out.missing.extend(row);
continue;
};
store_image(d, &g.key, img.blob, true);
let blob = img.blob.with_proxy();
read_images.insert(f.path.clone(), (blob.clone(), hash_bytes(b)));
store_image(d, &g.key, blob, true);
} else if let Some(b) = d.images.get(&g.key).filter(|b| b.proxy.is_none()) {
// Linked before links kept a preview.
let b = b.clone().with_proxy();
@@ -1266,6 +1288,11 @@ 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));
}
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
let doc_path = st.path.as_deref();
let path = fileio::document_path(&l.path, doc_path).ok();
+39 -3
View File
@@ -126,8 +126,13 @@ pub(crate) fn document_art(
return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd);
}
let mut l = fileio::load(name, bytes)?;
// Its linked images show their files; its placed documents stay as they are.
super::super::links::resolve(&mut l.doc, path, false, false);
// Its linked images show their files; its placed documents stay as they are, unless their
// stored bytes are lost, when resolving reads their files, which can place this one again.
// A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links
// left as stored.
if let Some(_reading) = Reading::enter(bytes) {
super::super::links::resolve(&mut l.doc, path, false, false);
}
l.doc.drop_edit_modes();
let n = l.doc.artboards.len();
let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect);
@@ -140,6 +145,33 @@ pub(crate) fn document_art(
Ok(DocumentArt { doc: l.doc, nodes, clip: (!bounding).then_some(board), frame, warnings: l.warnings })
}
/// The most placed documents read inside one another while their links are resolved.
const MAX_NESTED_READS: usize = 8;
std::thread_local! {
/// The hashes of the documents whose links are being resolved on this thread, outermost first.
static READING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// A document's links being resolved ([`READING`]): the entry goes when this does.
struct Reading;
impl Reading {
/// `None` when the document of `bytes` is already being read, or [`MAX_NESTED_READS`] are.
fn enter(bytes: &[u8]) -> Option<Self> {
let hash = hash_bytes(bytes);
READING.with_borrow_mut(|r| (r.len() < MAX_NESTED_READS && !r.contains(&hash)).then(|| r.push(hash))).map(|()| Reading)
}
}
impl Drop for Reading {
fn drop(&mut self) {
READING.with_borrow_mut(|r| {
r.pop();
});
}
}
/// `nodes` as one group, clipped to `clip` (its own ids: what drawing reads).
fn group(nodes: Vec<Node>, clip: Option<Rect>) -> Node {
let mut children: Vec<Arc<Node>> = nodes.into_iter().map(Arc::new).collect();
@@ -159,8 +191,12 @@ pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Documen
/// `p`'s file read again, while it is the file it was read from (how the document model gets it
/// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]).
pub(crate) fn read_file_again(p: &PlacedDocument) -> Option<Vec<u8>> {
// The stored path is followed as stored paths are, but with no document to share a network
// folder with: a file on another computer is never read here, even one resolving refused
// (which leaves the stored path in place), so embedding or exporting can't connect to it.
let path = fileio::document_path(&p.link.path, None).ok()?;
// One byte over the limit tells a file that is too big, without reading it all.
let bytes = fileio::read_file_head(&p.link.path, MAX_FILE + 1).ok()?;
let bytes = fileio::read_file_head(&path, MAX_FILE + 1).ok()?;
if bytes.len() > MAX_FILE {
return None;
}
+3 -1
View File
@@ -543,7 +543,9 @@ impl Session {
self.prefs = p;
if fonts_folder_changed {
let folder = self.prefs.fonts_folder.trim();
vectorcraft_text::set_user_font_dirs(if folder.is_empty() { vec![] } else { vec![folder.into()] });
// A folder an automation caller chose is read without following its links out of it.
let confined = crate::file_access::current().is_some();
vectorcraft_text::set_user_font_dirs_confined(if folder.is_empty() { vec![] } else { vec![folder.into()] }, confined);
// Once the fonts were scanned, scan again now: the folder's fonts appear (or go) in the
// font menus, and type in them lays out again. The first scan reads it anyway.
if vectorcraft_text::FontDb::global().installed_fonts_changed() {
+18
View File
@@ -264,6 +264,22 @@ impl FolderStore {
builder.create(dir).map_err(|e| format!("{}: {e}", dir.display()))
}
/// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix)
/// belongs to this account. Listing areas leaves links out, so a name another account had
/// linked to a folder of its own in a shared recovery folder looked free, and claiming it
/// wrote the copies through the link.
fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> {
let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
if !m.file_type().is_dir() {
return Err(format!("{}: not a folder of its own", dir.display()));
}
#[cfg(unix)]
if std::os::unix::fs::MetadataExt::uid(&m) != rustix::process::geteuid().as_raw() {
return Err(format!("{}: belongs to another account", dir.display()));
}
Ok(())
}
/// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist.
fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> {
let entries = match std::fs::read_dir(dir) {
@@ -298,6 +314,7 @@ impl RecoveryStore for FolderStore {
let f = self.file(name)?;
if let Some(dir) = f.parent() {
Self::create_area(dir)?;
Self::own_area(dir)?;
}
vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display()))
}
@@ -317,6 +334,7 @@ impl RecoveryStore for FolderStore {
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
let dir = self.area(area)?;
Self::create_area(&dir)?;
Self::own_area(&dir)?;
let path = dir.join(LOCK_FILE);
let file = std::fs::OpenOptions::new()
.read(true)
+2 -1
View File
@@ -469,6 +469,7 @@ fn dataset_list(s: &mut Session, _p: &Value) -> Result<Value> {
fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
const C: &str = "dataset.select";
let doc_path = s.doc()?.path.clone();
let (applied, skipped, mut typed, bound) = s.edit("Apply Data Set", |d, sel| {
let ds = d.variables.dataset(name).cloned().ok_or_else(|| bad(C, format!("no dataset named `{name}`")))?;
// One object may be driven by several variables; walk them in definition order, so two
@@ -513,7 +514,7 @@ fn apply_dataset(s: &mut Session, name: &str) -> Result<Value> {
true
}),
// Swap the linked file; keep the object's bounds. A missing path skips.
(VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path).is_ok(),
(VariableKind::Image, DataValue::Image(path)) => links::set_image_from_path(d, id, path, doc_path.as_deref()).is_ok(),
(VariableKind::Graph, DataValue::Graph(csv)) => super::graph::apply_csv(d, id, csv).is_ok(),
_ => false,
};