mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-25 02:17:30 -06:00
Saving writes a temporary file and renames it over the target, so everything attached to the old file went with the file that was unlinked. Several of those are security decisions, so a save quietly undid them: a download lost its Mark of the Web, an explicit ACL fell back to whatever the folder hands down. New crates/format/src/atomic/meta.rs, reached from atomic_write through two AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new and defaults to rename, so the failure-injecting test file systems are unchanged): - Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine (the Mark of the Web's macOS counterpart) and Linux user.* all ride along. Attributes the system owns refuse to be written and are skipped; com.apple.decmpfs is skipped deliberately, since it describes storage the new bytes do not have. - macOS ACL (#7073): a new macOS-only exacl dependency (MIT). fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is APFS-only and would mean writing every save as clone-then-truncate; the reasoning is in docs/decisions.md. A file with no ACL is left alone. - Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT, no dependencies, a safe wrapper; the workspace forbids writing the FFI call here). One call keeps the destination's DACL, its named streams including Zone.Identifier, and its creation time. - Automation write path (#7095): AuthorizedWorkspace::write copies the replaced file's attributes through the two cap-std descriptors, never a path, so a request path still cannot escape the write root. Permissions there remain #4963; ReplaceFileW is path-based and so is not used on that path. The order inside copy_metadata matters: attributes and ACL first, permission bits last. Writing an attribute needs the destination open for writing, so copying a 0444 mode onto the temporary file first locks you out of your own temporary file. Covered by a regression test. Atomicity is not weakened. The metadata step runs on the temporary file before the swap, never touches bytes, and never fails a save. Windows is the one place the swap itself changed, and a backup file name is passed because it is a correctness requirement, not an option: ReplaceFileW without one documents ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the replacement under its temporary name, which is a failed save that loses the user's document. With a backup name the same failure leaves both files alone. ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is unwound by hand; every other error falls back to the plain rename, i.e. to the previous behaviour. THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream, icacls output, creation time -- have never executed. They do compile and pass clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac gets. CI's Windows job is the first real run, and #7094 in particular (Mark of the Web surviving a replace) is the whole point of the change and is the part no one has observed working. Tested on macOS: a real chmod +a entry and a real Finder tag both survive a save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB attribute value, a read-only file, the mode, and the automation path. Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is not reported by listxattr, so it needs its own step; not part of these issues), and macOS creation time is still reset by a save (that half of #6603 needs setattrlist, which has no safe wrapper here).
35 lines
2.8 KiB
Markdown
35 lines
2.8 KiB
Markdown
# Implementation decisions
|
|
|
|
## 2026-10-10: Smart Object contents identity (#2163)
|
|
|
|
Smart Object instances carry a document-scoped contents ID separately from their layer ID. Normal duplicates preserve it; New Smart Object via Copy creates a new one. Contents edits update the matching instances together, retaining their individual placement and filters. Copies into another document remap contents IDs as a batch, preserving sharing within the copied layers without joining existing destination objects.
|
|
|
|
Native files persist this ID. Legacy embedded objects without an ID remain independent: identical file bytes do not prove that two objects were intended to share edits. Legacy nonempty linked paths and PSD source identifiers retain their explicit sharing relationship. PSD export keys embedded sources by contents identity as well as bytes, so independent equal-content objects stay independent.
|
|
|
|
## 2026-10-17: keeping a replaced file's ACL and named streams (#7073, #7094, #7096)
|
|
|
|
Saving replaces a file by renaming a new one over it, which drops everything the old file
|
|
carried. Carrying the security-relevant parts across needs platform calls that have no safe API
|
|
in `std`, and the workspace forbids `unsafe`, so each platform took a dependency rather than an
|
|
exception.
|
|
|
|
**macOS ACLs: `exacl`, not `fclonefileat`.** The two routes that avoid `unsafe` are a macOS-only
|
|
`exacl` dependency (a safe wrapper over `acl_get_file`/`acl_set_file`) and `fclonefileat` with
|
|
the undocumented-in-`rustix` `CLONE_ACL` bit. The clone keeps the ACL, but only on APFS, and it
|
|
means writing every save as clone-then-truncate-then-write with a fallback for every other file
|
|
system — restructuring the one write path the whole app's durability rests on, to preserve an
|
|
ACL. `exacl` is a best-effort step beside the existing permission copy, it works wherever ACLs
|
|
do, and a failure cannot affect the bytes.
|
|
|
|
**Windows: `ReplaceFileW` via `winsafe`, with a backup name.** Copying the `Zone.Identifier`
|
|
stream by hand with `std` would cover the Mark of the Web but not the DACL, and enumerating every
|
|
stream needs `FindFirstStreamW`. `ReplaceFileW` is the call Microsoft documents for the
|
|
save-through-a-temporary-file pattern and preserves the DACL, named streams, creation time and
|
|
object identifier in one step; `winsafe`'s `kernel` feature wraps it safely and pulls in nothing.
|
|
|
|
The backup-file argument is a correctness requirement, not an option: `ReplaceFileW` without one
|
|
documents `ERROR_UNABLE_TO_MOVE_REPLACEMENT` as leaving the replaced file deleted and the
|
|
replacement under its temporary name — a failed save that loses the user's document. With a
|
|
backup name the same failure leaves both files alone, and the one failure that does move things
|
|
(`ERROR_UNABLE_TO_MOVE_REPLACEMENT_2`) is unwound in `atomic/meta.rs`.
|