Filesystem access
This concern owns the requirements and implemented architecture below. Moving these records does not change their implementation or approval status. Library-only capabilities remain separate from production CLI activation; each section records its own limits. Accepted decisions remain in ADRs.
Requirements
Qualified scope roots and advisory writer locking
filesystem::QualifiedScope::open(&InstallationRoot) pins an existing absolute
scope directory on Linux and macOS. Parent components, missing or non-directory
roots and final symlinks are rejected. Dot components and trailing separators
are normalized before final symlink checks. Ancestor aliases are permitted
during explicit selection; native non-UTF-8 paths are preserved where the
underlying filesystem accepts them. Filesystem refusals remain contextual
errors.
verify_binding() revalidates the normalized selected spelling, canonical path
and directory device/inode identity. A renamed, replaced or symlink-substituted
root, or retargeted ancestor alias, invalidates the old binding. The held handle
does not freeze the path namespace or establish descendant containment.
try_lock_writer() uses a fresh close-on-exec directory description and a
nonblocking exclusive advisory lock. It revalidates binding before and after
acquisition. Same-process and cross-process writers to one physical directory
contend, including aliases and different Project/User labels; distinct
directories remain independent. Contention returns a busy error. Other
filesystem failures remain errors. An opaque borrowing ScopeWriterGuard
retains exclusion until drop or holder termination; ordinary exec does not
retain it. Temporary fork inheritance is possible. No fork-safe lease, fairness,
network-filesystem guarantee or exclusion against external editors is promised.
These operations create no files, directories or persistent state and preserve existing bytes and permissions. The API exposes no raw descriptor, cloning or write operation. Other operating systems explicitly report unsupported behavior. This library prerequisite activates no CLI command, schema change, lifecycle execution, descendant containment, observation or recovery. It does not complete the M1 filesystem stage or Windows qualification.
See AD-0028 and the qualified scope-lock design for rationale, acceptance examples and platform evidence.
Qualified destination observations
QualifiedScope::observe_destinations(&[FileLocation]) captures exact optional
FileValue values beneath the borrowed physical scope. Regular files return
binary bytes and Unix permission bits (Some(mode & 0o7777)); absent
destinations return None. Observations preserve the supplied native roots and
portable paths, sorted by scope, raw root spelling and relative path. Every
supplied root must independently qualify and bind to the same physical
directory, including ancestor-alias spellings and different Project/User labels.
Empty input still validates the scope and filesystem profile.
Capture validates portable paths and rejects every potentially overlapping pair before descendant reads. Components may match when either contains non-ASCII characters or both compare equal ignoring ASCII case. If every component pair through the shorter path may match, the batch is rejected, including duplicates, prefix pairs and aliases across root spellings or labels. This conservative rule can reject distinct files on case-sensitive filesystems.
The inactive API qualifies APFS on macOS and ext-family/tmpfs filesystems on
Linux. Linux requires a returned statx mount ID; macOS retains the fstatfs
filesystem ID and mount bindings. Unknown profiles, missing mount evidence,
cross-mount traversal and other operating systems return explicit errors.
Descriptor-relative enumeration requires exact stored component spellings.
No-follow traversal rejects symlinks, non-directory ancestors, directory leaves,
files with multiple links and special files. Only ENOENT establishes absence;
recoverable filesystem, permission, I/O, descriptor-exhaustion and unsupported
failures remain contextual errors, including allocation errors reported by a
fallible read. Recoverable memory exhaustion is not guaranteed: ordinary Rust
infallible allocation failures may abort, and the operating system may terminate
the process instead of returning Err.
An opaque, non-Clone DestinationSnapshot retains close-on-exec root, directory
and file handles, selected root bindings, mount evidence and either exact file
stamps or the first missing component with its unresolved suffix. Capture checks
identity, type, links, size, mode, modification/change times (including
fractional seconds) and byte count around each read. Before returning, and again
from verify_unchanged(), it checks fresh bindings and directory identities,
exact stored spellings, mounts, exact bytes, file stamps and absence evidence.
These checks never refresh stale expectations. Timestamp-only changes, edits
with bytes and mtime restored but changed ctime, same-size edits with restored
mtime, chmod, identical-byte inode replacement, ancestor replacement,
missing-to-present transitions and root-alias retargeting invalidate evidence.
Access times and unrelated directory timestamp changes do not invalidate it.
Retained handles close on drop and returned-error paths; process abort or
termination does not guarantee Rust destructors run.
Observation creates no files, directories, locks or persistent state and
acquires no writer guard. Plain observations convey no execution or permission
authority. These sampled checks do not exclude external writers or hostile
ancestor relocation. An applying executor must retain its writer guard and
independently establish complete lifecycle inputs, authority, permission policy,
immediate pre-effect checks and recovery. Existing commands and schema 1 remain
unchanged; Windows qualification and complete observational lifecycle
integration remain pending. The opt-in
application boundary
provides guarded execution and recovery for supplied plans. Hosted Linux runtime
qualification and Windows unsupported-boundary checks passed on 2026-10-13 for
PR head 765e70b9b7ce5386530ffea1fe9814b228724428 (merge checkout
242930ef9107aff6eff432484f474e47157983f2) in
Actions run 38330080515.
That evidence does not establish a qualified Windows backend or cover later
local timestamp and special-bit tests. Independent regression controls now cover
fractional mtime changes, metadata changes during acquisition, short reads with
unchanged descriptor metadata, unknown native profiles and different retained
mount bindings through the production qualification and verification paths.
Existing-file overlap and repeated stale verification are also pinned. Linux’s
missing returned mount-ID mask production-path case passed natively on Ubuntu in
Actions run 38334882688
on 2026-10-13 for PR #273 head 2a50ca6a5163e11912cbcee7987593c68e0a4197.
Ubuntu job 115092610337 passed 28 public destination cases; four native
profile/mask check and production-call omissions failed their intended
assertions. Each restored qualification.rs to SHA-256
1c5b288ed575ae1ccdd99a10170c13aa9054f92004a0927a64fb1587bac40bd5, followed by
22 restored private cases GREEN. Local APFS separately passed 28 public and 22
private cases plus 14 independent native omission controls. All 13 head checks
succeeded, including Windows unsupported-boundary job 115092610428. This dated
evidence covers that head; hosted CI for the final head after temporary harness
removal remains required before merge. Injected query results and expected mount
bindings establish neither real privileged mount or fault behavior nor a
qualified Windows backend. See
AD-0029
and the
destination observation design.
Explicit file permission selection
files::permissions::select_file_mode(destination, source, resource_execution)
is an inactive pure library API returning anyhow::Result<u32>. Its
supplied-value inputs are DestinationMode::{Absent, Present(Option<u32>)},
PermissionSource::{Generated, Resource { captured_mode: Option<u32> }} and
ResourceExecutionPolicy::{Disabled, OwnerExecutable}. These types derive
Debug, Clone, Copy, PartialEq and Eq; they carry no serialized state
or effect capability.
Every supplied destination and captured resource mode must be within
0o0000..=0o7777, including evidence unused by the selection branch. Errors
identify destination or captured resource evidence. When multiple inputs are
invalid, no particular diagnostic ordering is promised. Present(None) rejects
as unknown existing mode. Existing modes containing any 0o7000 special bit
reject as unsupported application policy; ordinary existing modes are returned
exactly, including 0o000, 0o400, 0o640, 0o755 and 0o777, under either
resource policy.
Absent generated output selects literal 0o600 under either policy. Absent
resources also select 0o600 when execution is Disabled, including unknown
captured mode. OwnerExecutable requires a known captured resource mode and
selects 0o700 only when its owner-execute bit 0o100 is set; otherwise it
selects 0o600. Captured 0o755 and 0o4777 therefore select 0o700;
0o011, 0o644, 0o1000, 0o2000 and 0o4000 select 0o600. Source
group/other and special bits are never copied. Unknown captured resource mode
rejects only when selecting new resource execution; an existing ordinary
destination mode remains exact.
The function reads no environment or process umask and performs no I/O. Supplied values and selected bits convey no source authenticity, ownership, physical qualification or execution authority. Future writers must separately qualify owner/group, ACLs, extended metadata, containment and atomic replacement and persist actual resulting mode evidence. This API changes no compiler output, planner validation, CLI behavior or lockfile schema. The opt-in executor in qualified file execution and recovery supplies file application, journals and recovery. Combined lifecycle state, activation and hosted qualification remain pending; M1-A2, M1-A3 and M1 remain incomplete. See AD-0031 and the permission design.
Architecture
Qualified scope roots and writer locking
The library boundary is implemented; broader platform qualification remains pending.
Intent and boundary
Establish one process-safe writer exclusion primitive for the owned lifecycle, without changing current CLI behavior or writing state. This is the first part of the M1 filesystem stage. Destination observation, equivalence, containment, source enumeration, file application, recovery and Windows qualification remain separate gates. Completing this slice does not complete the filesystem stage.
Retain Rust 1.85, synchronous execution, a single binary, zero warnings and library-only presentation. Current commands remain on schema 1.
Components and interfaces
The
filesystem module
exports QualifiedScope and ScopeWriterGuard. Their
implementation
keeps selection, binding validation and the Linux/macOS directory-lock backend
private. It consumes ownership::InstallationRoot; the scope label does not
determine physical lock identity. Unsupported-platform branches return
contextual errors. The
real-filesystem integration tests
exercise the public boundary and subprocess lifetime; private unit tests pin
acquisition ordering and final-symlink refusal independently.
Alternatives and decision
Select a pinned existing directory handle and nonblocking OS advisory exclusive lock on that directory on Linux and macOS. It avoids creating a persistent lock file, removing a lock inode during release, stale PID cleanup and read-only preview writes. The inode, not a root spelling or Project/User label, identifies the lock.
A create-new sentinel needs a separate crash/stale-owner protocol and is rejected. A portable lock-file abstraction requires a securely established persistent lock namespace and Windows qualification; retain that as a future option. New APIs return an explicit unsupported error on other operating systems. Existing CLI behavior remains available there. Do not claim all supported-platform filesystem qualification from Linux/macOS evidence.
Admit the existing locked rustix 1.1.4 dependency directly, target-specific to
Linux/macOS, with filesystem support. Record the dependency decision in AD-0028.
Do not add an async runtime or hand-written unsafe syscall bindings. Standard
library file locks require Rust 1.89 and cannot satisfy Rust 1.85.
Root contract
filesystem::QualifiedScope::open(&InstallationRoot) returns an opaque,
non-Clone pinned root or an error. Require a nonempty absolute existing
directory. Reject parent components, root symlinks, regular files, missing roots
and special files. Normalize redundant current-directory and trailing separators
before checking the final component, so they cannot hide a symlink. Preserve
non-UTF-8 root paths without lossy conversion where the filesystem accepts them;
propagate filesystem refusal contextually.
Ancestor symlinks are permitted during explicit ambient root selection, including macOS temporary-directory aliases. Open both the normalized selected path and its resolved directory with no final symlink following, and compare both metadata identities with the selected root. Retain original normalized spelling, resolved path and the resolved directory’s open identity. This binds the explicitly chosen root; it is not descendant containment or a security sandbox against hostile namespace relocation.
verify_binding() reopens the selected root and checks its directory identity,
resolved path and nonsymlink final component. Renaming/replacing the root,
retargeting an ancestor alias or substituting a symlink must reject the old
scope. The pinned handle alone does not freeze the path namespace. Directory
edits by other processes remain possible, and later action code must revalidate
bindings and descendant preconditions immediately before each action.
Writer contract
try_lock_writer() acquires a fresh open description of the pinned directory,
checks identity, obtains a nonblocking exclusive OS lock, then checks the root
binding again before returning an opaque non-Clone ScopeWriterGuard borrowing
the scope. Reopening must use the pinned descriptor (for example openat(".")),
not a cloned description that shares a flock with another guard.
A competing same-process or cross-process writer to the same physical root returns a contextual busy error promptly. Different roots do not contend. Alias spellings and different scope labels cannot split exclusion. A dropped guard or terminated holder releases the lock through descriptor close. Do not expose the locked handle or allow cloning it. Use close-on-exec descriptors so ordinary subprocess execution does not prolong exclusion. This API promises no fork-safe lease, fairness, blocking wait, network-filesystem guarantee or protection against external editors. If the filesystem refuses locking, return that error; never pretend the lock succeeded.
Opening, validation, lock acquisition and release create no files, directories, backups, journals or schema changes and preserve existing bytes and modes. They do not establish state/source/destination preconditions or authorize writes.
Acceptance and evidence
| ID | Required example |
|---|---|
| Q01 | Pin a real absolute root, verify binding, acquire/drop/reacquire without changing recursive directory contents or modes. |
| Q02 | Reject relative, empty, parent-traversing, missing, file and symlink roots, including trailing slash and dot disguises. |
| Q03 | Equivalent root spellings and different scope labels contend; independent roots can lock together. |
| Q04 | A second guard in the same process is busy while the first lives and succeeds after drop. |
| Q05 | A real child process holds the lock; parent is busy; graceful child exit and forced termination each permit reacquisition. Synchronize readiness and bound all waits. |
| Q06 | Replacement/rename/symlink substitution and ancestor-alias retargeting reject stale binding before a guard is returned. |
| Q07 | Native non-UTF-8 roots work where the filesystem accepts them; filesystem refusal remains contextual. Unsupported platforms return explicit errors without effects. |
| Q08 | A child exec from a holder does not retain the lock after the holder exits. |
Tests use temporary roots and never write developer HOME. Capture failing acceptance tests before implementation, assertion-failure semantic controls for lock omission, missing binding validation and accidental description sharing, Rust 1.85 checks, full repository verification, Linux hosted contention/release and local macOS evidence. Windows tests pin the unsupported boundary.
Measure release CLI size against the same-toolchain baseline. Also measure a small release probe that actually calls root qualification and locking against a matched probe, because dead-code elimination hides an inactive library feature from CLI size. Record toolchain, target, exact bytes and interpretation; these measurements do not establish future executor size.
Historical execution results are retained in the filesystem validation record.
Qualified destination observation
Implemented with local APFS evidence and hosted Linux and Windows boundary checks. Windows qualification remains pending.
Intent and scope
Capture existing file bytes and Unix modes, or verified absence, beneath one qualified physical scope. Reject unsafe paths and potentially overlapping destinations before a future executor receives observations. Retain opaque evidence that can be revalidated while the caller retains its scope writer lock. Preview may observe without acquiring that lock and performs no application writes.
This is the next part of the M1 filesystem stage after AD-0028. It activates no CLI behavior, ownership adoption, permission authority, schema-2 persistence, source stabilization, file application or recovery. It does not establish that the caller supplied the complete lifecycle destination union. A future lifecycle orchestrator must derive that union and select exactly the affected observations for the pure planner. Windows qualification remains pending.
Alternatives and decision
Select descriptor-relative traversal with no symlink following, retained directory/file identities and a conservative batch overlap rule. Qualify APFS on macOS and ext-family filesystems and tmpfs on Linux. Reject other filesystems, cross-mount traversal and unknown qualification evidence explicitly. No new dependency is required beyond the admitted rustix 1.1.4.
Ambient path canonicalization followed by ordinary reads is rejected: checking a path does not bind subsequent accesses to that checked directory chain. Creating temporary names to discover comparison behavior is rejected because preview must not create files. Exact emulation of every filesystem’s Unicode comparison is also rejected: it adds version-dependent tables and still does not qualify an unknown filesystem.
APFS preserves filename spelling while supporting normalization and case insensitivity. ext4 can select Unicode case-insensitive lookup per directory. See the Apple APFS FAQ and Linux ext4 documentation. Use a conservative relation instead of claiming that byte inequality proves distinctness. This intentionally rejects some valid case-sensitive or Unicode batches. A later qualified comparison implementation may relax rejection without changing the safety boundary.
Public interface
QualifiedScope::observe_destinations
returns a DestinationSnapshot borrowing the scope. The snapshot exposes
observations() as a borrowed slice and verify_unchanged() as a fallible
revalidation operation.
Export DestinationSnapshot from filesystem. Reuse
files::plan::FileLocation, ObservedFile and FileValue. Existing regular
files produce exact bytes and Some(mode & 0o7777); absent destinations produce
None. Sort observations by the existing exact location key: scope, raw root
OsStr and portable path. Permutation of locations must not change the returned
data or acceptance.
The snapshot borrows its qualified scope and privately owns retained evidence. It is opaque, non-Clone and nonserializable, with redacted Debug. Returning or cloning plain observations conveys no execution authority. There is no raw descriptor, write method, lock acquisition or successful partial result.
Every location root must itself satisfy scope selection and remain bound to the same physical directory as the supplied QualifiedScope. Different scope labels and ancestor-alias spellings are permitted for distinct locations, but cannot split physical identity or the batch overlap checks. Retain and revalidate the selected spellings of additional roots. A different physical root is an error. An empty input returns an empty snapshot only after validating the scope and its filesystem profile.
Path and filesystem qualification
Validate all portable relative paths using the ownership path rules before accessing descendants. Reject empty, dot, parent, absolute, repeated-separator, backslash, colon and control-character paths. Preserve native root paths without lossy conversion. Error display may use the existing contextual presentation.
Qualify the pinned root with descriptor-based filesystem metadata. On Linux, accept the ext-family magic or tmpfs magic and require a returned statx mount ID. If the kernel cannot supply that evidence, return an explicit qualification error. On macOS, require the APFS filesystem name and retain filesystem identity and mount binding from fstatfs. Other operating systems remain unsupported. Every opened descendant must stay on the selected mount: Linux mount IDs must match, and macOS filesystem/mount identities must match. Comparing only device numbers is insufficient for same-device bind mounts. Recheck these facts during verification. Network, FUSE, overlay and other unqualified filesystems are not implicitly accepted.
Walk one portable component at a time relative to a pinned directory descriptor. Open each existing intermediate directory without following symlinks. Require the requested component’s exact bytes to appear in descriptor-relative directory enumeration, so a case or normalization alias is rejected rather than silently adopted. Compare the selected entry and opened directory identities. Retain the opened directories to prevent inode reuse from imitating the original chain. Use close-on-exec descriptors throughout.
Inspect the final entry without following symlinks before opening it; reject a known nonregular type before any file open. An existing final component must likewise have exact stored spelling, be opened without symlink following and be a regular file with link count exactly one. Reject directories, symlinks, hard links and special files. Nonblocking opening and type checks must prevent a FIFO or device fixture from hanging observation. Compare entry and open identities, qualify its mount and retain the file handle.
At the first missing component, retain the deepest existing directory and the entire unresolved suffix as absence evidence. Do not create directories or probe files. Only an actual not-found result establishes absence; permission, I/O, unsupported and malformed-path errors remain errors. A non-directory ancestor does not mean absence. Revalidate the existing chain and first missing component before returning the snapshot.
Conservative overlap rule
All locations resolve under one physical root. Reject exact duplicate locations and every pair whose portable paths may denote the same destination or an ancestor/descendant pair. Compare components in order up to the shorter path:
- Two ASCII components may match when their ASCII lowercase strings match.
- If either component contains a non-ASCII character, treat that component pair as potentially matching, regardless of its spelling.
- Otherwise the components are distinct and the two paths cannot overlap.
If every compared component may match, reject the pair. This catches case-only
collisions, Unicode normalization/case uncertainty, aliases across root
spellings or scope labels, and potential prefix conflicts below missing parents.
Apply the rule to the whole supplied set, including existing files. It is a
conservative rejection rule, not a claim that every rejected pair is
filesystem-equivalent. For example, a/x and b/y are distinct; a/X and
a/x conflict; a/é and a/e conflict; a/é and b/e are distinct. A single
Unicode path remains eligible when its existing components have exact stored
spellings.
Also reject duplicate opened file identities as a physical backstop. Existing directory aliases introduced by mount traversal are excluded by mount checks. No permission to remove or replace anything follows from successful observation.
Capture and revalidation
Capture the regular file through its retained handle. Require stable identity, type, link count, size, permission bits, modification time and change time, including fractional seconds, across the read, and require the read byte count to match the sampled size. Do not use access time as a precondition: reads can update it. Capture failure returns no snapshot. File data may be binary; never decode or normalize it.
Before returning, revalidate all selected root bindings, every directory chain,
mount evidence and final entry identity or absence. Recheck captured file
metadata and exact bytes. verify_unchanged() repeats those checks against the
retained evidence and exact observations; it never refreshes the snapshot into a
new accepted state. Detect same-length byte edits even when modification time is
restored, timestamp-only changes, and edits with bytes and modification time
restored while change time differs. Also detect permission changes,
identical-byte inode replacement, ancestor replacement, missing-to-present
transitions and root-alias retargeting. Unrelated sibling edits do not
invalidate a snapshot merely by changing directory times.
These are sampled observations under the same namespace assumptions as AD-0028. Pinned handles and revalidation do not freeze external writers, prevent hostile ancestor relocation between checks or form an operating-system security sandbox. A future executor must hold the writer guard, validate state/source authority and permission policy, recheck destination evidence immediately before each effect, and record interruptions. This API provides none of those effects or recovery.
Recoverable filesystem, read and descriptor-exhaustion failures return
contextual errors; only not-found establishes absence. Allocation errors
reported by a fallible read also return contextual errors. The API does not
guarantee recoverable memory exhaustion: ordinary Rust infallible allocations,
including captured-byte cloning, may abort, and operating-system process
termination may prevent a return of Err. Retained handles close on drop and on
returned-error paths; process abort or termination does not guarantee Rust
destructors run. No production retries or silent fallback to ambient path reads
are allowed.
Contract and acceptance coverage
The requirements are recorded in qualified destination observations. The public acceptance suite covers capture, absence, candidate validation, unsafe entries, alias handling, adversarial changes and opaque evidence. Focused private tests in the backend, qualification and candidate modules independently pin retained evidence and comparison rules. Filesystem fixtures use temporary roots; privileged mount behavior remains outside the exercised contract.
Historical execution results are retained in the filesystem validation record.
Explicit file permission selection
This design describes the implemented inactive pure library API.
AD-0026 separates captured resource modes from generated output permissions. The selected next prerequisite adds a pure selection interface before any qualified file effects.
Existing ordinary permission bits are preserved exactly. Unknown existing modes and existing special bits are errors. New generated output uses 0o0600. New resources also use 0o0600; an explicit resource-only execution policy may select 0o0700 when captured evidence contains the owner-execute bit. Source group/other and special bits are never copied. Supplied modes outside 0o0000..=0o7777 reject even when selection would not use them.
This is a supplied-value policy, with no I/O or process-umask input. It grants no ownership, containment, metadata-preservation or execution authority. The separate qualified executor enforces a restricted owner/group and metadata envelope before atomic replacement. The compiler, planners and current CLI remain unchanged. Lifecycle state persistence and activation remain pending.
Public interface and examples
files::permissions::select_file_mode accepts
DestinationMode::{Absent, Present(Option<u32>)},
PermissionSource::{Generated, Resource { captured_mode: Option<u32> }} and
ResourceExecutionPolicy::{Disabled, OwnerExecutable} and returns
anyhow::Result<u32>. All three enums derive Debug, Clone, Copy,
PartialEq and Eq. The API has no serialized representation or effect
capability.
An existing 0o000, 0o400, 0o640, 0o755 or 0o777 remains exact for
either source kind and either resource policy. Existing 0o1000, 0o2000,
0o4000 and 0o2640 reject rather than losing special bits. Unknown existing
mode rejects.
For an absent resource with OwnerExecutable, captured 0o100, 0o755 and
0o4777 select 0o700; captured 0o011, 0o644, 0o1000, 0o2000 and
0o4000 select 0o600. Unknown captured mode rejects for that selection but is
allowed with Disabled or an existing ordinary destination mode. Out-of-range
captured evidence rejects even when an existing destination mode would otherwise
win. Contextual errors distinguish destination evidence, captured resource
evidence, unknown existing mode, existing special bits and unknown
executable-resource mode.
The
implementation
selects from supplied values. The
public acceptance tests
exercise literal modes and diagnostics. Independent omission controls pin
preservation, unknown-mode handling, explicit policy, owner execution,
special-bit handling and both evidence ranges. No mutable input exists: all
inputs are Copy values and the function consults no filesystem, environment or
clock.
See specification 4.10 and AD-0031. The opt-in executor applies selected bits only after qualification; hosted executor qualification and lifecycle integration remain required. M1-A2, M1-A3 and M1 remain incomplete.