Skip to content

Finding #9: resolve included-file roots for Clean Stale Archives; conservative on any unproven attribution#17

Merged
bcourbage merged 3 commits into
mainfrom
fix/finding9-included-root-attribution
Jul 20, 2026
Merged

Finding #9: resolve included-file roots for Clean Stale Archives; conservative on any unproven attribution#17
bcourbage merged 3 commits into
mainfrom
fix/finding9-included-root-attribution

Conversation

@bcourbage

@bcourbage bcourbage commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Finding #9 — "Clean Stale Archives" now attributes an archive correctly when a profile's roots come from an included file, and stays conservative (never preselecting a destructive cleanup) whenever attribution can't be proven.

Problem

Profile collection read only the direct root = lines. A profile whose roots live in an include/source file therefore looked rootless, so an archive it actively uses could be classified as a local orphan and start checked for deletion.

Design

  • ProfileRootResolver (pure) resolves a profile's effective roots recursively through include / source / include? / source?, replicating upstream prefs.ml semantics: resolved relative to the Unison directory; include appends .prf only when the exact file is absent; source never appends; ? variants skip a proven-absent file. It is conservative — any ambiguity it cannot prove past marks the resolution reliable == false: a missing required target, an unreadable/unparseable target, a cycle, a malformed/.raw line Unison would itself reject, or exceeding a hard traversal bound (cycle detection + maxFiles/maxDepth guarantee termination that upstream lacks).
  • CleanStaleArchivesWindowController feeds the resolved roots to the matcher and folds reliable into attributionReliable, so an unreliably-resolved profile's archives stay uncertain and never start checked.

Safety properties

  • Unreadable ≠ absent. An existing-but-unreadable target (invalid UTF-8 that Unison would still read as bytes, or a non-regular object) is never treated as absent, even for an optional include?/source? — it marks the resolution unreliable. Only a proven-.missing optional is skipped.
  • Precise filesystemRead. A directory, or a non-regular object (FIFO/device/socket), classifies as .unreadable (not .missing): for include its existence correctly suppresses the .prf fallback, and a FIFO/device is classified via stat without being opened (no blocking). A leading UTF-8 BOM is stripped so the first line never mis-parses.
  • Enumeration failure → global uncertainty. If the Unison directory can't be enumerated, the controller does not return an empty profile set (which would make every archive a certain orphan and preselect the local-only ones). It propagates global uncertainty: every row becomes uncertain and none are preselected.
  • Uncertainty is surfaced. An orange banner (pure, tested attributionWarning) explains an unreadable directory and names profiles with unresolved/unreadable includes; the per-row tooltip lists the includes case too.

Coverage

Pure-logic tests: direct/nested/diamond/cyclic includes; required-vs-optional missing; source no-.prf; relative-to-Unison-dir + escaped names; malformed .raw; rootalias surfaced; bounded traversal; optional invalid-UTF-8 → unreliable; exact-path directory alongside a valid <name>.prf → no fallback + unreliable; FIFO → .unreadable without hanging; directory/missing/regular classification; BOM parity; global uncertainty forces no destructive preselect; warning-text content (no em-dash). Full suite green.

Scope note

The enumeration-failure → no-preselect invariant is proven at the unit level via the pure rowUncertain/defaultsToChecked composition and attributionWarning; the controller's private profileScan()/window wiring is a straight-line application of those. End-to-end window behavior against a live .unison directory remains manual (no UI harness).

Untouched

Swift + tests only. vendor/, patches/, and the vendored blob (a57f5c4e) are untouched.

bcourbage and others added 3 commits July 19, 2026 23:23
"Clean Stale Archives" collected only each profile's direct `root =` values, so
a profile whose roots come from an included file contributed nothing: its live
archive matched no profile, appeared as a local orphan, and could start CHECKED
for deletion.

Add pure `ProfileRootResolver` that resolves a profile's effective roots (and
rootaliases) by following include/source recursively, replicating upstream
prefs.ml semantics:
- `include f`: fileInUnisonDir(f); exact file if it exists else `f.prf`; missing
  is fatal. `source f`: literal fileInUnisonDir(f), never `.prf`; missing fatal.
  `include?`/`source?`: missing file silently skipped. All tokens resolve
  relative to the Unison directory, including nested includes.
- Conservative reliability: a required missing/unreadable file, a cycle, a
  malformed/`.raw` line (which Unison itself rejects), or exceeding the
  traversal bound makes resolution unreliable.
- Cycle detection (recursion stack) + hard bounds (maxFiles/maxDepth) guarantee
  termination that upstream lacks.

CleanStaleArchivesWindowController.profiles() now feeds resolved roots to the
matcher and folds resolution.reliable into attributionReliable, so an archive
whose roots live in an included file is attributed correctly and an
unreliably-resolved profile's archives stay uncertain (never preselected).

Tests: 18 ProfileRootResolver cases with scratch fixtures (direct/include/
source/exact-file/optional-missing/required-missing/nested/diamond/cycle/
subdir/escaped-space/malformed/rootalias) plus an injected reader for
unreadable and unbounded-chain cases. No real archives touched. Pure-logic
coverage only — not field-proven through the live Clean Stale Archives window.

No C/bridge/vendor change; blob a57f5c4e, vendor/ and patches/ untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Safety-correction pass for PR #17 (included-root attribution).

1. Existing-but-unreadable target is no longer treated as absent, even
   when OPTIONAL. `include?`/`source?` skip only a proven-`.missing` file;
   an unreadable one is `.unreadable` -> unreliable. Unison reads profiles
   as bytes, so a file we fail to decode as UTF-8 may still carry roots we
   can't see; treating it as absent would misattribute an archive as an
   orphan.

2. filesystemRead classification made precise. A directory, or a
   non-regular object (FIFO/device/socket), is `.unreadable` and NOT
   `.missing`: its existence must suppress the `.prf` fallback for
   `include`, and a FIFO/device is classified via stat WITHOUT being opened
   (an open could block indefinitely). A leading UTF-8 BOM is stripped so
   the first line never mis-parses as `.raw`.

3. Enumeration failure propagates GLOBAL uncertainty. When the Unison
   directory can't be enumerated, the controller no longer returns an empty
   profile set (which would mark every archive a certain orphan and
   preselect the local-only ones for deletion). Every row becomes uncertain
   and none are preselected.

4. Uncertainty is surfaced. A new orange banner (pure, tested
   `attributionWarning`) explains an unreadable directory and names
   profiles with unresolved/unreadable includes; the per-row tooltip now
   lists the includes case too.

5. Tests: optional invalid-UTF-8 -> unreliable; exact-path directory
   alongside a valid <name>.prf -> no fallback + unreliable; FIFO ->
   unreadable without hanging; missing/regular classification; BOM parity;
   global-uncertainty forces no destructive preselect; warning-text content
   + no em-dash. Full suite 580 tests, 0 failures (1 pre-existing env skip).

Debug build + Release compile both succeed. Swift-only; vendored blob
a57f5c4e and vendor/ patches/ untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@bcourbage bcourbage changed the title Finding #9: resolve included profile roots for Clean Stale Archives Finding #9: resolve included-file roots for Clean Stale Archives; conservative on any unproven attribution Jul 20, 2026
@bcourbage
bcourbage marked this pull request as ready for review July 20, 2026 19:11
@bcourbage
bcourbage merged commit cb7aca1 into main Jul 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant