ADR-020: File permissions are hierarchical rules scoped to PartialFile entries¶
Status: accepted
Context¶
#107 deliberately left DesiredEntry without a permissions field,
anticipating this issue (#65) rather than guessing its shape. Two things
make permission metadata harder than it first looks, given ADR-006's
symlink-first architecture:
- Almost every
DesiredEntry(SymlinkFile/SymlinkDirectory) is a symlink to a file still tracked in the overlay's own source tree. A symlink shares its target's inode: there is no independent "permission of the target" to set — the only real mode is the overlay source file's own, visible through the link. Managing it would meanoverchmod'ing its own tracked source file as a side effect ofapply/status/diff, which are otherwise all read-only-until-execute(Plan::build,status::Report,diff::Reportnever mutate anything) — a much bigger, more surprising commitment than this issue asks for. - Git only tracks a single executable bit (
100644vs100755), not arbitrary mode bits — a0600SSH key or credential file committed to an overlay repo will not keep that mode across a clone. Nothing inovertoday re-asserts a declared permission policy after materialization. -
113/ADR-017 already generalized
hierarchical override model for materialization strategy, resolved path-by-path while walking the overlay tree.link_dirsinto adefaults/rules¶
Decision¶
Mirror ADR-017's defaults/rules shape for permissions
(src/overlays/permissions.rs: FileMode, PermissionRule, resolve),
but scope it to the one intent where over writes real, target-owned
content directly today:
MaterializationIntent::PartialFile (#66).
Every other intent (Directory, Checkout, SymlinkFile,
SymlinkDirectory) always carries DesiredEntry.permissions: None and is
never compared or enforced this way — see the regression test
symlinked_file_never_carries_a_permission_even_with_a_matching_rule in
src/desired/tree.rs.
Overlay::permissions: Option<Vec<PermissionRule>>([[permissions]] path = "...", mode = "...") andOverlay::defaults.mode: Option<FileMode>cascade through the same descriptor chain as every other field (ADR-002), resolved with the same specificity precedence asMaterializationRule(most specific match, elsedefaults.mode, elseNone— unmanaged).- Resolution matches against a
PartialFileentry's own sidecar-relative stem (e.g.ssh/agentforssh/agent.partial.toml), the same identity.link.*/.partial.*sidecars already use elsewhere — not the rendered, possibly-externaltargetpath, and not a path walked from the overlay's own file tree the wayMaterializationRuleis. - A mismatch on an otherwise-correctly-materialized entry (content already
matches, only the mode differs) is a new
Operation::Repair { current, desired }— actionable like an unblockedMigrate(no prompt, no--force), never aConflict: nothing structural is wrong, only achmodis needed.statusreports it asModified;diffreuses the existing line-diff renderer for a trivial644 -> 600text diff, exactly like the symlink-target-path case already does — no newChangevariant. - Materialization is a plain
fs::set_permissions(actions::fs:: SetPermissions,#[cfg(unix)]-gated; a no-op on non-unix, where permission bits aren't a meaningful concept to enforce), applied either right afterEnsurePartialBlockwrites new/updated content, or on its own for a pureRepairstep.
Consequences¶
Permission tracking only ever affects the file over itself writes
content into — a symlinked file's permission remains entirely a function of
the overlay source file, completely unmanaged by this feature, exactly the
same as before #65. #61's future rendered whole-file content is a natural
extension of the same mechanism (another intent that writes real content
over owns) with no redesign needed.
A permissions/defaults.mode rule whose path never actually matches
any PartialFile sidecar's stem silently has no effect — the same class of
"no-op override" already possible with an unrelated rules entry (ADR-017)
whose glob matches nothing on disk. over lint gains matching checks
(check_empty_permissions, check_invalid_permission_globs,
check_duplicate_permission_rule_paths), mirroring the existing rules
checks, but deliberately has no "paths exist" check the way
check_rules_paths_exist does: a permission rule's matching key is a
sidecar stem, not a real path in the walked tree, so "exists on disk"
doesn't apply the same way.
Because permissions/defaults are replaced wholesale by the nearest
config level that defines them, not merged element-wise (the same
limitation ADR-017 already documents for rules), an overlay that defines
its own permissions list loses the repository root's entries entirely
unless it repeats them.