Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
10c5b22
fix: use handle-based FileHelper deletion
mnriem Nov 9, 2026
1204653
fix: retain deletion roots and preserve directory permissions
mnriem Nov 9, 2026
aec2fa5
fix: resolve deletion paths through the retained root
mnriem Nov 9, 2026
d631ce1
fix: retain root handles and bound deletion traversal
mnriem Nov 10, 2026
26759e2
Merge upstream main to refresh shared filesystem helpers
mnriem Nov 10, 2026
065b56d
fix: preserve helper access and close operation owners
mnriem Nov 10, 2026
2824a4c
fix: validate acquired root metadata and isolate native errors
mnriem Nov 10, 2026
0108a88
fix: preserve the Windows native error code when copying
mnriem Nov 10, 2026
2fd9908
fix: anchor Windows parent lookup during deletion
mnriem Nov 10, 2026
3877f0d
fix: preserve recursive directory permission behavior
mnriem Nov 10, 2026
8e57948
Merge upstream main and preserve shared file-handle contracts
mnriem Nov 10, 2026
55432cb
fix: bound descriptors during POSIX deletion traversal
mnriem Nov 10, 2026
ddd74cd
fix: bound recursive POSIX deletion handles
mnriem Nov 10, 2026
549791b
Merge upstream main while preserving bounded deletion traversal
mnriem Nov 10, 2026
b3b1cdc
fix: support legacy Windows file identity queries
mnriem Nov 10, 2026
0a45f0d
fix: close catalog HTTP errors before translating
mnriem Nov 10, 2026
7e2c359
fix: use native handles for Windows deletion operations
mnriem Nov 10, 2026
93ecd17
test: exercise native Windows deletion boundaries
mnriem Nov 10, 2026
b81ebb8
fix: synchronize relative Windows directory I/O
mnriem Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion design/catalogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ Current behavior differs by primitive:
sources fetch repository catalogs when online and fall back to packaged
snapshots only for catalog-unavailable failures; malformed or unsafe content
is not masked by the snapshot. Bundle stacks memoize parsed payloads for the
lifetime of one stack instance.
lifetime of one stack instance. When translating an HTTP status error, the
fetcher closes its response body before propagating the catalog error, while
retaining the original exception as its cause and preserving status-based
fallback classification.

A future shared fetch boundary may share URL validation, redirect validation,
bounded reads, cache records, and error types. It must preserve each
Expand Down
158 changes: 144 additions & 14 deletions design/file-helper.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ the policy. `specify_cli._project.resolve_specify_project_files()` performs the
normal existing-project resolution and returns the same handoff for new CLI
adapters. Commands using another established root call `project_files` on
their actually selected root. Retain a helper only for the current operation;
a later invocation resolves a new helper, so manual edits take effect.
a later invocation resolves a new helper, so manual edits take effect. Use the
returned owner as a context manager, or call `close()` in the owner's cleanup
path, to release retained root handles at that operation boundary.

The `specify_cli.integration_state` helpers are an explicit managed-metadata
exception to this handoff. Their reads and writes of `.specify/integration.json`
Expand Down Expand Up @@ -101,12 +103,12 @@ existing lifecycle rollback, including prior policy and unrelated options.
```python
from specify_cli.file_helper import FileHelper

files = FileHelper(project_root) # deny symlinks by default
files.mkdir("generated", exist_ok=True)
files.create_text("generated/config.txt", "initial\n")
files.write_text("generated/config.txt", "updated\n")
content = files.read_text("generated/config.txt")
files.delete("generated", recursive=True)
with FileHelper(project_root) as files: # deny symlinks by default
files.mkdir("generated", exist_ok=True)
files.create_text("generated/config.txt", "initial\n")
files.write_text("generated/config.txt", "updated\n")
content = files.read_text("generated/config.txt")
files.delete("generated", recursive=True)
```

## Boundary and policy
Expand Down Expand Up @@ -144,6 +146,66 @@ any followed target hierarchy. An unrelated project symlink does not prevent
an operation. A helper cannot cross to another root; callers establish separate
helpers for independent project/source/managed boundaries.

