mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 20:07:05 -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).
37 lines
1.4 KiB
TOML
37 lines
1.4 KiB
TOML
[package]
|
|
name = "photocraft-automation"
|
|
description = "MCP server (rmcp) over the PhotoCraft engine, plus a bridge to a running GUI"
|
|
version.workspace = true
|
|
edition.workspace = true
|
|
license.workspace = true
|
|
rust-version.workspace = true
|
|
|
|
[lints]
|
|
workspace = true
|
|
|
|
[dependencies]
|
|
photocraft-geom = { workspace = true }
|
|
photocraft-doc = { workspace = true }
|
|
photocraft-compose = { workspace = true }
|
|
photocraft-codecs = { workspace = true }
|
|
photocraft-io = { workspace = true }
|
|
photocraft-format = { path = "../format" }
|
|
photocraft-engine = { workspace = true }
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
|
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
|
base64 = "0.22"
|
|
schemars = "1"
|
|
getrandom = { workspace = true }
|
|
cap-std = "4"
|
|
|
|
[dev-dependencies]
|
|
rmcp = { version = "3.5", default-features = false, features = ["client", "server", "macros", "transport-io"] }
|
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
|
|
|
# Reading back an extended attribute the write path should have preserved (#7095).
|
|
[target.'cfg(unix)'.dev-dependencies]
|
|
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
|