From bac52a6d9e37b88a1ba6649c39d5cf9502dd7bf9 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:53:15 +0300 Subject: [PATCH 1/2] fix(save): preserve macOS extended attributes (#2658) --- Cargo.lock | 1 + ROADMAP.md | 2 + crates/automation/Cargo.toml | 3 + crates/automation/tests/automation.rs | 59 +++++++++ crates/platform/README.md | 6 +- crates/platform/src/staging.rs | 168 ++++++++++++++++++++++++++ docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 8 files changed, 243 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91ed3c4904..962faeb152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4006,6 +4006,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", "sha2 0.11.0", "tempfile", diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec04..6cbcfcf688 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log + +- **2026-10-17 (M1, #2658):** macOS saves retain accessible destination extended attributes before atomic publication, including Finder tags and empty/binary values. Metadata errors keep the original and unsaved edit; source symlinks are not followed. Permissions/ACLs, hard links and creation dates remain separate work. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4c..4e3a220aee 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -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"] } diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 1e662d8f38..47e1665b9c 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { 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"PdfCraft\n6"; + 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 { use pdfcraft_engine::{Edit, Protection, Session}; let mut session = Session::new(); diff --git a/crates/platform/README.md b/crates/platform/README.md index 6b23752b5f..c3dcc076fb 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -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. 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 and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. This does not preserve permission bits, ACLs, hard-link identity or creation dates; other parts of #2658 need separate work. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index f7200efb9e..78ceead9c2 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -70,6 +70,8 @@ pub fn staging_suffixes() -> impl Iterator { /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own /// (`sync_all`); parent-directory durability after a power loss is not promised. +/// 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) } @@ -93,6 +95,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}")) + })?; sync_staged(&file, sync_all)?; } std::fs::rename(&tmp, path) @@ -103,6 +109,63 @@ 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")); + } + // A nonempty buffer distinguishes an empty value from a request for its size. If a + // value grows between these reads, ERANGE refuses the save rather than truncating it. + let mut value = vec![0u8; size.max(1)]; + let len = fgetxattr(&source, &name, value.as_mut_slice())?; + let value = value.get(..len).ok_or_else(|| std::io::Error::other("extended attribute changed size while saving"))?; + fsetxattr(staged, &name, value, XattrFlags::empty())?; + total = total.saturating_add(len); + } + Ok(()) +} + /// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks /// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator /// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers. @@ -215,4 +278,109 @@ mod tests { assert_eq!(names(&dir), ["out.pdf"]); 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}; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("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(); + } + // 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| { + 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(); + 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(); + } + + #[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(); + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 3dfea674f4..17d12ff035 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-17 - **Last updated:** 2026-10-17 - **Change:** minor (M1: macOS atomic save retains destination xattrs; estimates unchanged) + > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -97,7 +99,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:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- 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.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
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`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- 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.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
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). macOS atomic saves retain accessible destination xattrs, including Finder tags; a metadata failure keeps the original and the unsaved edit (#2658, metadata only). Fuzzing runs nightly (`xtask fuzz`). 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 | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6dbc34f35f..2fc5d98095 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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/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 or metadata limits 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" From 34e4aac28e5b951907c365e30f5c50ea87325d84 Mon Sep 17 00:00:00 2001 From: PerchAnton <101743951+PerchAnton@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:32 +0300 Subject: [PATCH 2/2] M1: keep roadmap status blockquotes valid --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 54bf9906b4..763da71ac4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #2658: reject partial extended-attribute copies after a size change; estimates unchanged) > **Last reviewed:** 2026-10-17 - **Last updated:** 2026-10-17 - **Change:** minor (M1: macOS atomic save retains destination xattrs; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)