On supported deletion backends, construction retains a handle to the canonical
root until `close()` or context-manager exit. Keeping the original directory open
prevents its identity from being reused for a different directory while the
helper is alive; stored device/inode numbers alone are not the binding. On POSIX,
the handle uses search-only access where available. On Windows, construction
requests only root metadata access (`FILE_READ_ATTRIBUTES`) and permits delete
sharing; it does not request directory-list access on the canonical ancestors.
The retained reference selects a tagged identity scheme from that handle.
The full 128-bit `FileIdInfo` identity is preferred. If that query reports
`ERROR_INVALID_FUNCTION`, `ERROR_NOT_SUPPORTED`, or `ERROR_INVALID_PARAMETER`,
or returns an all-zero ID, the helper queries the opened volume's filesystem
and uses `GetFileInformationByHandle`'s volume serial plus nonzero 64-bit file
index only for FAT, FAT32, exFAT, or NTFS. The volume query is handle-relative
and works through the filesystem driver rather than a pathname-based volume
lookup, including when a network driver exposes the necessary information.
ReFS and unrecognized filesystems do not fall back to potentially ambiguous
64-bit indexes. Access-denied, sharing, and other unrelated errors still
propagate rather than selecting a weaker identity.
Before accepting it, construction compares Python-native metadata obtained from
the acquired handle with the initially observed root metadata. This comparison
does not reopen the root pathname, so replacement followed by restoration cannot
substitute a different retained object. The metadata query uses a temporary
non-inheritable duplicate and leaves the original reference open.
If that reference or identity query is unavailable, the native capability error
is deferred to `delete()` rather than preventing ordinary read/write operations.
The cached error remains traceback-free; each failed deletion raises an
independent shallow copy retaining the native error type and filesystem details.

Windows deletion separately pins the canonical ancestry through the drive/share
root without delete sharing, then queries both that root handle and the retained
reference using the already selected scheme. It compares their live identities,
not a cached legacy index: FAT IDs can change after renaming. It neither
renegotiates identity schemes mid-operation nor compares identities from
different schemes. Those operation-time handles require both
`FILE_LIST_DIRECTORY` and `FILE_READ_ATTRIBUTES` on every canonical ancestor,
including those above the helper root, and reject reparse points. An ACL allowing
traversal but not listing can therefore prevent deletion while still allowing
bootstrap reads. Windows deletion also retains non-reparse handles to accessed
parent directories throughout link resolution, before classifying child entries
or reading link targets. Each child is opened relative to its native parent
handle, not by reconstructing an absolute pathname. Metadata and symbolic-link
targets are read from opened leaf handles; directory names are enumerated from
the directory handle. Relative native opens request both
`FILE_SYNCHRONOUS_IO_NONALERT` and `SYNCHRONIZE`, including directories, so
enumeration cannot consume a buffer before a pending directory query completes.
These accessed directories also require
directory-list/read-attribute access. The resolution handles are released after
selection; native mutation parents and root ancestry remain retained during
preflight and removal. Directory sharing locks alone do not prevent in-place
reparse changes: handle-relative operations retain the opened directory object
instead of following its changed pathname. These locks are released at the end of
deletion; the metadata-only reference remains until the helper closes. Automatic
finalization is a compatibility fallback, not a substitute for deterministic
cleanup. Bootstrap configuration and managed integration-state consumers close
their own helpers explicitly. Installed-workflow listing also closes its
`project_files()` owner on every validation, registry-read, and successful exit;
other consumers receiving `project_files()` own its lifetime.
Pinning the root does not make other pathname-based operations
descriptor-relative or change their documented concurrency limitations.

| Operation | Default deny | Explicit allow |
|---|---|---|
| Read file | Reject accessed parent or leaf links | Follow only existing contained regular-file targets |
Expand All @@ -158,6 +220,9 @@ helpers for independent project/source/managed boundaries.

## API and failures

