From 11a7f02cc0f059d2622633fa68590db0429ef678 Mon Sep 17 00:00:00 2001 From: Christian Lappin <36496308+christianlappin@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:31:47 -0500 Subject: [PATCH] Fix Finder association attribute sizing on macOS --- crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/src/platform/file_watch.rs | 8 ++- .../ui-egui/src/platform/macos_association.rs | 69 ++++++++++--------- 3 files changed, 46 insertions(+), 36 deletions(-) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 77132de42f..fdac3dbdbe 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -75,9 +75,8 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry", "NSBundle", "NSURL", "NSString", "NSError"] } block2 = "0.6" plist = "1.10" -# A plain fsync where a volume refuses the full flush `sync_all` asks for (SMB, FUSE); already in -# the tree through pdfcraft-cli. -rustix = { version = "1", features = ["fs"] } +# Query and read Finder association attributes without following symbolic links. +rustix = { version = "1.1.6", features = ["fs"] } [target.'cfg(windows)'.dependencies] # Auto interface language: the Windows display language, without starting a process. diff --git a/crates/ui-egui/src/platform/file_watch.rs b/crates/ui-egui/src/platform/file_watch.rs index 85ec4d0f5b..539b98361a 100644 --- a/crates/ui-egui/src/platform/file_watch.rs +++ b/crates/ui-egui/src/platform/file_watch.rs @@ -623,14 +623,18 @@ mod tests { #[test] fn a_large_binding_does_not_block_a_document_save() { let (_dir, path) = TempDir::new("large-binding-save", &fixture(2)); - xattr::set(&path, "com.apple.LaunchServices.OpenWith", &vec![b'x'; 65537]).unwrap(); + let binding = vec![b'x'; 65537]; + xattr::set(&path, "com.apple.LaunchServices.OpenWith", &binding).unwrap(); xattr::set(&path, "com.apple.quarantine", b"0082;1234;Preview;").unwrap(); let mut h = harness(&path); let app = h.state_mut(); app.views[0].select_pages(&[0]); app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); assert!(app.save_active(SaveTarget::InPlace), "the document save uses upstream preservation limits"); - assert_eq!(xattr::get(&path, "com.apple.LaunchServices.OpenWith").unwrap().unwrap().len(), 65537); + let mut saved = vec![0; binding.len()]; + let count = rustix::fs::lgetxattr(&path, "com.apple.LaunchServices.OpenWith", saved.as_mut_slice()).unwrap(); + assert_eq!(count, binding.len()); + assert_eq!(saved, binding); assert_eq!(xattr::get(&path, "com.apple.quarantine").unwrap(), Some(b"0082;1234;Preview;".to_vec())); } diff --git a/crates/ui-egui/src/platform/macos_association.rs b/crates/ui-egui/src/platform/macos_association.rs index 2116481bd5..d81f058000 100644 --- a/crates/ui-egui/src/platform/macos_association.rs +++ b/crates/ui-egui/src/platform/macos_association.rs @@ -41,26 +41,30 @@ impl OpeningMetadata { /// Read a last-component-symlink-safe attribute with a strict allocation cap. fn read_attribute(path: &Path, name: &str) -> io::Result>> { - read_attribute_with(|buffer| rustix::fs::lgetxattr(path, name, buffer)) + read_attribute_with(|| rustix::fs::lgetxattr_query(path, name), |buffer| rustix::fs::lgetxattr(path, name, buffer)) } /// Probe the length before allocating. A concurrently grown value fails safely; unreadable /// attributes or values beyond the parser cap skip recovery without affecting open/save. -fn read_attribute_with(mut get: impl FnMut(&mut [u8]) -> rustix::io::Result) -> io::Result>> { +fn read_attribute_with( + query_size: impl FnOnce() -> rustix::io::Result, + read: impl FnOnce(&mut [u8]) -> rustix::io::Result, +) -> io::Result>> { use rustix::io::Errno; let unreadable = |error| match error { Errno::RANGE => Err(io::Error::new(io::ErrorKind::InvalidData, "opening metadata exceeds 64 KiB or changed while reading")), _ => Ok(None), }; - let len = match get(&mut []) { + let len = match query_size() { Ok(len) if len <= MAX_ATTRIBUTE => len, Ok(_) => return unreadable(Errno::RANGE), Err(e) => return unreadable(e), }; - let mut bytes = vec![0; len]; - match get(bytes.as_mut_slice()) { - Ok(len) if len <= bytes.len() => { - bytes.truncate(len); + // Read through a nonempty buffer so a zero-length attribute can still be checked for growth. + let mut bytes = vec![0; len.max(1)]; + match read(bytes.as_mut_slice()) { + Ok(count) if count <= len => { + bytes.truncate(count); Ok(Some(bytes.into_boxed_slice().into_vec())) } Ok(_) => unreadable(Errno::RANGE), @@ -285,12 +289,16 @@ mod tests { let dir = directory(); let file = dir.join("out.pdf"); std::fs::write(&file, b"original PDF").unwrap(); - xattr::set(&file, OPEN_WITH, &vec![b'x'; MAX_ATTRIBUTE + 1]).unwrap(); + let binding = vec![b'x'; MAX_ATTRIBUTE + 1]; + xattr::set(&file, OPEN_WITH, &binding).unwrap(); xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap(); assert!(OpeningMetadata::read(&file).is_err()); crate::editing::write_atomically(file.to_str().unwrap(), b"new PDF").unwrap(); assert_eq!(std::fs::read(&file).unwrap(), b"new PDF"); - assert_eq!(xattr::get(&file, OPEN_WITH).unwrap().unwrap().len(), MAX_ATTRIBUTE + 1); + let mut saved = vec![0; binding.len()]; + let count = rustix::fs::lgetxattr(&file, OPEN_WITH, saved.as_mut_slice()).unwrap(); + assert_eq!(count, binding.len()); + assert_eq!(saved, binding); assert_eq!(xattr::get(&file, QUARANTINE).unwrap(), Some(b"0082;1234;Preview;".to_vec())); std::fs::remove_dir_all(dir).unwrap(); } @@ -393,30 +401,29 @@ mod tests { /// and skips recovery if the value grows between the probe and read. #[test] fn attribute_probes_bound_allocation_and_detect_growth() { - let mut calls = 0; - let small = read_attribute_with(|out| { - calls += 1; - if calls == 1 { - assert!(out.is_empty()); - return Ok(3); - } - assert_eq!(out.len(), 3); - out.copy_from_slice(b"abc"); - Ok(3) - }) + let small = read_attribute_with( + || Ok(3), + |out| { + assert_eq!(out.len(), 3); + out.copy_from_slice(b"abc"); + Ok(3) + }, + ) .unwrap() .unwrap(); assert_eq!(small, b"abc"); assert_eq!(small.capacity(), 3); - assert!(read_attribute_with(|_| Ok(MAX_ATTRIBUTE + 1)).is_err()); - let mut calls = 0; - assert!( - read_attribute_with(|_| { - calls += 1; - if calls == 1 { Ok(3) } else { Err(rustix::io::Errno::RANGE) } - }) - .is_err() + assert!(read_attribute_with(|| Ok(MAX_ATTRIBUTE + 1), |_| panic!("oversized attribute must not be read")).is_err()); + assert!(read_attribute_with(|| Ok(3), |_| Err(rustix::io::Errno::RANGE)).is_err()); + let empty = read_attribute_with( + || Ok(0), + |out| { + assert_eq!(out.len(), 1); + Ok(0) + }, ); + assert_eq!(empty.unwrap(), Some(Vec::new())); + assert!(read_attribute_with(|| Ok(0), |_| Ok(1)).is_err()); } /// Volumes may expose no readable attributes with errors beyond ENOTSUP. @@ -424,7 +431,7 @@ mod tests { fn unreadable_attributes_are_best_effort() { use rustix::io::Errno; for errno in [Errno::NOSYS, Errno::INVAL, Errno::PERM, Errno::ACCESS, Errno::IO] { - assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None); + assert_eq!(read_attribute_with(|| Err(errno), |_| panic!("unreadable attribute must not be read")).unwrap(), None); } } @@ -433,8 +440,8 @@ mod tests { fn unsupported_attribute_volumes_are_distinct_from_read_failures() { use rustix::io::Errno; for errno in [Errno::NOATTR, Errno::NOTSUP, Errno::OPNOTSUPP] { - assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None, "{errno}"); + assert_eq!(read_attribute_with(|| Err(errno), |_| panic!("unsupported attribute must not be read")).unwrap(), None, "{errno}"); } - assert!(read_attribute_with(|_| Err(Errno::RANGE)).is_err()); + assert!(read_attribute_with(|| Err(Errno::RANGE), |_| panic!("failed size query must not read the attribute")).is_err()); } }