Merge PR #3042: fix(save): preserve destination extended attributes on macOS

This commit is contained in:
aiongg
2026-10-22 01:46:42 +00:00
8 changed files with 323 additions and 4 deletions
Generated
+1
View File
@@ -4012,6 +4012,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
"rustix 1.1.5",
"serde_json",
"sha2 0.11.0",
"tempfile",
+2
View File
@@ -41,6 +41,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -190,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
+3
View File
@@ -32,3 +32,6 @@ flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
[target.'cfg(target_os = "macos")'.dev-dependencies]
rustix = { version = "1", features = ["fs"] }
+59
View File
@@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec<u8> {
bytes
}
#[cfg(target_os = "macos")]
#[test]
fn saving_a_pdf_keeps_the_destinations_extended_attributes() {
use rustix::fs::{XattrFlags, getxattr, setxattr};
let dir = workdir("save-xattrs");
let target = dir.join("a.pdf");
let tags = b"<plist version=\"1.0\"><array><string>PdfCraft\n6</string></array></plist>";
let binary = b"\0\xffpdfcraft\0";
let attributes: &[(&str, &[u8])] = &[("com.apple.metadata:_kMDItemUserTags", tags), ("com.pdfcraft.test", binary)];
for (name, value) in attributes {
setxattr(&target, *name, value, XattrFlags::empty()).unwrap();
}
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
for full in [false, true] {
ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90}));
let saved = ok(&mut a, "doc_save", json!({"doc": doc, "full": full}));
assert_eq!(saved["incremental"], !full);
for (name, value) in attributes {
let mut bytes = [0u8; 256];
let len = getxattr(&target, *name, &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], *value, "full={full}, {name}");
}
}
// Save As preserves the destination's metadata, not attributes from the open input file.
let other = dir.join("other.pdf");
std::fs::write(&other, fixture(1)).unwrap();
setxattr(&other, "com.pdfcraft.test", b"destination", XattrFlags::empty()).unwrap();
ok(&mut a, "doc_save", json!({"doc": doc, "path": "other.pdf"}));
let mut bytes = [0u8; 256];
let len = getxattr(&other, "com.pdfcraft.test", &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], b"destination");
assert_eq!(getxattr(&other, "com.apple.metadata:_kMDItemUserTags", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit() {
use rustix::fs::{XattrFlags, setxattr};
let dir = workdir("save-xattrs-error");
let target = dir.join("a.pdf");
let original = std::fs::read(&target).unwrap();
setxattr(&target, "com.apple.ResourceFork", &vec![42u8; 16 * 1024 * 1024 + 1], XattrFlags::empty()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90}));
let failure = err(&mut a, "doc_save", json!({"doc": doc}));
assert!(failure.to_string().contains("extended attributes"), "{failure}");
assert!(failure.to_string().contains("Save As"), "{failure}");
assert_eq!(std::fs::read(&target).unwrap(), original);
assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], true);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "only the two original PDFs remain");
// A fresh name remains usable: metadata refusal doesn't discard the accepted edit.
ok(&mut a, "doc_save", json!({"doc": doc, "path": "copy.pdf"}));
assert_ne!(std::fs::read(dir.join("copy.pdf")).unwrap(), original);
std::fs::remove_dir_all(dir).unwrap();
}
fn protected_fixture(password: &str) -> Vec<u8> {
use pdfcraft_engine::{Edit, Protection, Session};
let mut session = Session::new();
+5 -1
View File
@@ -1,7 +1,11 @@
# pdfcraft-platform
Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies.
Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It has no UI dependencies; macOS filesystem calls use the safe `rustix` wrapper.
`staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller.
Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save.
On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested.
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
+249
View File
@@ -101,6 +101,8 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
/// that is replaced keeps its permissions, so a private file stays private; a new file gets the
/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
/// the volume stores one.
/// On macOS, accessible extended attributes of an existing regular destination are copied
/// before flushing; a copy failure refuses the save rather than losing metadata (#2658).
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all)
}
@@ -138,6 +140,10 @@ pub fn write_atomically_with(
{
let mut file = file;
file.write_all(bytes)?;
#[cfg(target_os = "macos")]
preserve_target_xattrs(path, &file).map_err(|e| {
std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}"))
})?;
#[cfg(any(windows, target_os = "macos"))]
keep_created(&file, created);
sync_staged(&file, sync_all)?;
@@ -150,6 +156,73 @@ pub fn write_atomically_with(
result
}
/// Copy accessible macOS attributes to the staging descriptor before publication. The kernel
/// hides compression bookkeeping unless SHOWCOMPRESSION is requested; leave that option off.
/// Provenance is managed by macOS for the new file, not writable document metadata.
#[cfg(target_os = "macos")]
fn preserve_target_xattrs(path: &Path, staged: &std::fs::File) -> std::io::Result<()> {
use rustix::fs::{Mode, OFlags, XattrFlags, fgetxattr, flistxattr, fsetxattr, open};
use rustix::io::Errno;
// A new destination has nothing to retain. Replacing a symlink has always replaced the
// link, not its referent: do not open it to read metadata from a different file. NONBLOCK
// also prevents a FIFO at the destination from hanging the metadata read.
let source = match open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, Mode::empty()) {
Ok(fd) => std::fs::File::from(fd),
Err(Errno::NOENT | Errno::LOOP) => return Ok(()),
Err(e) => return Err(e.into()),
};
if !source.metadata()?.is_file() {
return Ok(());
}
// Metadata is untrusted too: bound both allocations and total copying, including forks.
const MAX_NAMES_BYTES: usize = 64 * 1024;
const MAX_ATTRIBUTES: usize = 1024;
const MAX_VALUE_BYTES: usize = 16 * 1024 * 1024;
const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
let mut names = vec![0u8; MAX_NAMES_BYTES];
let len = match flistxattr(&source, names.as_mut_slice()) {
Ok(len) => len,
Err(Errno::NOTSUP) => return Ok(()), // No attributes exist on this filesystem.
Err(Errno::RANGE) => return Err(std::io::Error::other("extended attribute name list exceeds 64 KiB")),
Err(e) => return Err(e.into()),
};
let names = names.get(..len).ok_or_else(|| std::io::Error::other("extended attribute name list exceeds 64 KiB"))?;
let mut total = 0usize;
for (index, name) in names.split(|b| *b == 0).filter(|name| !name.is_empty()).enumerate() {
if index >= MAX_ATTRIBUTES {
return Err(std::io::Error::other("extended attribute count exceeds 1024"));
}
if name == b"com.apple.provenance" {
continue;
}
let name = std::ffi::CString::new(name).map_err(|_| std::io::Error::other("invalid extended attribute name"))?;
let size = fgetxattr(&source, &name, &mut [] as &mut [u8])?;
if size > MAX_VALUE_BYTES || size > MAX_TOTAL_BYTES.saturating_sub(total) {
return Err(std::io::Error::other("extended attributes exceed the 16 MiB per-value or 64 MiB total limit"));
}
// Resource forks may return a prefix instead of ERANGE on growth; check the size again.
let value = read_xattr_value(size, |buffer| Ok(fgetxattr(&source, &name, buffer)?))?;
fsetxattr(staged, &name, &value, XattrFlags::empty())?;
total = total.saturating_add(value.len());
}
Ok(())
}
/// Read an already size-bounded attribute, refusing a partial result or a changed post-read size.
#[cfg(target_os = "macos")]
fn read_xattr_value(size: usize, mut read: impl FnMut(&mut [u8]) -> std::io::Result<usize>) -> std::io::Result<Vec<u8>> {
let mut value = vec![0u8; size.max(1)];
let len = read(value.as_mut_slice())?;
let after = read(&mut [])?;
if len != size || after != size {
return Err(std::io::Error::other("extended attribute changed size while saving"));
}
value.truncate(len);
Ok(value)
}
/// Give the staging file the creation date of the file it replaces, where the system keeps one
/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the
/// new file's own date, as before.
@@ -304,6 +377,182 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "macos")]
#[test]
fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() {
use rustix::fs::{XattrFlags, fgetxattr, setxattr};
use std::os::macos::fs::FileTimesExt as _;
let dir = test_dir("xattrs");
let target = dir.join("out.pdf");
let attributes: &[(&str, &[u8])] = &[
("com.apple.metadata:_kMDItemUserTags", b"<plist version=\"1.0\"><array><string>PdfCraft\n6</string></array></plist>"),
("com.pdfcraft.test.binary", b"\0\xff\x80original\0"),
("com.pdfcraft.test.empty", b""),
];
std::fs::write(&target, b"OLD").unwrap();
for (name, value) in attributes {
setxattr(&target, *name, value, XattrFlags::empty()).unwrap();
}
let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap();
assert_eq!(original.metadata().unwrap().created().unwrap(), created);
drop(original);
// Both desktop and automation name forms, including a second save of the replacement.
for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] {
write_atomically_with(&target, b"NEW", form, [7], |staged| {
assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush");
for (name, value) in attributes {
let mut bytes = [0u8; 256];
let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}");
}
Ok(())
})
.unwrap();
let saved = std::fs::File::open(&target).unwrap();
assert_eq!(saved.metadata().unwrap().created().unwrap(), created);
for (name, value) in attributes {
let mut bytes = [0u8; 256];
let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], *value);
}
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
}
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn a_failed_flush_keeps_the_original_extended_attributes() {
use rustix::fs::{XattrFlags, getxattr, setxattr};
let dir = test_dir("xattrs-flush");
let target = dir.join("out.pdf");
std::fs::write(&target, b"OLD").unwrap();
setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap();
let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err();
assert!(e.to_string().contains("flush refused"));
assert_eq!(std::fs::read(&target).unwrap(), b"OLD");
let mut value = [0u8; 64];
let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap();
assert_eq!(&value[..len], b"original");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
/// Model an HFS-style partial read while using real macOS metadata and the atomic writer.
/// The shared read validator must reject growth before the staging file can be published.
#[cfg(target_os = "macos")]
#[test]
fn a_growing_resource_fork_refuses_publication_and_keeps_original() {
use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr};
let dir = test_dir("xattrs-growing-fork");
let target = dir.join("out.pdf");
let name = "com.apple.ResourceFork";
for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] {
std::fs::write(&target, b"ORIGINAL PDF").unwrap();
setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap();
let source = std::fs::File::open(&target).unwrap();
let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| {
let size = fgetxattr(&source, name, &mut [] as &mut [u8])?;
let value = read_xattr_value(size, |buffer| {
if buffer.is_empty() {
return Ok(fgetxattr(&source, name, buffer)?);
}
// HFS can return the prefix that fits instead of ERANGE. APFS need not
// have that behaviour: model the kernel response, not a timing race.
setxattr(&target, name, b"ABCD", XattrFlags::empty())?;
buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC");
Ok(3)
})?;
fsetxattr(staged, name, &value, XattrFlags::empty())?;
Ok(())
});
let error = result.expect_err("a short prefix of the grown fork must not be published");
assert!(error.to_string().contains("changed size"), "{error}");
assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF");
let mut bytes = [0u8; 8];
let len = getxattr(&target, name, &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused.
#[cfg(target_os = "macos")]
#[test]
fn attribute_reads_require_a_complete_value_and_stable_size() {
for bytes in [&b""[..], &b"\0\xffB"[..]] {
let value = read_xattr_value(bytes.len(), |buffer| {
if !buffer.is_empty() {
buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes);
}
Ok(bytes.len())
})
.unwrap();
assert_eq!(value, bytes);
}
for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] {
let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len }));
assert!(result.unwrap_err().to_string().contains("changed size"));
}
let result = read_xattr_value(3, |buffer| {
if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) }
});
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied);
}
#[cfg(target_os = "macos")]
#[test]
fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() {
use rustix::fs::{XattrFlags, getxattr, setxattr};
let dir = test_dir("xattrs-limit");
let target = dir.join("out.pdf");
std::fs::write(&target, b"OLD").unwrap();
// A resource fork can exceed the ordinary attribute limit on macOS. No fixture file
// is committed; only the OS metadata of this temporary synthetic file holds the bytes.
let fork = vec![42u8; 16 * 1024 * 1024 + 1];
setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap();
let mut flushed = false;
let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| {
flushed = true;
Ok(())
})
.unwrap_err();
assert!(e.to_string().contains("extended attributes"), "{e}");
assert!(e.to_string().contains("Save As"), "{e}");
assert!(!flushed, "metadata failure must stop before flush and rename");
assert_eq!(std::fs::read(&target).unwrap(), b"OLD");
let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap();
assert_eq!(size, fork.len());
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(target_os = "macos")]
#[test]
fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() {
use rustix::fs::{XattrFlags, getxattr, setxattr};
let dir = test_dir("xattrs-symlink");
let outside = test_dir("xattrs-outside").join("private.pdf");
std::fs::write(&outside, b"PRIVATE").unwrap();
setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap();
let target = dir.join("out.pdf");
std::os::unix::fs::symlink(&outside, &target).unwrap();
write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap();
assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE");
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
assert!(!target.symlink_metadata().unwrap().file_type().is_symlink());
let mut bytes = [0u8; 64];
assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR);
let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap();
assert_eq!(&bytes[..len], b"private metadata");
std::fs::remove_dir_all(dir).unwrap();
std::fs::remove_dir_all(outside.parent().unwrap()).unwrap();
}
/// #1790: a replaced file keeps its permissions, and its staging file already has them while
/// it is written; a new file gets the same mode as any other new file.
#[cfg(unix)]
+2 -1
View File
@@ -50,6 +50,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
Forward-looking: the milestones, the current focus and what comes next. The parity numbers behind
it are in [target-app-parity.md](target-app-parity.md), the ranked work list in [gaps.md](gaps.md),
@@ -114,7 +115,7 @@ total matches the parity estimate.
| M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
+2 -2
View File
@@ -286,8 +286,8 @@ milestone = "M1"
status = "shipped"
commands = ["file.save"]
tools = ["doc_save"]
evidence = ["crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"]
notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI)."
evidence = ["crates/platform/src/staging.rs::atomic_saves_keep_finder_tags_and_binary_extended_attributes", "crates/platform/src/staging.rs::a_growing_resource_fork_refuses_publication_and_keeps_original", "crates/automation/tests/automation.rs::saving_a_pdf_keeps_the_destinations_extended_attributes", "crates/automation/tests/automation.rs::a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit", "crates/ui-egui/tests/editing.rs::save_writes_an_incremental_update_and_clears_dirty", "crates/engine/src/tests.rs::save_is_incremental_stamps_mod_date_and_rebases", "crates/organize/src/tests.rs::successive_incremental_saves_stack_revisions", "crates/automation/src/lib.rs::staging_never_writes_through_a_file_planted_at_its_name", "crates/ui-egui/src/editing.rs::saving_never_writes_through_a_file_planted_at_the_staging_name"]
notes = "The atomic write stages to a new, randomly named file created with create_new, so a file or link planted at the staging path is never written through (desktop app, automation and CLI). On macOS, accessible destination xattrs (including Finder tags) are copied before flush/rename; errors, metadata limits or a changed post-read size refuse the save without replacing the file. System-managed provenance is excluded. Permissions, ACLs, hard-link identity and creation dates are not preserved by this change (#2658)."
[[feature]]
id = "core.save-as-full-rewrite"