- `close()` releases the helper's retained root handles and is idempotent.
`with FileHelper(...) as files:` closes on both success and failure. Operations
on an explicitly closed helper raise `FileHelperError`.
- `mkdir(path, parents=False, exist_ok=False)` creates directories.
- `read_bytes(path)` / `read_text(path, encoding="utf-8")` read regular files.
- `create_bytes(path, content, mode=0o644)` / `create_text(...)` exclusively
Expand Down Expand Up @@ -224,7 +289,58 @@ helpers for independent project/source/managed boundaries.
the attributes of an existing file.
- `delete(path, recursive=False)` unlinks files/links or removes directories.
Nonrecursive directory deletion requires an empty directory. The established
root cannot be deleted.
root cannot be deleted. Recursive enumeration and each removal use anchored
directories. Each deletion borrows the root handle retained at construction,
checks the currently named root for replacement, and uses that original anchor through
preflight and all removals. POSIX uses this retained root for component
classification, link-target reads and resolution, recursive preflight, and
per-entry revalidation, as well as descriptor-relative enumeration and
`unlink`/`rmdir`. Canonical pathnames identify root-relative entries but are not
reopened for metadata or link resolution. Windows verifies the full canonical
ancestry during root acquisition and then carries native directory handles
through child opens, metadata queries, link-target reads, and enumeration.
Removal opens the leaf itself with `DELETE` access and marks that same handle
with `FileDispositionInfo`; it does not call pathname `unlink` or `rmdir`.
This does not change the trusted treatment of aliases resolved
at root establishment. Allowed contained parent links are resolved within the
retained boundary, and leaf links are still unlinked rather than followed.
Platforms lacking the required directory-descriptor operations fail with
`ENOTSUP`, not pathname-only deletion.
POSIX traversal and removal use search/path-only descriptors (`O_SEARCH` or
`O_PATH`) where available, including Darwin's native `O_SEARCH` value on Python
builds that do not expose it, so known entries in write-and-execute-only parents
can still be removed. Read-capable descriptors are requested only for directories
being enumerated; systems without search/path-only flags retain read-capable
traversal. Recursive enumeration still requires directory read permission, and
removal still requires the operating system's normal directory permissions.
Preflight opens the enumerated child directly as readable, relative to its
anchored parent, without a search-only open or `"."` reopening first. An empty
readable directory therefore does not need its own search permission to be
removed; accessing children in a nonempty directory still requires search
permission. The parent still needs the permissions required to remove its
directory entry.
On systems using the read-capable fallback, root establishment also requires
read permission on the root.
POSIX component resolution and final parent anchoring retain only the current
traversal descriptor, opening its child before releasing the preceding one.
Their live descriptor count is bounded independently of operation-path depth,
in addition to the helper's retained root. Link-target jumps and `..` are
re-anchored through that root rather than following an opened directory's
physical parent after a rename. Windows retains native directory handles for
its child-relative traversal, including during recursive preflight and removal.
POSIX recursive preflight and mutation use explicit worklists and separate
eight-directory handle caches. The selected parent and established root remain
anchored outside those caches. An uncached parent is reacquired component by
component from the nearest retained prefix with no-follow flags, releasing
least-recently-used handles as the cache fills. Removed directories are evicted,
and preflight handles are not reused for mutation. Live descriptors are bounded
independently of recursive depth, including transient opens and enumeration;
worklist and complete-plan memory still scale with the selected tree.
Reacquiring evicted prefixes trades additional opens for that fixed handle
budget. Total opens can grow with the sum of visited path lengths, rather than
being unconditionally linear in the directory count. Windows retains its
native-handle traversal instead of evicting handles needed for child-relative
operations. The complete plan is still preflighted before any removal.
- `symlink(target, path)` creates a link exclusively. Relative targets are
interpreted relative to the link parent, not the helper root.

Expand Down Expand Up @@ -283,11 +399,23 @@ substituted symlink. This does not freeze file contents against in-place edits.

Preflight detects static policy failures before recursive deletion or parent
creation. Atomic writes recheck the original path before replacement, and
deletion rechecks each accessed entry. No operation is a general race-free
sandbox. For operations that still use validated paths, another process can
exchange parents or entries between checks and syscalls. An I/O failure during
any mutation can leave partial results. Protect against concurrent untrusted
mutation separately.
deletion rechecks each accessed entry. Deletion also anchors directory
enumeration and removal so exchanging an accessed parent for a link cannot
redirect those operations. Reopening the absolute root pathname for each entry is
avoided; the established root handle is never rebound to a replacement, even if
device/inode values are reused. POSIX removal stays attached to
the opened directory even if that directory is renamed; validation continues to
inspect that same directory tree. Windows prevents ancestor renames while handles
are held. Concurrent changes may still cause deletion to fail or leave
partial results, and preflight is not a transaction or an immutable tree snapshot.
POSIX leaf removal acts on the current entry in the anchored parent; Windows
removal acts on its opened leaf handle. No operation is a general race-free
sandbox. For operations that still
use validated paths (reads, directory creation, atomic writes, and symlink
creation), portable path checks are **not**
a race-free sandbox: another process can exchange parents or entries between
checks and syscalls, and an I/O failure during mutation can leave partial
results. Protect against concurrent untrusted mutation separately.
Exclusive file creation anchors its resolved parent hierarchy as described
above. On POSIX, renaming an opened directory does not redirect creation to
its replacement: creation stays with the opened directory, even if it is
Expand Down Expand Up @@ -317,7 +445,9 @@ rather than falling back to path-based overwrite.
Windows path classification and directory-mode reparse rejection have portable
regression tests. Native Windows path and junction tests also exist and are
skipped on other hosts; running the portable cases on macOS does not validate
Windows filesystem syscalls or junction behavior. Exclusive-creation tests
Windows filesystem syscalls or junction behavior. Deletion replacement tests
exercise swaps at enumeration and removal boundaries on the native host; Windows
handle-sharing behavior requires a native Windows run. Exclusive-creation tests
cover parent replacement before anchoring and at the final open, along with
native Windows parent-handle pinning and portable handle-lifetime checks;
portable checks do not substitute for native Windows execution.
Loading
Loading