Fix Finder association attribute sizing on macOS

This commit is contained in:
Christian Lappin
2026-11-13 05:58:15 -05:00
parent d6579c2bef
commit 11a7f02cc0
3 changed files with 46 additions and 36 deletions
+2 -3
View File
@@ -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.
+6 -2
View File
@@ -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()));
}
@@ -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<Option<Vec<u8>>> {
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<usize>) -> io::Result<Option<Vec<u8>>> {
fn read_attribute_with(
query_size: impl FnOnce() -> rustix::io::Result<usize>,
read: impl FnOnce(&mut [u8]) -> rustix::io::Result<usize>,
) -> io::Result<Option<Vec<u8>>> {
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());
}
}