From d977d37682db2892eac1dab22be79b66f71c5776 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:56:29 -0700 Subject: [PATCH 01/98] docs(architecture): add reqwest 0.12 to 0.13 migration plan Captures why the 0.12.28 pin still holds (0.13 replaced the rustls-tls-native-roots feature with a rustls-platform-verifier + pluggable-crypto model), the recommended rustls-no-provider + ring path that avoids the aws-lc-sys C build, the xwin/musl cross-compile risk, and a full validation + rollout + rollback plan. Planning only, no code change. --- .../reqwest-0.13-migration-plan.md | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 docs/architecture/reqwest-0.13-migration-plan.md diff --git a/docs/architecture/reqwest-0.13-migration-plan.md b/docs/architecture/reqwest-0.13-migration-plan.md new file mode 100644 index 000000000..9bb7822b0 --- /dev/null +++ b/docs/architecture/reqwest-0.13-migration-plan.md @@ -0,0 +1,389 @@ + + +# reqwest 0.12 to 0.13 Migration Plan (v1) + +Status: **PLANNING / not started**. Author: Robert S. A. Nio. Owner: SKY, LLC. +Related: [[access-broker-followups]] (TLS/handle plumbing style), the ring +nightly-canary blocker (issue #553), `vet-deps-real-audit-procedure`. + +--- + +## 1. TL;DR + +reqwest `0.13` removed the `rustls-tls-native-roots` / `rustls-tls` feature +flags and replaced the whole rustls TLS model with a +**`rustls-platform-verifier` + pluggable-crypto-provider** design. Our pin at +`0.12.28` is therefore still correct today, but `0.12` will eventually stop +receiving fixes and we must migrate. + +- **Recommended path: Option B — `reqwest 0.13` with `rustls-no-provider` + + an explicit `ring` `CryptoProvider` installed at `uffs-update` startup.** + This keeps the crypto backend we already cross-compile (`ring`), avoids the + heavy `aws-lc-sys` C build, and stays a single-provider tree. +- The only hard risk is **cross-compilation to `x86_64-pc-windows-msvc` (and + `x86_64-unknown-linux-musl`) via `cargo xwin`**. Option B inherits the + already-proven `ring` build; Option A (`aws-lc-rs`) does not and is the main + thing that makes this a "headache." +- Blast radius is small: reqwest is used in **exactly one crate** + (`uffs-update`), one file (`github.rs`), one function (`client()`), with **no + explicit TLS-builder calls**. The code delta is a few lines plus one + provider-install call. +- This migration does **not** resolve the ring `0.17.14` nightly-canary blocker + (#553): `ring` is still pulled transitively by `object_store`, `rustls`, and + `rustls-webpki` regardless of the reqwest feature we pick. Do not conflate the + two. + +--- + +## 2. Current state (as of 2026-07-14, v0.6.27) + +### 2.1 Declaration + +`Cargo.toml` (workspace): + +```toml +reqwest = { version = "0.12.28", default-features = false, features = [ + "blocking", + "rustls-tls-native-roots", + "json", +] } +``` + +Consumed by **`uffs-update` only** (`crates/uffs-update/Cargo.toml`: +`reqwest.workspace = true`). `uffs-update` is a **separate binary** from `uffs` +precisely so the HTTP + TLS stack never bloats the lean, fast-starting CLI. That +isolation invariant must survive the migration (see §7.4). + +### 2.2 Usage surface (the entire thing) + +`crates/uffs-update/src/github.rs`: + +```rust +fn client() -> Result { + reqwest::blocking::Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) // 30 s + .timeout(READ_TIMEOUT) // 60 s + .build() + .context("building HTTP client") +} +``` + +- Requests: `client.get(url).send()?.error_for_status()`, plus a capped + streaming body copy (`copy_capped`). Blocking. No async runtime. +- Endpoints: GitHub Releases API + asset download hosts (public CAs). +- **No TLS-builder calls at all** (`use_rustls_tls`, `add_root_certificate`, + `tls_built_in_native_certs`, `danger_*` are all absent). TLS behavior is + entirely determined by the feature flag: today that means "rustls with the OS + trust store." This is the single most important fact for the migration: we are + not configuring rustls by hand, so we only have to preserve the *default* + behavior ("rustls + system trust"), not reproduce a custom builder. +- Retry/timeout logic (`is_retryable`, `with_retry`) keys on + `reqwest::Error::{is_timeout, is_connect, status}` and + `reqwest::Result` / `reqwest::Error`. These APIs are unchanged in 0.13 and + need no edits (confirm during §6.1). + +### 2.3 Lock state (crypto / TLS deps today) + +| Crate | Version | Role | +|---|---|---| +| `ring` | 0.17.14 | crypto provider (via `rustls`, `rustls-webpki`, `object_store`) | +| `rustls` | 0.23.40 | TLS | +| `rustls-webpki` | 0.103.13 | cert path validation | +| `rustls-native-certs` | 0.8.4 | loads OS root store into rustls (what `-native-roots` pulls) | +| `aws-lc-rs` | absent | — | +| `aws-lc-sys` | absent | — | +| `rustls-platform-verifier` | absent | — | + +--- + +## 3. Why 0.13 broke the pin (what actually changed) + +reqwest `0.13` did not rename the feature. It **replaced the rustls model**: + +| reqwest 0.12 (ours) | reqwest 0.13.4 | +|---|---| +| `rustls-tls` = rustls + webpki bundled roots | removed | +| `rustls-tls-native-roots` = rustls + `rustls-native-certs` (OS roots), crypto = `ring` | removed | +| default-tls = `native-tls` (OpenSSL/schannel) | **default-tls = `rustls`** | +| — | `rustls` = `rustls-platform-verifier` + **`aws-lc-rs`** (crypto forced) | +| — | `rustls-no-provider` = `rustls-platform-verifier`, **no** crypto provider | + +Two consequences: + +1. **Trust model change.** `rustls-native-certs` *imports* the OS root + certificates into rustls' own webpki verifier. `rustls-platform-verifier` + instead *delegates verification to the OS* (SecTrust on macOS, CryptoAPI on + Windows, OpenSSL/native on Linux). For our use (public-CA HTTPS to GitHub) + these are functionally equivalent; platform-verifier is arguably more correct + (honors OS revocation and policy). Still, it is a behavior change and must be + smoke-tested against a real GitHub download on all three platforms (§6.3). +2. **Crypto-provider selection is now explicit.** In 0.12 the `ring` provider + was implicit. In 0.13 you either accept `aws-lc-rs` (the `rustls` feature) or + bring your own (`rustls-no-provider`). This is the crux of the decision. + +--- + +## 4. Options + +### Option A — `reqwest 0.13` with the `rustls` feature (aws-lc-rs) + +```toml +reqwest = { version = "0.13.4", default-features = false, features = [ + "blocking", "json", "rustls", +] } +``` + +- **Pros:** smallest Cargo change; the idiomatic 0.13 default; no startup code. +- **Cons (why this is the headache path):** + - Pulls **`aws-lc-rs` + `aws-lc-sys`**, a vendored AWS-LC C/assembly build. + Cross-compiling `aws-lc-sys` from macOS to `x86_64-pc-windows-msvc` via + `cargo xwin` needs NASM + a C toolchain for the target and is materially + harder than `ring`; it is the single most likely thing to break the Windows + ship job. `musl` (Linux) builds of `aws-lc-sys` are also touchier than ring. + - **Dual crypto providers.** `ring` stays in the tree (object_store, rustls, + rustls-webpki still pull it), so both `ring` and `aws-lc-rs` are compiled in. + rustls then has no unambiguous process default and + `ClientConfig::builder()` (the path reqwest uses internally) will **panic at + runtime** with "no process-level CryptoProvider available" unless we call + `CryptoProvider::install_default(...)` once at startup anyway. So Option A + does not even save us the startup call. + - New heavyweight deps to `cargo vet` (real `safe-to-deploy` audits for + `aws-lc-rs` and the vendored-C `aws-lc-sys`), larger `uffs-update` binary, + longer build. +- **Verdict:** rejected as the default. Only revisit if upstream forces + aws-lc-rs or if `rustls-no-provider` is dropped. + +### Option B — `reqwest 0.13` with `rustls-no-provider` + explicit `ring` (RECOMMENDED) + +```toml +reqwest = { version = "0.13.4", default-features = false, features = [ + "blocking", "json", "rustls-no-provider", +] } +``` + +Plus a one-time provider install at `uffs-update` startup (see §5.2). + +- **Pros:** + - Keeps **`ring`** as the only crypto provider — the exact backend the xwin + and musl pipelines already build successfully. Lowest cross-compile risk. + - Single-provider tree (no dual-provider ambiguity once we install the ring + default explicitly). + - No `aws-lc-sys` C build, smaller binary, smaller audit surface. +- **Cons:** + - `rustls-platform-verifier` still enters the lock (unavoidable in 0.13; it is + the trust mechanism). Needs a `cargo vet` audit. + - Requires the explicit `CryptoProvider::install_default` call and a + `rustls` (with `ring` feature) direct dev/normal dependency in + `uffs-update` so we can name the provider. Small, contained. + - Must keep our direct `rustls` version aligned with the one reqwest 0.13 + resolves (single `rustls` in the graph) so `install_default` targets the + same `CryptoProvider` type reqwest uses. +- **Verdict:** recommended. + +### Option C — native-tls + +Rejected on principle. `uffs-update`'s design note explicitly commits to rustls +("we never [use OpenSSL/schannel]"). native-tls reintroduces the OS TLS stack we +deliberately avoid. + +--- + +## 5. Recommended implementation (Option B), step by step + +### 5.1 Cargo manifest + +Workspace `Cargo.toml`: + +```toml +# ───── Network (self-update acquire helper only) ───── +# reqwest 0.13 replaced `rustls-tls-native-roots` with a +# `rustls-platform-verifier` (OS-native cert verification) + pluggable +# crypto-provider model. We use `rustls-no-provider` and install the `ring` +# provider ourselves (see uffs-update main) so the crypto backend stays `ring` +# — the one the xwin/musl cross-builds already prove — instead of dragging in +# aws-lc-sys. rustls-platform-verifier replaces rustls-native-certs for trust. +reqwest = { version = "0.13.4", default-features = false, features = [ + "blocking", + "json", + "rustls-no-provider", +] } + +# Direct handle on rustls so uffs-update can install the ring CryptoProvider as +# the process default. Keep the version pinned to whatever reqwest 0.13 resolves +# so there is exactly one rustls in the graph. +rustls = { version = "0.23", default-features = false, features = ["ring"] } +``` + +`crates/uffs-update/Cargo.toml`: add `rustls.workspace = true` next to the +existing `reqwest.workspace = true`. + +> Confirm the exact resolved `rustls` minor (`cargo tree -p reqwest -i rustls` +> after the bump) and match it; a split rustls graph would make +> `install_default` target the wrong provider type. + +### 5.2 Provider install at startup + +`crates/uffs-update/src/main.rs`, once, before any HTTPS call (top of `main` +before dispatch), documented and error-tolerant: + +```rust +/// Install the process-wide rustls crypto provider (ring) exactly once. +/// reqwest 0.13's `rustls-no-provider` feature ships no default provider, so +/// the first `ClientConfig::builder()` inside reqwest would otherwise panic +/// with "no process-level CryptoProvider available". Idempotent: a second call +/// (or a provider already installed by a dependency) returns Err, which we +/// ignore. +fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} +``` + +Call it at the very start of `main()`. Add a unit test that asserts the second +call is a no-op / does not panic, and that `github::client()` builds after it. + +### 5.3 Code changes in `github.rs` + +Expected: **none to the request logic.** `Client::builder().user_agent().connect_timeout().timeout().build()`, +`.get().send()`, `error_for_status()`, `is_timeout/is_connect/status`, +`reqwest::Error`/`reqwest::Result` are all stable across 0.12 to 0.13. Update +only the module doc comment (mention platform-verifier instead of +native-roots). Verify against the 0.13 changelog during §6.1; if any signature +moved, patch the one call site. + +### 5.4 Expected lock delta + +- **Added:** `rustls-platform-verifier` (+ its small platform shims: + `rustls-native-certs` may remain or be replaced; on Apple/Windows the verifier + uses OS APIs via `security-framework` / `windows-sys`, which are already in our + tree via other deps — confirm). +- **Removed:** possibly `rustls-native-certs` (if nothing else pulls it). +- **Unchanged:** `ring`, `rustls`, `rustls-webpki`. +- **Not added:** `aws-lc-rs`, `aws-lc-sys` (the whole point of Option B). + +Record the real delta from `cargo tree` after the change and paste it into the +PR. + +--- + +## 6. Validation plan (the part that de-risks the headache) + +### 6.1 Compile + API parity (host) +- [ ] `cargo build -p uffs-update` on macOS host (pinned nightly). +- [ ] Read the reqwest 0.13.0..0.13.4 changelog; confirm no breaking change hits + our four call sites. Patch if needed. +- [ ] `just lint-prod` + `just lint-tests` clean. + +### 6.2 Cross-compile (the primary risk gate) +- [ ] `just lint-ci-windows` (xwin clippy, `x86_64-pc-windows-msvc`) clean. +- [ ] Full `cargo xwin build -p uffs-update --release --target x86_64-pc-windows-msvc` + succeeds (this is where aws-lc-sys would have failed; ring should sail). +- [ ] Linux `x86_64-unknown-linux-musl` release build of `uffs-update` succeeds. +- [ ] Confirm no `aws-lc-sys` in `cargo tree` for any target. + +### 6.3 Runtime TLS smoke (all three platforms, real network) +- [ ] `uffs-update doctor` / `acquire` performs a real HTTPS GET against a live + GitHub release and downloads an asset, on macOS arm64, Linux x64, Windows + x64. This exercises `rustls-platform-verifier` against real public CAs. +- [ ] Negative check: a request to a host with an untrusted/self-signed cert is + rejected (verifier is actually enforcing, not bypassed). +- [ ] Confirm the process does not panic on first request (provider install + worked). + +### 6.4 Supply chain +- [ ] `cargo vet` real `safe-to-deploy` audits for every newly-added crate + (`rustls-platform-verifier` and any new platform shim). Follow + `vet-deps-real-audit-procedure`; do **not** rubber-stamp exemptions. +- [ ] `cargo deny check` clean (licenses/advisories for new deps). +- [ ] `cargo vet check --locked` green (matches the committed `imports.lock`; + remember the imports.lock `--locked` gotcha from the v0.6.27 ship). + +### 6.5 Full gate +- [ ] `just lint-pre-push` fully green (all buckets), then normal push. + +--- + +## 7. Risks and mitigations + +| Risk | Likelihood (Option B) | Mitigation | +|---|---|---| +| xwin/musl cross-build fails on a new crypto C dep | Low (ring reused) | §6.2 gates before merge; Option B avoids aws-lc-sys entirely | +| Runtime panic: no default CryptoProvider | Medium if forgotten | §5.2 explicit `install_default`; §6.3 first-request smoke | +| Split rustls graph (install targets wrong provider) | Low | Pin direct `rustls` to reqwest's resolved minor; assert single rustls in `cargo tree` | +| platform-verifier trust behaves differently than native-certs | Low | §6.3 real-download smoke on all three OSes + negative test | +| New deps stall the vet gate | Low | §6.4 real audits up front, in the same PR | +| Binary size / build time of `uffs-update` grows | Low | measure; still far smaller than aws-lc-rs path; isolation preserved (§7.4) | + +### 7.4 Isolation invariant +reqwest lives only in `uffs-update`. The migration must **not** let +`reqwest`/`rustls-platform-verifier` leak into `uffs`, `uffsd`, or any hot-path +crate. Verify post-change with +`cargo tree -e no-dev -i reqwest` (only `uffs-update`) and confirm the main +`uffs` binary's dep tree is unchanged. + +### 7.5 Relationship to the ring nightly-canary blocker (#553) +Neither option removes `ring` from the tree, so **this migration has no effect +on #553** (the `ring 0.17.14` aarch64-apple const-eval regression on the +floating nightly). Track #553 separately; it clears only when upstream ring +ships a fix and we `cargo update -p ring`. + +--- + +## 8. Rollout / sequencing + +1. Branch `feat/reqwest-0.13-migration` off `main`. Do **not** bundle with an + unrelated dep-bump sweep or a release. +2. Implement §5, then walk §6 top to bottom. Treat §6.2 (cross-compile) as the + go/no-go gate: if aws-lc-sys somehow sneaks in or ring fails to cross, stop + and reassess. +3. Open a normal PR (not a release PR). Let the merge-queue heavy jobs + (Windows clippy + tests, Linux, vet, deny) run. Paste the `cargo tree` lock + delta and the three-platform download-smoke results into the PR body. +4. Merge via the queue. Ship in the next routine `just ship-fresh` (the change + is code + deps, so it rides a normal version bump). + +## 9. Rollback + +Trivial and low-stakes: revert the `Cargo.toml`/`Cargo.lock`/`main.rs` changes +back to the `reqwest 0.12.28` pin (still on crates.io and maintained) and the +`ring`/native-certs tree returns. Because reqwest is confined to `uffs-update`, +a rollback cannot affect search, indexing, or the daemon. + +## 10. Open questions / decisions to lock before coding + +- [ ] Confirm reqwest 0.13's MSRV vs our pinned toolchain and `edition = 2024`. +- [ ] Confirm the exact `rustls` minor reqwest 0.13.4 resolves; pin our direct + `rustls` to match. +- [ ] Confirm whether `rustls-platform-verifier` on Windows/macOS pulls + `security-framework` / `windows-sys` versions already in our lock (avoid a + second copy). +- [ ] Decide whether to keep `rustls-native-certs` if a transitive dep still + needs it, or let it drop. +- [ ] Confirm `uffs-update`'s existing integration/e2e self-update test can run + the real-download smoke in CI, or whether it stays a manual per-platform + check. + +--- + +## 11. Appendix — quick reference + +**reqwest 0.13.4 rustls-relevant features** + +``` +default-tls = ["rustls"] +rustls = ["__rustls-aws-lc-rs", "dep:rustls-platform-verifier", "__rustls"] # aws-lc-rs +rustls-no-provider = ["dep:rustls-platform-verifier", "__rustls"] # bring your own +__rustls = ["dep:hyper-rustls", "dep:tokio-rustls", "dep:rustls", "__tls"] +(no rustls-tls, no rustls-tls-native-roots, no webpki-roots feature) +``` + +**One-liners** +- Who pulls ring: `cargo tree -i ring --depth 1` +- Confirm no aws-lc: `cargo tree -i aws-lc-sys` (expect "not found") +- reqwest confinement: `cargo tree -e no-dev -i reqwest` +- Resolved rustls: `cargo tree -p reqwest -i rustls` From ed29eeaa6ab6fc8959456a6c10b2aa13057cae74 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:56:35 -0700 Subject: [PATCH 02/98] feat(winget): winget-av-submit helper + Defender FP early-warning `just winget-av-submit ` builds the WDSI false-positive submission archive (password-protected, from the release's actual uffs-windows-x64.zip contents so it never drifts from the real bin set) and prints the portal URL, SHA-256s, and every form field to paste. A non-blocking Defender scan in release.yml warns at release time when the Windows binaries will likely trip the recurring winget Validation-Defender-Error, pointing at the helper. README documents the drill. Stopgap until Authenticode signing lands. --- .github/workflows/release.yml | 41 +++++++++++ just/packaging.just | 14 ++++ packaging/winget/README.md | 37 ++++++++++ packaging/winget/av-submit.sh | 133 ++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100755 packaging/winget/av-submit.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eacaa704a..0e8b6c940 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -612,6 +612,47 @@ jobs: fi done + # Unsigned Rust binaries recurrently trip Defender's ML heuristic, which + # blocks the winget-pkgs PR (Validation-Defender-Error) hours after the + # release. Scan the freshly-built binaries here so the risk surfaces NOW, + # in the release summary, pointing at the remediation. EARLY WARNING ONLY: + # a clean result does NOT guarantee winget passes (its cloud/ML validation + # uses fresh defs this runner may lack), and a hit never fails the release. + - name: Defender false-positive early warning + if: contains(matrix.target, 'windows') + continue-on-error: true + shell: pwsh + run: | + $mp = (Get-ChildItem "$env:ProgramData\Microsoft\Windows Defender\Platform\*\MpCmdRun.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | Select-Object -First 1).FullName + if (-not $mp) { $mp = "$env:ProgramFiles\Windows Defender\MpCmdRun.exe" } + if (-not (Test-Path $mp)) { "::notice::MpCmdRun not found; skipping Defender early-warning scan."; exit 0 } + $sig = (Get-MpComputerStatus -ErrorAction SilentlyContinue).AntivirusSignatureVersion + $dir = "target/${{ matrix.target }}/release" + $flagged = @() + Get-ChildItem "$dir/*.exe" -ErrorAction SilentlyContinue | ForEach-Object { + & $mp -Scan -ScanType 3 -File $_.FullName -DisableRemediation *> $null + if ($LASTEXITCODE -eq 2) { $flagged += $_.Name } + } + $tag = "${{ needs.release-preparation.outputs.tag }}" + if ($flagged.Count -gt 0) { + $list = $flagged -join ", " + "::warning::Defender flagged $($flagged.Count) binary(ies) [$list] (defs $sig). This release will likely hit the winget Validation-Defender-Error. Remediate with: just winget-av-submit $tag" + @( + "### 🛡️ Defender false-positive early warning", + "", + "**$($flagged.Count) binary(ies) flagged** (signature defs ``$sig``): $list", + "", + "This release will very likely trip ``Validation-Defender-Error`` on the winget-pkgs PR. Prep the WDSI false-positive submission:", + '```', + "just winget-av-submit $tag", + '```' + ) | Out-File -Append -Encoding utf8 $env:GITHUB_STEP_SUMMARY + } else { + "::notice::Defender early-warning scan clean (defs $sig). Note: winget cloud/ML validation may still differ." + "### 🛡️ Defender early-warning scan: clean (defs ``$sig``)" | Out-File -Append -Encoding utf8 $env:GITHUB_STEP_SUMMARY + } + - name: Package binaries shell: bash run: | diff --git a/just/packaging.just b/just/packaging.just index 6f509becd..8ecbc8a56 100644 --- a/just/packaging.just +++ b/just/packaging.just @@ -25,3 +25,17 @@ install-linux: cargo build --release -p uffs-cli sudo ./packaging/linux/install.sh target/release/uffs @printf "\033[0;32m✅ Install complete\033[0m\n" + +# Prep a WDSI Defender false-positive submission for a release's winget binaries. +# +# Use when a SkyLLC.UFFS winget-pkgs PR trips `Validation-Defender-Error`. +# Downloads the release's uffs-windows-x64.zip, builds a password-protected +# archive of the exact binaries winget ships, and prints the WDSI URL + the +# form values to paste. The submission itself is a manual web form (no dev API). +# +# just winget-av-submit v0.6.27 # prep only +# just winget-av-submit v0.6.27 --open # also open the WDSI form +# ZIP_PW=infected REPO=owner/repo just winget-av-submit v0.6.27 +winget-av-submit *ARGS: + @printf "\033[0;34m🛡️ Preparing WinGet/Defender false-positive submission...\033[0m\n" + @bash packaging/winget/av-submit.sh {{ ARGS }} diff --git a/packaging/winget/README.md b/packaging/winget/README.md index c434554c5..30120f39f 100644 --- a/packaging/winget/README.md +++ b/packaging/winget/README.md @@ -110,3 +110,40 @@ Until then, the broker is reachable from its installed path, and the CLI's When the GUI demo is bundled into the zip, add one entry to [`nested-aliases.yaml`](nested-aliases.yaml) (`uffs-windows-x64/uffs-gui.exe` → `uffs-gui`) and seed it on the next winget PR. No other change required. + +## Defender false-positive (`Validation-Defender-Error`) + +Unsigned Rust binaries recurrently trip Windows Defender's ML/heuristic +detection, which blocks the `SkyLLC.UFFS` winget-pkgs PR with the +`Validation-Defender-Error` label a few hours after a release (it has hit 0.6.18, +0.6.26, and 0.6.27). It is a **false positive**: the release bytes match the +published `SHA256SUMS`, and Microsoft's analyst has cleared prior submissions. + +**When a winget PR goes red on Defender, run:** + +```bash +just winget-av-submit v0.6.XX # prep the submission +just winget-av-submit v0.6.XX --open # prep + open the WDSI form +``` + +The [`av-submit.sh`](av-submit.sh) helper downloads the release's +`uffs-windows-x64.zip`, builds a password-protected archive (pw `infected`) of +exactly the binaries winget ships, and prints the submission URL +(), the SHA-256s, and every +form field to paste. The WDSI submission is a manual Microsoft-account web form +(no developer API), so the final click-through is by hand. + +The release build also runs a **best-effort Defender early-warning scan** +([`release.yml`](../../.github/workflows/release.yml)): if the fresh Windows +binaries scan dirty it flags the release summary and points at this command, so +the block surfaces at release time instead of from the winget PR hours later. A +clean scan is not a guarantee (winget's cloud/ML validation may differ); a hit is +a strong signal. + +**Sequence after submitting:** WDSI analyst clears the detection, then the winget +re-validation needs a **moderator** (author `@wingetbot run` is privilege-denied) +— nudge the PR citing the WDSI submission id and the "no positive detection" +result, or wait for wingetbot's auto-retry, and the label lifts. + +**Durable fix:** Authenticode code signing (Azure Trusted Signing) stops signed +binaries from tripping the unsigned-Rust heuristic and eliminates this drill. diff --git a/packaging/winget/av-submit.sh b/packaging/winget/av-submit.sh new file mode 100755 index 000000000..d2686336d --- /dev/null +++ b/packaging/winget/av-submit.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025-2026 SKY, LLC. +# +# UFFS — WinGet / Defender false-positive submission helper. +# +# When a release's winget-pkgs PR trips `Validation-Defender-Error` (the +# recurring unsigned-Rust-binary ML false positive), this scripts gets a WDSI +# (Microsoft Security Intelligence) false-positive submission to the doorstep: +# +# 1. Downloads the release's `uffs-windows-x64.zip` — the EXACT archive the +# winget package installs — for the given tag. +# 2. Builds a password-protected zip of *whatever binaries that archive +# actually contains* (so the submission never drifts from the real bin set; +# e.g. it correctly includes uffs-tui.exe when the release bundles it). +# 3. Prints per-binary + archive SHA-256. +# 4. Prints the WDSI URL and the exact form-field values to paste. +# +# The WDSI submission itself is an interactive Microsoft-account web form with +# no developer API, so the final click-through stays manual. This script does +# everything up to that click. +# +# Usage: +# packaging/winget/av-submit.sh [--open] +# just winget-av-submit v0.6.27 +# +# Env overrides: +# REPO GitHub repo to pull the release from (default: skyllc-ai/UltraFastFileSearch) +# ZIP_PW password for the submission archive (default: infected) + +set -euo pipefail + +TAG="${1:-}" +OPEN_BROWSER=0 +[[ "${2:-}" == "--open" ]] && OPEN_BROWSER=1 + +REPO="${REPO:-skyllc-ai/UltraFastFileSearch}" +ZIP_PW="${ZIP_PW:-infected}" +WDSI_URL="https://www.microsoft.com/en-us/wdsi/filesubmission" +WINGET_ZIP="uffs-windows-x64.zip" + +# sha256 helper (macOS: shasum; Linux: sha256sum). +sha256() { + if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'; + else sha256sum "$1" | awk '{print $1}'; fi +} + +# Resolve tag (default to latest published release). +if [[ -z "$TAG" ]]; then + TAG="$(gh release view --repo "$REPO" --json tagName -q .tagName 2>/dev/null || true)" + [[ -n "$TAG" ]] && echo "ℹ️ No tag given; using latest release: $TAG" +fi +[[ -z "$TAG" ]] && { echo "❌ usage: av-submit.sh [--open]" >&2; exit 2; } + +VERSION="${TAG#v}" +WORK="dist/winget-av-submit/${TAG}" +BINS="${WORK}/bins" +OUT="${WORK}/uffs-${TAG}-winget-binaries.zip" + +rm -rf "$WORK"; mkdir -p "$BINS" + +echo "📥 Downloading ${WINGET_ZIP} from ${REPO}@${TAG} ..." +gh release download "$TAG" --repo "$REPO" --pattern "$WINGET_ZIP" --dir "$WORK" --clobber + +echo "📦 Extracting the executables the winget package actually ships ..." +# -j junks the `uffs-windows-x64/` prefix; the `*.exe` filter drops any bundled +# docs/brand files so only the binaries Defender flags go into the submission. +unzip -o -j "${WORK}/${WINGET_ZIP}" '*.exe' -d "$BINS" >/dev/null + +shopt -s nullglob +EXE_PATHS=("${BINS}"/*.exe) +shopt -u nullglob +[[ ${#EXE_PATHS[@]} -eq 0 ]] && { echo "❌ no .exe found inside ${WINGET_ZIP}" >&2; exit 1; } +mapfile -t EXES < <(for p in "${EXE_PATHS[@]}"; do basename "$p"; done | sort) + +echo "🔐 Building password-protected submission archive (pw: ${ZIP_PW}) ..." +rm -f "$OUT" +( cd "$BINS" && zip -q -P "$ZIP_PW" -j "../$(basename "$OUT")" ./*.exe ) + +ARCHIVE_SHA="$(sha256 "$OUT")" + +# ── Report ────────────────────────────────────────────────────────────── +cat </dev/null 2>&1; then open "$WDSI_URL"; + elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$WDSI_URL"; fi +fi From cb02ed64ebcdd7d081eb05dfc7715c31c25cc4da Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:03:47 -0700 Subject: [PATCH 03/98] fix(core): stop uffsd crash on multi-extension --ext filters + close UTF-16 anti-pattern gaps resolve_ext_ids zipped an unbounded 0_u16.. range against ext_names.iter(); RangeFrom::next() computes its next state eagerly, so once a drive's ext_names table reached its legitimate u16::MAX ceiling, a query extension absent from that drive drove the range one step past u16::MAX and panicked with "attempt to add with overflow" - taking the whole daemon down. Switched to .enumerate(), which cannot overflow. Also closes two from_utf16_lossy anti-pattern gaps: $ATTRIBUTE_LIST/$UsnJrnl:$J name decoding now routes through the crate's shared malformed-name-safe decoder (WI-4.1) instead of a redundant lossy implementation, and the two genuinely non-filename Win32 sysinfo decodes (volume label, registry string) are marked AUDIT-OK. Also documents the --agg top-N cap and its terms:FIELD,top=N escape hatch in --agg --help, and splits args.rs's static help text into args_help.rs to stay under the workspace's 800-LOC file policy. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-cli/src/args.rs | 264 +---------------- crates/uffs-cli/src/args_help.rs | 265 ++++++++++++++++++ crates/uffs-core/src/compact.rs | 13 +- crates/uffs-core/src/compact_tests.rs | 72 +++++ .../uffs-mft/src/commands/sysinfo/windows.rs | 7 + .../uffs-mft/src/platform/metafile_decode.rs | 16 +- 6 files changed, 378 insertions(+), 259 deletions(-) create mode 100644 crates/uffs-cli/src/args_help.rs diff --git a/crates/uffs-cli/src/args.rs b/crates/uffs-cli/src/args.rs index 1a626b7ac..b2f898765 100644 --- a/crates/uffs-cli/src/args.rs +++ b/crates/uffs-cli/src/args.rs @@ -479,258 +479,18 @@ fn extend_drives_from_csv(drives: &mut Vec, value: &str) { } // ── Help & version ───────────────────────────────────────────────────── - -/// Short help text. -const HELP: &str = "\ -uffs - Ultra Fast File Search - -USAGE: uffs [OPTIONS] - uffs -- [ACTION] [OPTIONS] - -Search-first: any first token that is NOT a `--command` is a search pattern, -so `uffs --update`, `uffs --status`, etc. search for those words. Management is -`--` (below). To search a pattern that begins with `--`, use -`uffs -- `. - -EXAMPLES: - uffs '*.txt' Find all .txt files - uffs '>.*\\.log$' --drive C Regex search on C: - uffs '*' --mft-file C.bin Offline MFT search - uffs --ext rs,toml Find Rust project files - uffs --type picture --min-size 10MB Large images - uffs --update doctor Self-update health check - -COMMANDS: - --search Explicit search (same as the bare default) - --stats [PATH] Show filesystem statistics - --agg Run aggregate analytics - --deleted Forensic tombstone read: recently-deleted files from an MFT - --snapshot Capture the live MFT to a baseline file (Windows, for --diff) - --daemon Manage the UFFS daemon (start/stop/load/status) - --mcp Manage the UFFS MCP server - --update [ACTION] Self-update (snapshot/acquire/apply/doctor/recover) - --status Show combined system status - -COMMON OPTIONS: - -v, --verbose Verbose output - -d, --drive Drive letter (e.g. C or C:) - --drives Multiple drive letters - --mft-file Raw MFT file(s), comma-separated - --data-dir Data directory with drive_* subdirs - --files-only Show only files - --dirs-only Show only directories - --ext Filter by extension(s) - --type Filter by type: code, picture, video, etc. - -n, --limit Max results (0 = unlimited, default: 0) - -f, --format Output: table (default in a terminal), csv (default - when piped/redirected or with --out), json - --sort Sort by column, prefix - for desc - --out Write to file instead of console - --columns Columns to output (default: all) - --newer Modified after date/duration - --older Modified before date/duration - --diff Search the DELETED set vs a baseline MFT capture - (combine with any filter: --diff C_old.bin --drive C - '*.txt' --newer 30d). Needs the drive loaded. - --min-size Minimum file size (e.g. 100KB, 10MB) - --max-size Maximum file size - --profile Show timing breakdown - --benchmark Measure only, skip output - --help Print this help - --version Print version -"; - -/// Print help and exit. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_help() { - print!("{HELP}"); -} - -/// Print version and exit. The short line ties a running binary to the exact -/// source (`[.exe] ()`, `-dirty` for an uncommitted tree); -/// `verbose` adds the multi-line build fingerprint (commit date, rustc, target, -/// profile) for bug reports. Shared with every UFFS binary via `uffs-version`. -#[expect(clippy::print_stdout, reason = "intentional version output")] -pub(crate) fn print_version(verbose: bool) { - if verbose { - println!("{}", uffs_version::version_long!("uffs")); - } else { - println!("{}", uffs_version::version_short!("uffs")); - } -} - -// ── Subcommand help texts ───────────────────────────────────────────── - -/// Help text for `uffs --daemon`. -const DAEMON_HELP: &str = "\ -uffs --daemon — Manage the UFFS background daemon - -USAGE: uffs --daemon [OPTIONS] - -ACTIONS: - start Start the daemon - --data-dir PATH Data directory with drive_* subdirs - --mft-file PATH Raw MFT file(s), comma-separated - --no-cache Skip cached index, re-parse MFT - --elevate Request a UAC prompt (Windows) if not elevated - [env: UFFS_ELEVATE=1] - status Show daemon status (running, drives, PID) - -v, --verbose Long view: build, broker mode, memory, paths, perf stats - --json Machine-readable status + drives + stats - stop Gracefully stop the daemon - kill Hard kill + remove PID/socket files - restart Stop then restart (re-loads all indices) - load Hot-load additional MFT file(s) into running daemon - --mft-file PATH Raw MFT file(s) to load - --data-dir PATH Data directory with drive_* subdirs - --drive LETTER Drive letter(s) to load from data-dir - --no-cache Skip cache when loading - hibernate Demote shards to Cold (free RAM, encrypted cache stays) - [DRIVE...] Drive letter(s); omit to hibernate all loaded drives - --drives A,B Drive letter(s) as comma-separated list - preload Promote shard(s) to Hot and pin the tier - [DRIVE...] Drive letter(s); at least one required - --drives A,B Drive letter(s) as comma-separated list - --pin-minutes N Pin window in minutes (default: 30) - forget Evict drive(s) from registry and delete on-disk caches - [DRIVE...] Drive letter(s); at least one required - --drives A,B Drive letter(s) as comma-separated list - --force Auto-hibernate non-Cold drives first (default: refuse) - status_drives Per-drive tier + telemetry table (Hot/Warm/Parked/Cold) -"; - -/// Print daemon help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_daemon_help() { - print!("{DAEMON_HELP}"); -} - -/// Help text for `uffs --stats`. -const STATS_HELP: &str = "\ -uffs --stats — Show filesystem statistics - -USAGE: uffs --stats [PATH] [OPTIONS] - -ARGUMENTS: - [PATH] Index file path (optional; omit to query daemon) - -OPTIONS: - --top Show top N largest files (default: 10) - --data-dir Data directory with drive_* subdirs - --mft-file Raw MFT file(s) -"; - -/// Print stats help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_stats_help() { - print!("{STATS_HELP}"); -} - -/// Help text for `uffs --snapshot`. -const SNAPSHOT_HELP: &str = "\ -uffs --snapshot — Capture the live MFT to a baseline file - -Save the drive's current MFT so a later `uffs --diff --drive C` can -report what was deleted since. Reads the live NTFS MFT: Windows + Administrator. - -USAGE: uffs --snapshot --drive --out [OPTIONS] - -OPTIONS: - -d, --drive Drive to capture (required, e.g. C). - -o, --out Output .bin path (required). - --no-compress Store uncompressed (default: zstd-compressed). - --compression-level zstd level 1-22 (default 3). - --raw Headerless raw dump for other MFT tools; implies - --no-compress and is NOT loadable by `uffs --diff`. - -EXAMPLE: - uffs --snapshot --drive C --out C_baseline.bin - uffs --diff C_baseline.bin --drive C '*.txt' # later: what .txt was deleted -"; - -/// Print snapshot help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_snapshot_help() { - print!("{SNAPSHOT_HELP}"); -} - -/// Help text for `uffs --deleted`. -const DELETED_HELP: &str = "\ -uffs --deleted — Forensic tombstone read (recently-deleted files) - -When NTFS deletes a file it clears the in-use flag but leaves the record (name, -parent, timestamps) intact until the MFT slot is reused. This surfaces those -not-in-use records as recently-deleted tombstones and reconstructs each path -from the surviving parent chain. No baseline needed. - -USAGE: uffs --deleted (--mft-file | --drive ) [OPTIONS] - -SOURCE (one required): - --mft-file Offline MFT capture to scan. - -d, --drive Live volume scan (Windows, elevated). With --mft-file, - just labels reconstructed paths. - -OPTIONS: - -n, --limit Max tombstones to print (0 = all). - --json Emit JSON instead of a table. - -LIMITS (best-effort by nature): - - Only deletes whose MFT slot has NOT been recycled are visible. - - The timestamp is the file's last-write time, NOT the deletion time. - - A path is unreliable if a parent directory's slot was itself reused - (such paths are prefixed with `…`). - -EXAMPLE: - uffs --deleted --mft-file C_mft.bin --drive C --limit 50 -"; - -/// Print deleted help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_deleted_help() { - print!("{DELETED_HELP}"); -} - -/// Help text for `uffs --agg`. -const AGGREGATE_HELP: &str = "\ -uffs --agg — Run aggregate analytics on the filesystem index - -USAGE: uffs --agg [OPTIONS] - -ARGUMENTS: - overview, by_type, by_extension, by_drive, - by_size, by_age, count - -OPTIONS: - --format Output format: table (default), csv, json - --data-dir Data directory with drive_* subdirs - --mft-file Raw MFT file(s) - --agg-cursor Continue from previous page - --agg-page-size Max buckets per page -"; - -/// Print aggregate help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_aggregate_help() { - print!("{AGGREGATE_HELP}"); -} - -/// Help text for `uffs --status`. -const STATUS_HELP: &str = "\ -uffs --status — Show combined system status (daemon + broker + MCP) - -USAGE: uffs --status [OPTIONS] - -OPTIONS: - -v, --verbose Expand every section (build, broker mode, live-update, - memory, paths; broker binary + uptime on Windows) - --json Machine-readable superset of all sections -"; - -/// Print status help. -#[expect(clippy::print_stdout, reason = "intentional help output")] -pub(crate) fn print_status_help() { - print!("{STATUS_HELP}"); -} +// +// Static help text + print_*_help functions live in `args_help.rs` (kept +// out of this file to stay under the workspace's 800-LOC-per-file policy; +// this file owns argument *parsing*, not help strings). Re-exported here +// so existing call sites (`args::print_help()`, `args::print_daemon_help()`, +// …) are unchanged. +#[path = "args_help.rs"] +mod help; +pub(crate) use help::{ + print_aggregate_help, print_daemon_help, print_deleted_help, print_help, print_snapshot_help, + print_stats_help, print_status_help, print_version, +}; #[cfg(test)] mod tests { diff --git a/crates/uffs-cli/src/args_help.rs b/crates/uffs-cli/src/args_help.rs new file mode 100644 index 000000000..90ff6b816 --- /dev/null +++ b/crates/uffs-cli/src/args_help.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Static `--help` text and `print_*_help` functions for every `uffs` +//! subcommand. Split out of `args.rs` (which owns argument *parsing*) +//! to keep that file under the workspace's 800-LOC-per-file policy — +//! this content is pure help strings with no parsing logic of its own. + +/// Short help text. +const HELP: &str = "\ +uffs - Ultra Fast File Search + +USAGE: uffs [OPTIONS] + uffs -- [ACTION] [OPTIONS] + +Search-first: any first token that is NOT a `--command` is a search pattern, +so `uffs --update`, `uffs --status`, etc. search for those words. Management is +`--` (below). To search a pattern that begins with `--`, use +`uffs -- `. + +EXAMPLES: + uffs '*.txt' Find all .txt files + uffs '>.*\\.log$' --drive C Regex search on C: + uffs '*' --mft-file C.bin Offline MFT search + uffs --ext rs,toml Find Rust project files + uffs --type picture --min-size 10MB Large images + uffs --update doctor Self-update health check + +COMMANDS: + --search Explicit search (same as the bare default) + --stats [PATH] Show filesystem statistics + --agg Run aggregate analytics + --deleted Forensic tombstone read: recently-deleted files from an MFT + --snapshot Capture the live MFT to a baseline file (Windows, for --diff) + --daemon Manage the UFFS daemon (start/stop/load/status) + --mcp Manage the UFFS MCP server + --update [ACTION] Self-update (snapshot/acquire/apply/doctor/recover) + --status Show combined system status + +COMMON OPTIONS: + -v, --verbose Verbose output + -d, --drive Drive letter (e.g. C or C:) + --drives Multiple drive letters + --mft-file Raw MFT file(s), comma-separated + --data-dir Data directory with drive_* subdirs + --files-only Show only files + --dirs-only Show only directories + --ext Filter by extension(s) + --type Filter by type: code, picture, video, etc. + -n, --limit Max results (0 = unlimited, default: 0) + -f, --format Output: table (default in a terminal), csv (default + when piped/redirected or with --out), json + --sort Sort by column, prefix - for desc + --out Write to file instead of console + --columns Columns to output (default: all) + --newer Modified after date/duration + --older Modified before date/duration + --diff Search the DELETED set vs a baseline MFT capture + (combine with any filter: --diff C_old.bin --drive C + '*.txt' --newer 30d). Needs the drive loaded. + --min-size Minimum file size (e.g. 100KB, 10MB) + --max-size Maximum file size + --profile Show timing breakdown + --benchmark Measure only, skip output + --help Print this help + --version Print version +"; + +/// Print help and exit. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_help() { + print!("{HELP}"); +} + +/// Print version and exit. The short line ties a running binary to the exact +/// source (`[.exe] ()`, `-dirty` for an uncommitted tree); +/// `verbose` adds the multi-line build fingerprint (commit date, rustc, target, +/// profile) for bug reports. Shared with every UFFS binary via `uffs-version`. +#[expect(clippy::print_stdout, reason = "intentional version output")] +pub(crate) fn print_version(verbose: bool) { + if verbose { + println!("{}", uffs_version::version_long!("uffs")); + } else { + println!("{}", uffs_version::version_short!("uffs")); + } +} + +// ── Subcommand help texts ───────────────────────────────────────────── + +/// Help text for `uffs --daemon`. +const DAEMON_HELP: &str = "\ +uffs --daemon — Manage the UFFS background daemon + +USAGE: uffs --daemon [OPTIONS] + +ACTIONS: + start Start the daemon + --data-dir PATH Data directory with drive_* subdirs + --mft-file PATH Raw MFT file(s), comma-separated + --no-cache Skip cached index, re-parse MFT + --elevate Request a UAC prompt (Windows) if not elevated + [env: UFFS_ELEVATE=1] + status Show daemon status (running, drives, PID) + -v, --verbose Long view: build, broker mode, memory, paths, perf stats + --json Machine-readable status + drives + stats + stop Gracefully stop the daemon + kill Hard kill + remove PID/socket files + restart Stop then restart (re-loads all indices) + load Hot-load additional MFT file(s) into running daemon + --mft-file PATH Raw MFT file(s) to load + --data-dir PATH Data directory with drive_* subdirs + --drive LETTER Drive letter(s) to load from data-dir + --no-cache Skip cache when loading + hibernate Demote shards to Cold (free RAM, encrypted cache stays) + [DRIVE...] Drive letter(s); omit to hibernate all loaded drives + --drives A,B Drive letter(s) as comma-separated list + preload Promote shard(s) to Hot and pin the tier + [DRIVE...] Drive letter(s); at least one required + --drives A,B Drive letter(s) as comma-separated list + --pin-minutes N Pin window in minutes (default: 30) + forget Evict drive(s) from registry and delete on-disk caches + [DRIVE...] Drive letter(s); at least one required + --drives A,B Drive letter(s) as comma-separated list + --force Auto-hibernate non-Cold drives first (default: refuse) + status_drives Per-drive tier + telemetry table (Hot/Warm/Parked/Cold) +"; + +/// Print daemon help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_daemon_help() { + print!("{DAEMON_HELP}"); +} + +/// Help text for `uffs --stats`. +const STATS_HELP: &str = "\ +uffs --stats — Show filesystem statistics + +USAGE: uffs --stats [PATH] [OPTIONS] + +ARGUMENTS: + [PATH] Index file path (optional; omit to query daemon) + +OPTIONS: + --top Show top N largest files (default: 10) + --data-dir Data directory with drive_* subdirs + --mft-file Raw MFT file(s) +"; + +/// Print stats help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_stats_help() { + print!("{STATS_HELP}"); +} + +/// Help text for `uffs --snapshot`. +const SNAPSHOT_HELP: &str = "\ +uffs --snapshot — Capture the live MFT to a baseline file + +Save the drive's current MFT so a later `uffs --diff --drive C` can +report what was deleted since. Reads the live NTFS MFT: Windows + Administrator. + +USAGE: uffs --snapshot --drive --out [OPTIONS] + +OPTIONS: + -d, --drive Drive to capture (required, e.g. C). + -o, --out Output .bin path (required). + --no-compress Store uncompressed (default: zstd-compressed). + --compression-level zstd level 1-22 (default 3). + --raw Headerless raw dump for other MFT tools; implies + --no-compress and is NOT loadable by `uffs --diff`. + +EXAMPLE: + uffs --snapshot --drive C --out C_baseline.bin + uffs --diff C_baseline.bin --drive C '*.txt' # later: what .txt was deleted +"; + +/// Print snapshot help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_snapshot_help() { + print!("{SNAPSHOT_HELP}"); +} + +/// Help text for `uffs --deleted`. +const DELETED_HELP: &str = "\ +uffs --deleted — Forensic tombstone read (recently-deleted files) + +When NTFS deletes a file it clears the in-use flag but leaves the record (name, +parent, timestamps) intact until the MFT slot is reused. This surfaces those +not-in-use records as recently-deleted tombstones and reconstructs each path +from the surviving parent chain. No baseline needed. + +USAGE: uffs --deleted (--mft-file | --drive ) [OPTIONS] + +SOURCE (one required): + --mft-file Offline MFT capture to scan. + -d, --drive Live volume scan (Windows, elevated). With --mft-file, + just labels reconstructed paths. + +OPTIONS: + -n, --limit Max tombstones to print (0 = all). + --json Emit JSON instead of a table. + +LIMITS (best-effort by nature): + - Only deletes whose MFT slot has NOT been recycled are visible. + - The timestamp is the file's last-write time, NOT the deletion time. + - A path is unreliable if a parent directory's slot was itself reused + (such paths are prefixed with `…`). + +EXAMPLE: + uffs --deleted --mft-file C_mft.bin --drive C --limit 50 +"; + +/// Print deleted help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_deleted_help() { + print!("{DELETED_HELP}"); +} + +/// Help text for `uffs --agg`. +const AGGREGATE_HELP: &str = "\ +uffs --agg — Run aggregate analytics on the filesystem index + +USAGE: uffs --agg [OPTIONS] + +ARGUMENTS: + overview, by_type, by_extension, by_drive, + by_size, by_age, count + + Each preset has a built-in top-N cap (e.g. by_extension + caps at 50 buckets). --agg-cursor/--agg-page-size page + through that cap; they do not raise it. To see more + than a preset's cap, use the raw terms syntax instead: + uffs --agg 'terms:extension,top=2000' --format json + +OPTIONS: + --format Output format: table (default), csv, json + --data-dir Data directory with drive_* subdirs + --mft-file Raw MFT file(s) + --agg-cursor Continue from previous page + --agg-page-size Max buckets per page (within the preset's cap) +"; + +/// Print aggregate help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_aggregate_help() { + print!("{AGGREGATE_HELP}"); +} + +/// Help text for `uffs --status`. +const STATUS_HELP: &str = "\ +uffs --status — Show combined system status (daemon + broker + MCP) + +USAGE: uffs --status [OPTIONS] + +OPTIONS: + -v, --verbose Expand every section (build, broker mode, live-update, + memory, paths; broker binary + uptime on Windows) + --json Machine-readable superset of all sections +"; + +/// Print status help. +#[expect(clippy::print_stdout, reason = "intentional help output")] +pub(crate) fn print_status_help() { + print!("{STATUS_HELP}"); +} diff --git a/crates/uffs-core/src/compact.rs b/crates/uffs-core/src/compact.rs index 9a2c9ed29..5669674ed 100644 --- a/crates/uffs-core/src/compact.rs +++ b/crates/uffs-core/src/compact.rs @@ -457,6 +457,15 @@ impl DriveCompactIndex { /// The lookup is a linear scan of `ext_names` (~500–2000 short strings), /// which takes < 1 µs. This runs **once per search per drive**, not per /// record. + /// + /// Uses `.enumerate()` rather than `(0_u16..).zip(...)`: `ext_names` can + /// legitimately grow to `u16::MAX` entries (see + /// [`Self::intern_extension`]'s own ceiling check), and + /// `RangeFrom::next()` computes its *next* state eagerly before + /// yielding — so an unbounded `0_u16..` zipped against a full-length + /// table overflows on the lookup's final step whenever the query misses + /// (queried extension absent from this drive), even though every + /// `ext_id` it ever needed to yield fit in `u16`. #[must_use] pub(crate) fn resolve_ext_ids(&self, extensions: &[String]) -> Vec { let mut ids = Vec::with_capacity(extensions.len()); @@ -465,9 +474,9 @@ impl DriveCompactIndex { if normalized.is_empty() { continue; } - for (ext_id, name) in (0_u16..).zip(self.ext_names.iter()) { + for (idx, name) in self.ext_names.iter().enumerate() { if name.as_ref() == normalized { - ids.push(ext_id); + ids.push(u16::try_from(idx).unwrap_or(0)); break; } } diff --git a/crates/uffs-core/src/compact_tests.rs b/crates/uffs-core/src/compact_tests.rs index ebe69256e..1752b0d0d 100644 --- a/crates/uffs-core/src/compact_tests.rs +++ b/crates/uffs-core/src/compact_tests.rs @@ -640,3 +640,75 @@ fn metafile_name_rejects_ordinary_dollar_files() { ); } } + +/// Build a `DriveCompactIndex` whose `ext_names` table is at its +/// legitimate maximum size (`u16::MAX` entries — see +/// `DriveCompactIndex::intern_extension`'s own ceiling check). +fn drive_with_full_ext_names_table() -> DriveCompactIndex { + let names: Vec = Vec::new(); + let records: Vec = Vec::new(); + let fold = uffs_text::case_fold::CaseFold::default_table(); + let trigram = TrigramIndex::build(&records, &names, fold); + let children = ChildrenIndex::build(&records); + let ext_index = ExtensionIndex::build(&records); + + // ids 0..u16::MAX-1 → exactly u16::MAX entries, the largest table + // `intern_extension` will ever produce (it refuses to assign + // id == u16::MAX). Slot 0 keeps the reserved "no extension" string. + let mut ext_names: Vec> = (0..u16::MAX) + .map(|i| Box::from(format!("ext{i}"))) + .collect(); + if let Some(slot) = ext_names.get_mut(0) { + *slot = Box::from(""); + } + + DriveCompactIndex { + letter: uffs_mft::platform::DriveLetter::C, + records: ColumnStorage::from_vec(records), + names: ColumnStorage::from_vec(names), + trigram: Arc::new(trigram), + children: Arc::new(children), + ext_index: Arc::new(ext_index), + fold, + ext_names, + source: IndexSource::MftFile(std::path::PathBuf::from("C:")), + source_epoch: 0, + bloom: None, + path_trie: None, + frs_to_compact: Vec::new(), + delta: None, + } +} + +/// Regression (uffsd crash on `--ext ` with several misses): +/// `resolve_ext_ids` used to zip an unbounded `0_u16..` range against +/// `ext_names.iter()`. `RangeFrom::next()` computes its *next* +/// state before yielding, so once `ext_names` reached its legitimate +/// `u16::MAX`-entry ceiling, looking up an extension absent from the +/// table drove the range one step past `u16::MAX` and panicked with +/// "attempt to add with overflow" — even though every id the table +/// could ever hold fit comfortably in a `u16`. Queries that missed on +/// even one requested extension (common with a multi-extension +/// `--ext` filter) crashed the whole daemon. +#[test] +fn resolve_ext_ids_handles_full_table_miss_without_overflow() { + let drive = drive_with_full_ext_names_table(); + assert_eq!(drive.ext_names.len(), usize::from(u16::MAX)); + + // Absent extension: must scan the entire (full) table and return + // no matches instead of panicking. + let ids = drive.resolve_ext_ids(&["nonexistent".to_owned()]); + assert!(ids.is_empty()); +} + +/// Companion to the miss-case regression above: a real extension at +/// the very last valid slot still resolves to the correct id. +#[test] +fn resolve_ext_ids_finds_last_slot_of_full_table() { + let drive = drive_with_full_ext_names_table(); + let last_id = u16::MAX - 1; + let last_ext = format!("ext{last_id}"); + + let ids = drive.resolve_ext_ids(&[last_ext]); + assert_eq!(ids, vec![last_id]); +} diff --git a/crates/uffs-mft/src/commands/sysinfo/windows.rs b/crates/uffs-mft/src/commands/sysinfo/windows.rs index 4d82d9661..abbcee65e 100644 --- a/crates/uffs-mft/src/commands/sysinfo/windows.rs +++ b/crates/uffs-mft/src/commands/sysinfo/windows.rs @@ -230,6 +230,10 @@ fn collect_volumes() -> Vec { /// Decode a NUL-terminated UTF-16 buffer up to its first NUL. fn u16_to_string(buf: &[u16]) -> String { let len = buf.iter().position(|&code| code == 0).unwrap_or(buf.len()); + // AUDIT-OK(bytes): decodes a Win32 filesystem-name buffer (e.g. "NTFS", + // "FAT32") from GetVolumeInformationW, not an NTFS on-disk filename — it + // never touches the MFT name path the WI-4.1 malformed-name mitigation + // guards, and the value is display-only sysinfo, not indexed/searched. String::from_utf16_lossy(buf.get(..len).unwrap_or(&[])) } @@ -271,6 +275,9 @@ fn reg_string(subkey: &str, value: &str) -> Option { // `cb` counts bytes including the trailing NUL; convert to a u16 length. let units = usize::try_from(cb).unwrap_or(0) / size_of::(); let len = units.saturating_sub(1).min(buf.len()); + // AUDIT-OK(bytes): decodes an HKLM OS-identity registry string (e.g. + // ProductName), not an NTFS on-disk filename — no MFT name path or + // WI-4.1 mitigation applies; display-only sysinfo, not indexed/searched. let text = String::from_utf16_lossy(buf.get(..len)?); let trimmed = text.trim().to_owned(); (!trimmed.is_empty()).then_some(trimmed) diff --git a/crates/uffs-mft/src/platform/metafile_decode.rs b/crates/uffs-mft/src/platform/metafile_decode.rs index 8b9e93440..b7f32d7dd 100644 --- a/crates/uffs-mft/src/platform/metafile_decode.rs +++ b/crates/uffs-mft/src/platform/metafile_decode.rs @@ -142,7 +142,7 @@ pub fn attribute_list_data_frs(list: &[u8], stream_name: &str) -> Vec { let name_off = usize::from(*list.get(pos + 7).unwrap_or(&0)); let name = list .get(pos + name_off..pos + name_off + name_len * 2) - .map(decode_utf16_lossy) + .map(decode_utf16_name) .unwrap_or_default(); if name == stream_name && let Some(base_ref) = rd_u64(list, pos + 0x10) @@ -158,15 +158,21 @@ pub fn attribute_list_data_frs(list: &[u8], stream_name: &str) -> Vec { out } -/// Decode UTF-16LE bytes to a lossy UTF-8 string. -fn decode_utf16_lossy(bytes: &[u8]) -> String { +/// Decode UTF-16LE bytes naming an NTFS file/stream through the crate's +/// shared, malformed-name-safe decoder (Category 4, WI-4.1) — the same +/// [`crate::io::parser::unified::decode_name_u16`] every live-MFT name +/// decode uses, so a captured `$ATTRIBUTE_LIST`/`$UsnJrnl:$J` payload gets +/// identical surrogate handling instead of `String::from_utf16_lossy`'s +/// silent substitution. Discards the replacement count: these offline +/// decoders have no `LOSSY_NAME_COUNT`-style telemetry sink of their own. +fn decode_utf16_name(bytes: &[u8]) -> String { let units: Vec = bytes .as_chunks::<2>() .0 .iter() .map(|pair| u16::from_le_bytes(*pair)) .collect(); - String::from_utf16_lossy(&units) + crate::io::parser::unified::decode_name_u16(&units).0 } /// One decoded USN change-journal record (the surfaced fields). @@ -248,7 +254,7 @@ pub fn parse_usn(payload: &[u8]) -> UsnSummary { if summary.sample.len() < USN_SAMPLE_MAX { let name = record .get(name_off..name_off + name_len) - .map(decode_utf16_lossy) + .map(decode_utf16_name) .unwrap_or_default(); summary.sample.push(UsnEntry { usn, reason, name }); } From 9531faf811380c8184247629d4a05f29ff15dd57 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:41:40 -0700 Subject: [PATCH 04/98] fix(client): make await_ready resilient to heavy-load daemon startup A production report showed `uffs --daemon start` failing with "Daemon did not become ready in time / request timed out" on a 7-drive / 25M-record machine whose load legitimately took 2m27s - 27s past the (fixed) 2-minute client timeout - even though the daemon was healthy and fully loaded moments later per `--daemon status`. await_ready's `timeout` is now an idle budget instead of a hard wall-clock cutoff: every DaemonStatus::Loading response whose drives_loaded advances resets the deadline, so a daemon under heavy system load that keeps visibly making progress is never killed by an arbitrary fixed cutoff - only one that stalls for a full timeout window is. A hard 5x outer ceiling still bounds the total wait. Applied to both the sync client (hit by the CLI) and its async sibling, with new tests proving both the extend-on-progress and still-times-out-on-stall behavior. connect.rs split connect/auto-start/retry into connect_autostart.rs to stay under the workspace's 800-LOC file policy. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-client/src/connect.rs | 267 +++---------------- crates/uffs-client/src/connect_autostart.rs | 234 ++++++++++++++++ crates/uffs-client/src/connect_sync.rs | 42 ++- crates/uffs-client/src/connect_sync_tests.rs | 59 ++++ crates/uffs-client/src/connect_tests.rs | 65 +++++ crates/uffs-client/src/lib.rs | 5 + 6 files changed, 441 insertions(+), 231 deletions(-) create mode 100644 crates/uffs-client/src/connect_autostart.rs diff --git a/crates/uffs-client/src/connect.rs b/crates/uffs-client/src/connect.rs index 71a67be11..639cfae6c 100644 --- a/crates/uffs-client/src/connect.rs +++ b/crates/uffs-client/src/connect.rs @@ -18,12 +18,7 @@ use core::sync::atomic::{AtomicU64, Ordering}; use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; -use crate::connect_logging::{log_connect_attempt, log_connect_error, log_spawn_details}; -use crate::daemon_ctl::{ - deep_health_check_enabled, find_daemon_exe, pid_file_path, socket_path, - verify_daemon_after_connect_strict, -}; -use crate::daemon_spawn::{ElevationPolicy, resolve_elevation_policy, spawn_daemon}; +use crate::daemon_ctl::pid_file_path; use crate::protocol::response::{DaemonStatus, DrivesResponse, SearchResponse, StatusResponse}; use crate::protocol::{RpcRequest, SearchParams}; @@ -115,217 +110,6 @@ impl UffsClient { self.cached_status = Some(status); } - /// Connect to a running daemon, or auto-start one if not running. - /// - /// Tries to connect to the socket. If the socket doesn't exist or - /// connection fails, spawns `uffsd` as a detached process and retries - /// with exponential backoff (up to ~30s). - /// - /// On Windows the daemon auto-discovers live NTFS drives so no extra - /// args are needed. On Mac/Linux, pass `--data-dir` or `--mft-file` - /// via [`Self::connect_with_args`] so the daemon knows where to find data. - /// - /// # Errors - /// - /// Returns `ConnectionFailed` if the daemon cannot be reached after - /// multiple retries, or `DaemonStartFailed` if auto-start fails. - pub async fn connect() -> Result { - Self::connect_with_args(&[]).await - } - - /// Try to connect to an already-running daemon **without** auto-starting. - /// - /// # Errors - /// - /// Returns `ConnectionFailed` if no daemon is listening. - pub async fn connect_raw() -> Result { - Self::platform_connect().await.map_err(|conn_err| { - crate::error::ClientError::ConnectionFailed(format!("No daemon is running: {conn_err}")) - }) - } - - /// Connect to a running daemon, or auto-start one with extra CLI - /// arguments. - /// - /// `spawn_args` are forwarded to `uffsd` **only** when the daemon - /// is not already running and must be auto-started. If a daemon is - /// already listening, the args are ignored (it already has its data - /// loaded). - /// - /// Auto-start uses the default - /// `ElevationPolicy::RequireExistingElevation` — on Windows, if - /// the daemon must be spawned and the current process is not - /// elevated, this returns - /// [`crate::error::ClientError::DaemonNeedsElevation`] instead of - /// triggering a UAC prompt. Callers that want the - /// pre-v0.5.36 behavior (automatic UAC dialog) should use - /// [`Self::connect_with_elevation`] or set `UFFS_ELEVATE=1`. - /// - /// Typical usage (Mac/Linux): - /// ```rust,ignore - /// let args = vec!["--data-dir".into(), "/path/to/uffs_data".into()]; - /// let client = UffsClient::connect_with_args(&args).await?; - /// ``` - /// - /// # Errors - /// - /// Returns `ConnectionFailed`, `DaemonStartFailed`, or - /// `DaemonNeedsElevation` (Windows, non-admin shell only). - pub async fn connect_with_args( - spawn_args: &[std::ffi::OsString], - ) -> Result { - Self::connect_with_args_inner(spawn_args, resolve_elevation_policy(false)).await - } - - /// Connect to a running daemon; if we must auto-start it, explicitly - /// request a UAC prompt on Windows when the current process is not - /// elevated. - /// - /// This is the opt-in variant used by `uffs --daemon start --elevate`. - /// All other entry points default to - /// `ElevationPolicy::RequireExistingElevation`. - /// - /// # Errors - /// - /// Same as [`Self::connect_with_args`], minus - /// `DaemonNeedsElevation` (which is turned into a UAC prompt). - pub async fn connect_with_elevation( - spawn_args: &[std::ffi::OsString], - ) -> Result { - Self::connect_with_args_inner(spawn_args, ElevationPolicy::AllowUacPrompt).await - } - - /// Shared body for [`Self::connect_with_args`] and - /// [`Self::connect_with_elevation`]. - /// - /// Takes an explicit [`ElevationPolicy`] so each public entry - /// point can decide whether a missing elevated context is a - /// hard error (the default) or a prompt request. - async fn connect_with_args_inner( - spawn_args: &[std::ffi::OsString], - policy: ElevationPolicy, - ) -> Result { - let sock = socket_path(); - let pid_path = pid_file_path(); - tracing::debug!( - socket_path = %sock.display(), - socket_exists = sock.exists(), - pid_file = %pid_path.display(), - pid_file_exists = pid_path.exists(), - ?policy, - "connect_with_args: paths" - ); - - // Try connecting directly first — daemon may already be running. - if let Ok(client) = Self::try_connect_existing().await { - return Ok(client); - } - - // Auto-start the daemon (`uffsd`) with the requested policy. - Self::auto_start_daemon(spawn_args, policy)?; - - // Retry with exponential backoff until connected. - Self::retry_connect(&sock, &pid_path).await - } - - /// Attempt to connect to an already-running daemon. - /// - /// # Errors - /// - /// Returns [`ClientError`](crate::error::ClientError) if no daemon is - /// running or the connection handshake fails. - async fn try_connect_existing() -> Result { - match Self::platform_connect().await { - Ok(mut client) => { - tracing::debug!("connect_with_args: already connected to existing daemon"); - // Commit B: strict identity verification — refuse to - // hand back a client bound to a hijacked pipe/socket. - verify_daemon_after_connect_strict()?; - // Commit C: deep health check — prove the daemon is - // actually responsive to RPCs, not just listening. - if deep_health_check_enabled() { - client.deep_health_check().await?; - } - Ok(client) - } - Err(conn_err) => { - tracing::debug!(%conn_err, "connect_with_args: initial connect failed"); - Err(conn_err) - } - } - } - - /// Spawn the daemon process with the given extra args and elevation - /// policy. - /// - /// # Errors - /// - /// Returns [`ClientError`](crate::error::ClientError) if the daemon - /// executable cannot be found, the spawn fails, or the policy - /// forbids elevation in the current context. - fn auto_start_daemon( - spawn_args: &[std::ffi::OsString], - policy: ElevationPolicy, - ) -> Result<(), crate::error::ClientError> { - tracing::info!(?policy, "Daemon not running, auto-starting via `uffsd`..."); - - let daemon_exe = find_daemon_exe(); - log_spawn_details(&daemon_exe, spawn_args); - - // On Windows, reading the MFT requires Administrator privileges. - // The default policy is `RequireExistingElevation` — if we are - // not already elevated, we return `DaemonNeedsElevation` and let - // the CLI render an actionable message. Callers opt in to a - // UAC prompt via `connect_with_elevation` or `UFFS_ELEVATE=1`. - spawn_daemon(&daemon_exe, spawn_args, policy)?; - tracing::debug!("auto_start_daemon: spawn returned OK"); - Ok(()) - } - - /// Retry connecting to the daemon with exponential backoff. - /// - /// # Errors - /// - /// Returns [`ClientError`](crate::error::ClientError) if all connection - /// attempts are exhausted. - async fn retry_connect( - sock: &std::path::Path, - pid_path: &std::path::Path, - ) -> Result { - let mut delay_ms = 50_u64; - let max_attempts = 20_usize; - for attempt in 1_usize..=max_attempts { - tokio::time::sleep(core::time::Duration::from_millis(delay_ms)).await; - log_connect_attempt(attempt, max_attempts, delay_ms, sock, pid_path); - - match Self::platform_connect().await { - Ok(mut client) => { - tracing::info!(attempt, "Connected to daemon"); - // Commit B: strict identity verification — refuse to - // hand back a client bound to a hijacked endpoint, - // even when we just spawned the daemon ourselves. - verify_daemon_after_connect_strict()?; - // Commit C: deep health check — prove the daemon is - // actually responsive to RPCs, not just listening. - if deep_health_check_enabled() { - client.deep_health_check().await?; - } - return Ok(client); - } - Err(conn_err) => { - log_connect_error(attempt, max_attempts, &conn_err); - } - } - - delay_ms = (delay_ms * 2).min(2000); - } - - tracing::warn!(max_attempts, "all connect attempts exhausted"); - Err(crate::error::ClientError::ConnectionFailed( - "Could not connect to daemon after auto-start".to_owned(), - )) - } - /// Receive the next daemon notification (non-blocking). /// /// Returns `None` if no notifications are pending. Use this in an @@ -524,14 +308,24 @@ impl UffsClient { /// /// Polls `status()` with exponential backoff (250ms → 2s cap) until the /// daemon reports [`crate::protocol::response::DaemonStatus::Ready`]. - /// Times out after `timeout` and returns an error. + /// + /// `timeout` is an **idle** budget, not a hard wall-clock cutoff: every + /// time the daemon reports forward progress (`DaemonStatus::Loading`'s + /// `drives_loaded` advancing), the deadline resets to `now + timeout`. + /// A big multi-drive load under heavy system load that keeps visibly + /// making progress is never killed by an arbitrary fixed cutoff — only + /// a daemon that stops progressing for a full `timeout` window is. A + /// hard outer ceiling (`5 * timeout`) still bounds the total wait so a + /// daemon stuck oscillating on the same drive count can't hang the + /// caller forever. /// /// If multiple consecutive I/O errors occur (e.g. broken pipe from a /// stale socket), the client automatically reconnects to the daemon. /// /// # Errors /// - /// Returns `ClientError` on connection failure or timeout. + /// Returns `ClientError` on connection failure, or once the idle + /// budget (or hard ceiling) elapses without reaching `Ready`. pub async fn await_ready( &mut self, timeout: core::time::Duration, @@ -545,10 +339,16 @@ impl UffsClient { return Ok(()); } - let deadline = tokio::time::Instant::now() + timeout; + let start = tokio::time::Instant::now(); + // Even under continuous progress, don't wait forever — 5x the + // caller's own idle budget scales with however patient it already + // asked to be, without introducing a load-independent magic number. + let hard_ceiling = start + timeout.saturating_mul(5); + let mut deadline = start + timeout; let mut delay_ms = 250_u64; let mut poll_count = 0_u32; let mut consecutive_io_errors = 0_u32; + let mut last_drives_loaded: Option = None; loop { poll_count += 1; @@ -560,6 +360,13 @@ impl UffsClient { self.cached_status = Some(DaemonStatus::Ready); return Ok(()); } + PollOutcome::Loading { drives_loaded } => { + consecutive_io_errors = 0; + if last_drives_loaded != Some(drives_loaded) { + last_drives_loaded = Some(drives_loaded); + deadline = (tokio::time::Instant::now() + timeout).min(hard_ceiling); + } + } PollOutcome::NotReady => { consecutive_io_errors = 0; } @@ -749,7 +556,15 @@ impl UffsClient { enum PollOutcome { /// Daemon reports `Ready`. Ready, - /// Daemon responded but is still loading. + /// Daemon reports `Loading`, with its current drive-count progress — + /// carried so `await_ready` can detect forward progress and extend + /// its idle deadline instead of applying a fixed wall-clock cutoff. + Loading { + /// Drives loaded so far, per `DaemonStatus::Loading::drives_loaded`. + drives_loaded: usize, + }, + /// Daemon responded but is neither `Ready` nor `Loading` (e.g. + /// `Refreshing`). NotReady, /// I/O or connection-closed error (may need reconnect). IoError, @@ -763,10 +578,12 @@ impl UffsClient { match self.status().await { Ok(resp) => { tracing::info!(poll_count, status = ?resp.status, "await_ready: got status"); - if resp.status == DaemonStatus::Ready { - PollOutcome::Ready - } else { - PollOutcome::NotReady + match resp.status { + DaemonStatus::Ready => PollOutcome::Ready, + DaemonStatus::Loading { drives_loaded, .. } => { + PollOutcome::Loading { drives_loaded } + } + DaemonStatus::Refreshing { .. } => PollOutcome::NotReady, } } Err( diff --git a/crates/uffs-client/src/connect_autostart.rs b/crates/uffs-client/src/connect_autostart.rs new file mode 100644 index 000000000..456f692ae --- /dev/null +++ b/crates/uffs-client/src/connect_autostart.rs @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Connect / auto-start entry points for [`crate::connect::UffsClient`] +//! (async variant). +//! +//! Extracted from `connect.rs` to keep that file under the workspace +//! 800-LOC policy ceiling. All items live on `UffsClient` via a split +//! `impl` block — no public surface moves. Mirrors the sync-path split +//! in `connect_sync_autostart.rs`, though that sibling holds only the +//! spawn helper (the sync client's retry loop stays inline); here the +//! whole connect → auto-start → retry chain moves together since it is +//! one tightly-coupled call sequence. + +use crate::connect::UffsClient; +use crate::connect_logging::{log_connect_attempt, log_connect_error, log_spawn_details}; +use crate::daemon_ctl::{ + deep_health_check_enabled, find_daemon_exe, pid_file_path, socket_path, + verify_daemon_after_connect_strict, +}; +use crate::daemon_spawn::{ElevationPolicy, resolve_elevation_policy, spawn_daemon}; + +impl UffsClient { + /// Connect to a running daemon, or auto-start one if not running. + /// + /// Tries to connect to the socket. If the socket doesn't exist or + /// connection fails, spawns `uffsd` as a detached process and retries + /// with exponential backoff (up to ~30s). + /// + /// On Windows the daemon auto-discovers live NTFS drives so no extra + /// args are needed. On Mac/Linux, pass `--data-dir` or `--mft-file` + /// via [`Self::connect_with_args`] so the daemon knows where to find data. + /// + /// # Errors + /// + /// Returns `ConnectionFailed` if the daemon cannot be reached after + /// multiple retries, or `DaemonStartFailed` if auto-start fails. + pub async fn connect() -> Result { + Self::connect_with_args(&[]).await + } + + /// Try to connect to an already-running daemon **without** auto-starting. + /// + /// # Errors + /// + /// Returns `ConnectionFailed` if no daemon is listening. + pub async fn connect_raw() -> Result { + Self::platform_connect().await.map_err(|conn_err| { + crate::error::ClientError::ConnectionFailed(format!("No daemon is running: {conn_err}")) + }) + } + + /// Connect to a running daemon, or auto-start one with extra CLI + /// arguments. + /// + /// `spawn_args` are forwarded to `uffsd` **only** when the daemon + /// is not already running and must be auto-started. If a daemon is + /// already listening, the args are ignored (it already has its data + /// loaded). + /// + /// Auto-start uses the default + /// `ElevationPolicy::RequireExistingElevation` — on Windows, if + /// the daemon must be spawned and the current process is not + /// elevated, this returns + /// [`crate::error::ClientError::DaemonNeedsElevation`] instead of + /// triggering a UAC prompt. Callers that want the + /// pre-v0.5.36 behavior (automatic UAC dialog) should use + /// [`Self::connect_with_elevation`] or set `UFFS_ELEVATE=1`. + /// + /// Typical usage (Mac/Linux): + /// ```rust,ignore + /// let args = vec!["--data-dir".into(), "/path/to/uffs_data".into()]; + /// let client = UffsClient::connect_with_args(&args).await?; + /// ``` + /// + /// # Errors + /// + /// Returns `ConnectionFailed`, `DaemonStartFailed`, or + /// `DaemonNeedsElevation` (Windows, non-admin shell only). + pub async fn connect_with_args( + spawn_args: &[std::ffi::OsString], + ) -> Result { + Self::connect_with_args_inner(spawn_args, resolve_elevation_policy(false)).await + } + + /// Connect to a running daemon; if we must auto-start it, explicitly + /// request a UAC prompt on Windows when the current process is not + /// elevated. + /// + /// This is the opt-in variant used by `uffs --daemon start --elevate`. + /// All other entry points default to + /// `ElevationPolicy::RequireExistingElevation`. + /// + /// # Errors + /// + /// Same as [`Self::connect_with_args`], minus + /// `DaemonNeedsElevation` (which is turned into a UAC prompt). + pub async fn connect_with_elevation( + spawn_args: &[std::ffi::OsString], + ) -> Result { + Self::connect_with_args_inner(spawn_args, ElevationPolicy::AllowUacPrompt).await + } + + /// Shared body for [`Self::connect_with_args`] and + /// [`Self::connect_with_elevation`]. + /// + /// Takes an explicit [`ElevationPolicy`] so each public entry + /// point can decide whether a missing elevated context is a + /// hard error (the default) or a prompt request. + async fn connect_with_args_inner( + spawn_args: &[std::ffi::OsString], + policy: ElevationPolicy, + ) -> Result { + let sock = socket_path(); + let pid_path = pid_file_path(); + tracing::debug!( + socket_path = %sock.display(), + socket_exists = sock.exists(), + pid_file = %pid_path.display(), + pid_file_exists = pid_path.exists(), + ?policy, + "connect_with_args: paths" + ); + + // Try connecting directly first — daemon may already be running. + if let Ok(client) = Self::try_connect_existing().await { + return Ok(client); + } + + // Auto-start the daemon (`uffsd`) with the requested policy. + Self::auto_start_daemon(spawn_args, policy)?; + + // Retry with exponential backoff until connected. + Self::retry_connect(&sock, &pid_path).await + } + + /// Attempt to connect to an already-running daemon. + /// + /// # Errors + /// + /// Returns [`ClientError`](crate::error::ClientError) if no daemon is + /// running or the connection handshake fails. + async fn try_connect_existing() -> Result { + match Self::platform_connect().await { + Ok(mut client) => { + tracing::debug!("connect_with_args: already connected to existing daemon"); + // Commit B: strict identity verification — refuse to + // hand back a client bound to a hijacked pipe/socket. + verify_daemon_after_connect_strict()?; + // Commit C: deep health check — prove the daemon is + // actually responsive to RPCs, not just listening. + if deep_health_check_enabled() { + client.deep_health_check().await?; + } + Ok(client) + } + Err(conn_err) => { + tracing::debug!(%conn_err, "connect_with_args: initial connect failed"); + Err(conn_err) + } + } + } + + /// Spawn the daemon process with the given extra args and elevation + /// policy. + /// + /// # Errors + /// + /// Returns [`ClientError`](crate::error::ClientError) if the daemon + /// executable cannot be found, the spawn fails, or the policy + /// forbids elevation in the current context. + fn auto_start_daemon( + spawn_args: &[std::ffi::OsString], + policy: ElevationPolicy, + ) -> Result<(), crate::error::ClientError> { + tracing::info!(?policy, "Daemon not running, auto-starting via `uffsd`..."); + + let daemon_exe = find_daemon_exe(); + log_spawn_details(&daemon_exe, spawn_args); + + // On Windows, reading the MFT requires Administrator privileges. + // The default policy is `RequireExistingElevation` — if we are + // not already elevated, we return `DaemonNeedsElevation` and let + // the CLI render an actionable message. Callers opt in to a + // UAC prompt via `connect_with_elevation` or `UFFS_ELEVATE=1`. + spawn_daemon(&daemon_exe, spawn_args, policy)?; + tracing::debug!("auto_start_daemon: spawn returned OK"); + Ok(()) + } + + /// Retry connecting to the daemon with exponential backoff. + /// + /// # Errors + /// + /// Returns [`ClientError`](crate::error::ClientError) if all connection + /// attempts are exhausted. + async fn retry_connect( + sock: &std::path::Path, + pid_path: &std::path::Path, + ) -> Result { + let mut delay_ms = 50_u64; + let max_attempts = 20_usize; + for attempt in 1_usize..=max_attempts { + tokio::time::sleep(core::time::Duration::from_millis(delay_ms)).await; + log_connect_attempt(attempt, max_attempts, delay_ms, sock, pid_path); + + match Self::platform_connect().await { + Ok(mut client) => { + tracing::info!(attempt, "Connected to daemon"); + // Commit B: strict identity verification — refuse to + // hand back a client bound to a hijacked endpoint, + // even when we just spawned the daemon ourselves. + verify_daemon_after_connect_strict()?; + // Commit C: deep health check — prove the daemon is + // actually responsive to RPCs, not just listening. + if deep_health_check_enabled() { + client.deep_health_check().await?; + } + return Ok(client); + } + Err(conn_err) => { + log_connect_error(attempt, max_attempts, &conn_err); + } + } + + delay_ms = (delay_ms * 2).min(2000); + } + + tracing::warn!(max_attempts, "all connect attempts exhausted"); + Err(crate::error::ClientError::ConnectionFailed( + "Could not connect to daemon after auto-start".to_owned(), + )) + } +} diff --git a/crates/uffs-client/src/connect_sync.rs b/crates/uffs-client/src/connect_sync.rs index b88d1c93a..6d1ce3cb2 100644 --- a/crates/uffs-client/src/connect_sync.rs +++ b/crates/uffs-client/src/connect_sync.rs @@ -563,17 +563,35 @@ impl UffsClientSync { /// round-trip on the hot CLI path. Falls back to the exponential /// poll loop when the cache is `None`, `Loading`, or `Refreshing`. /// + /// `timeout` is an **idle** budget, not a hard wall-clock cutoff: every + /// time the daemon reports forward progress (`DaemonStatus::Loading`'s + /// `drives_loaded` advancing), the deadline resets to `now + timeout`. + /// A big multi-drive load under heavy system load (contended disk I/O, + /// CPU pressure from other processes) that keeps visibly making + /// progress is never killed by an arbitrary fixed cutoff — only a + /// daemon that stops progressing for a full `timeout` window is. A + /// hard outer ceiling (`5 * timeout`) still bounds the total wait so a + /// daemon stuck oscillating on the same drive count can't hang the + /// caller forever. + /// /// # Errors /// - /// Returns `ClientError::Timeout` if not ready within `timeout`. + /// Returns `ClientError::Timeout` if not ready within the idle budget + /// (or the hard ceiling, whichever is hit first). pub fn await_ready(&mut self, timeout: core::time::Duration) -> Result<(), ClientError> { // Run 10 Part B short-circuit: skip the RPC on cached `Ready`. if matches!(self.cached_status, Some(DaemonStatus::Ready)) { return Ok(()); } - let deadline = std::time::Instant::now() + timeout; + let start = std::time::Instant::now(); + // Even under continuous progress, don't wait forever — 5x the + // caller's own idle budget scales with however patient it already + // asked to be, without introducing a load-independent magic number. + let hard_ceiling = start + timeout.saturating_mul(5); + let mut deadline = start + timeout; let mut poll_interval = core::time::Duration::from_millis(100); + let mut last_drives_loaded: Option = None; while std::time::Instant::now() < deadline { match self.status() { @@ -582,15 +600,27 @@ impl UffsClientSync { self.cached_status = Some(DaemonStatus::Ready); return Ok(()); } - // Any non-Ready outcome (Loading status, I/O error, - // connection closed, RPC timeout, transient protocol - // error) keeps polling. Mirrors the async sibling at + Ok(resp) => { + if let DaemonStatus::Loading { drives_loaded, .. } = resp.status + && last_drives_loaded != Some(drives_loaded) + { + last_drives_loaded = Some(drives_loaded); + deadline = (std::time::Instant::now() + timeout).min(hard_ceiling); + } + // `Refreshing`, or `Loading` with an unchanged + // `drives_loaded`, falls through to the sleep below + // without touching the deadline — no progress, no + // extension. + } + // Any error outcome (I/O error, connection closed, RPC + // timeout, transient protocol error) keeps polling without + // extending the deadline. Mirrors the async sibling at // `connect.rs::await_ready` (`PollOutcome::OtherError`). // Pinned by the // `await_ready_retries_on_protocol_error_until_deadline` // regression test — see its docstring for the // 2026-05-07 Phase 7 soak background. - _ => {} + Err(_) => {} } std::thread::sleep(poll_interval); poll_interval = (poll_interval * 2).min(core::time::Duration::from_secs(2)); diff --git a/crates/uffs-client/src/connect_sync_tests.rs b/crates/uffs-client/src/connect_sync_tests.rs index 863733563..0948aafa4 100644 --- a/crates/uffs-client/src/connect_sync_tests.rs +++ b/crates/uffs-client/src/connect_sync_tests.rs @@ -404,3 +404,62 @@ fn await_ready_polls_when_cached_status_is_loading() { not Ready; saw: {sent:?}", ); } + +/// Regression — heavy-system-load daemon-start resilience: a real report +/// showed `uffs --daemon start` failing with "Daemon did not become ready +/// in time / request timed out" on a 7-drive / 25M-record production +/// machine whose load legitimately took 2m27s — 27s past the (then-fixed) +/// 2-minute client timeout — even though `--daemon status` moments later +/// showed the daemon healthy and fully loaded. +/// +/// `await_ready`'s `timeout` is now an *idle* budget: every `Loading` +/// response whose `drives_loaded` advances resets the deadline. This test +/// feeds two distinct progress ticks (1 drive, then 2 drives) ahead of +/// `Ready`, with a 250ms idle budget. Real time elapses sleeping between +/// polls (100ms → 200ms), so total wall-clock exceeds 250ms — proving the +/// deadline was pushed out by progress rather than left fixed at start+250ms +/// (which would have expired before the third poll ever ran). +#[test] +fn await_ready_extends_deadline_on_drive_load_progress() { + let canned = concat!( + r#"{"jsonrpc":"2.0","id":1,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"result":{"status":{"state":"loading","drives_loaded":2,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":3,"result":{"status":{"state":"ready"},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + ) + .as_bytes(); + let (mut client, _writer) = client_with_canned_response(canned); + + let outcome = client.await_ready(core::time::Duration::from_millis(250)); + assert!( + outcome.is_ok(), + "progress ticks (1 drive, then 2) must each extend the idle deadline \ + so the loop reaches the eventual Ready response instead of timing \ + out at the fixed start+250ms mark; got {outcome:?}", + ); +} + +/// Companion to the progress-extends-deadline test above: a daemon that +/// reports the *same* `drives_loaded` on every poll (a genuine stall, e.g. +/// wedged on one drive) must NOT get free deadline extensions forever — +/// the idle budget still applies when there is no real progress. +#[test] +fn await_ready_times_out_when_drive_load_progress_stalls() { + let canned = concat!( + r#"{"jsonrpc":"2.0","id":1,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + ) + .as_bytes(); + let (mut client, _writer) = client_with_canned_response(canned); + + let outcome = client.await_ready(core::time::Duration::from_millis(120)); + assert!( + matches!(outcome, Err(ClientError::Timeout)), + "an unchanged drives_loaded across polls is a stall, not progress — \ + it must not extend the idle deadline indefinitely; got {outcome:?}", + ); +} diff --git a/crates/uffs-client/src/connect_tests.rs b/crates/uffs-client/src/connect_tests.rs index 5b23c7dcc..e5a173b36 100644 --- a/crates/uffs-client/src/connect_tests.rs +++ b/crates/uffs-client/src/connect_tests.rs @@ -391,3 +391,68 @@ async fn await_ready_polls_when_cached_status_is_loading() { not Ready; saw: {sent:?}", ); } + +/// Regression — heavy-system-load daemon-start resilience (async sibling of +/// the same-named test in `connect_sync_tests.rs`): `await_ready`'s +/// `timeout` is an idle budget, not a fixed wall-clock cutoff. Every +/// `Loading` response whose `drives_loaded` advances resets the deadline, so +/// a big multi-drive load that keeps visibly progressing under heavy system +/// load is never killed by an arbitrary cutoff. Feeds two progress ticks (1 +/// drive, then 2) ahead of `Ready` with a 250ms idle budget; the real sleeps +/// between polls push total wall-clock past 250ms, proving the deadline +/// followed the progress instead of staying fixed at start+250ms. +#[tokio::test] +async fn await_ready_extends_deadline_on_drive_load_progress() { + let canned = concat!( + r#"{"jsonrpc":"2.0","id":1,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"result":{"status":{"state":"loading","drives_loaded":2,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":3,"result":{"status":{"state":"ready"},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + ) + .as_bytes(); + let (mut client, _writer) = client_with_canned_response(canned); + + let outcome = client + .await_ready(core::time::Duration::from_millis(250)) + .await; + assert!( + outcome.is_ok(), + "progress ticks (1 drive, then 2) must each extend the idle deadline \ + so the loop reaches the eventual Ready response instead of timing \ + out at the fixed start+250ms mark; got {outcome:?}", + ); +} + +/// Companion to the progress-extends-deadline test above: a daemon that +/// reports the *same* `drives_loaded` on every poll (a genuine stall) must +/// NOT get free deadline extensions forever. +#[tokio::test] +async fn await_ready_times_out_when_drive_load_progress_stalls() { + let canned = concat!( + r#"{"jsonrpc":"2.0","id":1,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"result":{"status":{"state":"loading","drives_loaded":1,"drives_total":3},"uptime_secs":1,"connections":1,"pid":1}}"#, + "\n", + ) + .as_bytes(); + let (mut client, _writer) = client_with_canned_response(canned); + + let outcome = client + .await_ready(core::time::Duration::from_millis(120)) + .await; + match outcome { + Err(ClientError::ConnectionFailed(msg)) => { + assert!( + msg.contains("Timed out"), + "expected the async timeout message, got {msg}", + ); + } + other => panic!( + "an unchanged drives_loaded across polls is a stall, not \ + progress — it must not extend the idle deadline indefinitely; \ + got {other:?}" + ), + } +} diff --git a/crates/uffs-client/src/lib.rs b/crates/uffs-client/src/lib.rs index 850708ff6..b17c6ab2c 100644 --- a/crates/uffs-client/src/lib.rs +++ b/crates/uffs-client/src/lib.rs @@ -102,6 +102,11 @@ use uffs_security as _; /// `ws2_32.dll`) from its binary. #[cfg(feature = "async")] pub mod connect; +/// Connect / auto-start entry points for [`connect::UffsClient`] — split +/// off `connect.rs` to keep that file under the 800-LOC policy ceiling +/// after the drive-load-progress `await_ready` resilience work grew it. +#[cfg(feature = "async")] +mod connect_autostart; /// Background keepalive task + `KeepaliveGuard` for long-lived clients. /// /// `start_keepalive` is re-attached to `UffsClient` via a split `impl`; From 099b595780aaea8cae2214e6378b3a0216990910 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:35:43 -0700 Subject: [PATCH 05/98] feat(content): scaffold uffs-content + uffs-content-protocol crates Bare-bones Layer 0 (uffs-content-protocol: shared wire types) and Layer 4 (uffs-content: unprivileged coordinator binary) crates for the new VSS-snapshot-scoped content-export tool. No job intake, VSS, MFT, or streaming logic yet - placement and naming only, built against Docenta's uffs-ingest-protocol-v2-vss.md contract. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 15 ++++++ Cargo.toml | 42 +++++++++------ crates/uffs-content-protocol/Cargo.toml | 54 ++++++++++++++++++++ crates/uffs-content-protocol/src/error.rs | 22 ++++++++ crates/uffs-content-protocol/src/lib.rs | 37 ++++++++++++++ crates/uffs-content-protocol/src/state.rs | 24 +++++++++ crates/uffs-content/Cargo.toml | 62 +++++++++++++++++++++++ crates/uffs-content/src/lib.rs | 45 ++++++++++++++++ crates/uffs-content/src/main.rs | 43 ++++++++++++++++ 9 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 crates/uffs-content-protocol/Cargo.toml create mode 100644 crates/uffs-content-protocol/src/error.rs create mode 100644 crates/uffs-content-protocol/src/lib.rs create mode 100644 crates/uffs-content-protocol/src/state.rs create mode 100644 crates/uffs-content/Cargo.toml create mode 100644 crates/uffs-content/src/lib.rs create mode 100644 crates/uffs-content/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index fa0e6836d..09b94fdd0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4477,6 +4477,21 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "uffs-content" +version = "0.6.27" +dependencies = [ + "uffs-content-protocol", + "uffs-version", +] + +[[package]] +name = "uffs-content-protocol" +version = "0.6.27" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "uffs-core" version = "0.6.27" diff --git a/Cargo.toml b/Cargo.toml index 65ecbf5cf..83572d0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,22 +24,24 @@ cargo-features = [ resolver = "3" members = [ # ── Foundation ── - "crates/uffs-polars", # 🚀 Polars facade (compilation isolation) - "crates/uffs-security", # 🔒 Crypto, key storage, secure FS ops - "crates/uffs-text", # 📝 Unicode text processing, i18n foundation - "crates/uffs-time", # ⏱️ NTFS FILETIME arithmetic (pure, zero deps) - "crates/uffs-version", # 🏷️ Shared --version strings + build-metadata stamp (leaf) - "crates/uffs-statusfmt", # 🎨 Shared operator-status styling (color, glyphs, fields) (leaf) - "crates/uffs-broker-protocol", # 📟 Cross-platform broker wire-protocol types (F5) - "crates/uffs-winsvc", # 🪟 Native Windows service control + broker-pipe probe (leaf) - "crates/uffs-mft", # 📦 MFT reading → Polars DataFrame - "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) - "crates/uffs-core", # 🎯 Query engine + compact search engine + "crates/uffs-polars", # 🚀 Polars facade (compilation isolation) + "crates/uffs-security", # 🔒 Crypto, key storage, secure FS ops + "crates/uffs-text", # 📝 Unicode text processing, i18n foundation + "crates/uffs-time", # ⏱️ NTFS FILETIME arithmetic (pure, zero deps) + "crates/uffs-version", # 🏷️ Shared --version strings + build-metadata stamp (leaf) + "crates/uffs-statusfmt", # 🎨 Shared operator-status styling (color, glyphs, fields) (leaf) + "crates/uffs-broker-protocol", # 📟 Cross-platform broker wire-protocol types (F5) + "crates/uffs-content-protocol", # 📨 Cross-platform Content Service wire-protocol types + "crates/uffs-winsvc", # 🪟 Native Windows service control + broker-pipe probe (leaf) + "crates/uffs-mft", # 📦 MFT reading → Polars DataFrame + "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) + "crates/uffs-core", # 🎯 Query engine + compact search engine # ── Daemon Architecture ── - "crates/uffs-daemon", # 🛡️ Background service process - "crates/uffs-client", # 📡 Thin client library - "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents - "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) + "crates/uffs-daemon", # 🛡️ Background service process + "crates/uffs-client", # 📡 Thin client library + "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents + "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) + "crates/uffs-content", # 📦 Content Service — VSS-snapshot-scoped file content export (optional) # ── Surfaces ── "crates/uffs-cli", # 🖥️ Command-line interface "crates/uffs-update", # ⬆️ Self-update acquire helper (HTTP/TLS isolated from the CLI) @@ -149,6 +151,16 @@ uffs-client = { path = "crates/uffs-client", version = "0.6.27" } # F5 (issue #205) so neither side duplicates `BROKER_PIPE_NAME` / # wire-format byte literals. uffs-broker-protocol = { path = "crates/uffs-broker-protocol", version = "0.6.27" } +# `uffs-content-protocol` carries the wire-protocol types shared between +# `uffs-content` (the unprivileged content-coordinator producer, +# Windows-only binary) and any downstream consumer (e.g. Docenta). +# Pure-logic Layer-0 lib — cross-platform tests run on every CI lane, +# matching the `uffs-broker-protocol` pattern above. Design references +# (docs/dev/architecture/, local-only, not tracked): the original +# `content-stream-tool-design.md` sketch, its +# `uffs-content-stream-enterprise-design-review.md` replacement-design +# review, and Docenta's `uffs-ingest-protocol-v2-vss.md`. +uffs-content-protocol = { path = "crates/uffs-content-protocol", version = "0.6.27" } # `uffs-winsvc` — native Windows service control (SCM query/start/stop) + # the non-connecting broker-pipe readiness probe. Layer-0 leaf: its only # dependency is the `windows` crate (windows-target), with non-Windows diff --git a/crates/uffs-content-protocol/Cargo.toml b/crates/uffs-content-protocol/Cargo.toml new file mode 100644 index 000000000..c218951d8 --- /dev/null +++ b/crates/uffs-content-protocol/Cargo.toml @@ -0,0 +1,54 @@ +# ============================================================================ +# uffs-content-protocol: Content Service wire-protocol types +# ============================================================================ +# Layer 0 Foundation crate. Tiny dedicated lib, mirrors the shape of +# `uffs-broker-protocol`. Pure-logic byte-shuffling and enum/struct +# definitions — no Windows FFI, no I/O, no VSS/MFT access. +# +# Shared between `uffs-content` (the privileged VSS-snapshot-scoped Content +# Service producer, Windows-only binary) and any downstream consumer (e.g. +# Docenta) that speaks the framed content protocol. Keeping the contract in +# its own crate means the manifest layout, frame envelope, job/candidate +# state machine, and error taxonomy have one source of truth instead of +# being duplicated by hand on both sides of the wire. +# +# Cross-platform by design: tests run on every CI lane (Linux + macOS + +# Windows). The Windows-only VSS/MFT machinery stays in `uffs-content`'s +# `[[bin]]` where it belongs. +# +# Design references (docs/dev/architecture/, local-only, not tracked): +# the original `content-stream-tool-design.md` sketch, superseded by its +# `uffs-content-stream-enterprise-design-review.md` replacement-design +# review, and Docenta's `uffs-ingest-protocol-v2-vss.md` spec — the +# authoritative sources this crate's types are scaffolded from. +# ============================================================================ + +[package] +name = "uffs-content-protocol" +description = "UFFS Content Service wire-protocol types (cross-platform, shared by uffs-content + downstream consumers)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +# Intentionally NOT published (yet). This crate defines the manifest + +# framed content wire protocol between `uffs-content` (the privileged +# producer) and a downstream consumer such as Docenta. Reserve the name +# on crates.io to prevent squatting, but never carry content until the +# protocol is stable enough to be a public contract. See +# `crates/uffs-broker-protocol/Cargo.toml` for the precedent. +publish.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +# Structured error type for `ProtocolError`. +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/uffs-content-protocol/src/error.rs b/crates/uffs-content-protocol/src/error.rs new file mode 100644 index 000000000..c8e4d41bb --- /dev/null +++ b/crates/uffs-content-protocol/src/error.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Protocol-level error type (scaffold). + +use thiserror::Error; + +/// Errors that can occur while encoding or decoding a content-protocol +/// manifest or frame. +/// +/// This is a placeholder variant set so the crate is constructible before +/// the real error taxonomy lands. The full stable, machine-readable code +/// list (`SNAPSHOT_CREATE_FAILED`, `MANIFEST_CORRUPT`, `DIGEST_MISMATCH`, +/// ...) is design-doc §16 and will replace this enum once the wire +/// encoding is implemented. +#[derive(Debug, Error)] +pub enum ProtocolError { + /// Placeholder variant covering every not-yet-implemented protocol + /// operation. The string names which operation was attempted. + #[error("content protocol not yet implemented: {0}")] + NotYetImplemented(&'static str), +} diff --git a/crates/uffs-content-protocol/src/lib.rs b/crates/uffs-content-protocol/src/lib.rs new file mode 100644 index 000000000..a4222c4dd --- /dev/null +++ b/crates/uffs-content-protocol/src/lib.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Wire protocol between the UFFS Content Service (producer) and a +//! downstream content consumer such as Docenta. +//! +//! This is a dedicated cross-platform Layer-0 library — pure enum/struct +//! definitions and (eventually) byte-shuffling, no I/O, no Windows FFI, no +//! VSS/MFT access. Both sides of the wire (the `uffs-content` coordinator +//! process and any unprivileged consumer) import the types defined here so +//! the wire format has a single source of truth, matching the pattern +//! `uffs-broker-protocol` already established for the Access Broker. +//! +//! # Design references +//! +//! (all under `docs/dev/architecture/` — local-only, not tracked in git) +//! +//! - `content-stream-tool-design.md` — the original, VSS-less design sketch. +//! - `uffs-content-stream-enterprise-design-review.md` — the replacement-design +//! review superseding that sketch: content-delivery protocol independent of +//! read mode, logical file-ID reads as the default, raw/snapshot extent reads +//! demoted to an optional internal acceleration behind a narrow privileged +//! helper (never the public coordinator). +//! - Docenta's `uffs-ingest-protocol-v2-vss.md` — the settled v2 contract this +//! crate's types are scaffolded from: one VSS snapshot per job, an immutable +//! candidate manifest, a framed chunked content stream, and a durable failure +//! bucket. +//! +//! # Status +//! +//! Scaffold only. [`state::CandidateOutcome`] and [`error::ProtocolError`] +//! are placeholders. The manifest header/record/trailer layout (design-doc +//! §11), the frame envelope + frame types (§12), the failure record (§8), +//! and the full error taxonomy (§16) are not yet implemented. + +pub mod error; +pub mod state; diff --git a/crates/uffs-content-protocol/src/state.rs b/crates/uffs-content-protocol/src/state.rs new file mode 100644 index 000000000..750946121 --- /dev/null +++ b/crates/uffs-content-protocol/src/state.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Job and candidate terminal states (scaffold). + +/// One candidate's terminal outcome (design-doc §2.2, §9.2). +/// +/// Every candidate in a job's manifest MUST eventually reach exactly one +/// of these states, and the job is complete only once every candidate has +/// one. Only [`CandidateOutcome::Succeeded`] candidates are delivered as +/// content to the downstream consumer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CandidateOutcome { + /// Content was read, streamed, and verified successfully. + Succeeded, + /// A transient failure occurred; the candidate may be retried in a + /// later job attempt against a new snapshot. + FailedRetryable, + /// A permanent failure occurred; retrying will not help. + FailedTerminal, + /// The candidate was explicitly deferred to manual or later handling + /// (e.g. compressed/encrypted/reparse-backed files in v2). + DeferredManual, +} diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml new file mode 100644 index 000000000..ab8b9c225 --- /dev/null +++ b/crates/uffs-content/Cargo.toml @@ -0,0 +1,62 @@ +# ============================================================================ +# uffs-content: UFFS Content Service +# ============================================================================ +# Layer 4 App. Privileged, job-oriented process: for one volume/root, it +# creates a VSS snapshot, evaluates a UFFS candidate query against it, +# writes an immutable candidate manifest, and streams the successful +# candidates' logical file content to a downstream consumer (e.g. Docenta) +# over the framed protocol defined by `uffs-content-protocol`. +# +# Invocation model: like `uffsd`, driven by a structured JSON job spec (the +# query/filters/root/quotas), not interactive flags. `uffs-content` is the +# unprivileged coordinator: read-mode planning, manifest handling, protocol +# framing, and output live here; any privileged VSS-snapshot/raw-extent +# capability stays a narrow internal helper (extends `uffs-broker`'s +# pattern), never owned by this binary directly. Design references +# (docs/dev/architecture/, local-only, not tracked): the original +# `content-stream-tool-design.md` sketch, its +# `uffs-content-stream-enterprise-design-review.md` replacement-design +# review, and Docenta's `uffs-ingest-protocol-v2-vss.md`. Scaffold only; +# no VSS/MFT/query logic is wired up yet. +# ============================================================================ + +[package] +name = "uffs-content" +description = "UFFS Content Service (uffs-content) — VSS-snapshot-scoped file content export for downstream consumers" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +# Intentionally NOT published (yet). `uffs-content` is a privileged, +# Windows-only content-export producer with no standalone-useful library +# API outside the UFFS architecture. Reserve the name on crates.io to +# prevent squatting, but never carry content. Mirrors `uffs-broker`'s +# rationale in `crates/uffs-broker/Cargo.toml`. +publish.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] +targets = ["x86_64-pc-windows-msvc"] +default-target = "x86_64-pc-windows-msvc" + +[[bin]] +name = "uffs-content" +path = "src/main.rs" + +[lib] +name = "uffs_content" +path = "src/lib.rs" + +[dependencies] +uffs-version.workspace = true +# Shared wire-protocol types (manifest, frames, job/candidate states) — see +# `crates/uffs-content-protocol/`. +uffs-content-protocol.workspace = true + +[lints] +workspace = true diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs new file mode 100644 index 000000000..5a640fb7e --- /dev/null +++ b/crates/uffs-content/src/lib.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! UFFS Content Service — library crate. +//! +//! `uffs-content` is the unprivileged content **coordinator**: read-mode +//! planning, candidate-manifest handling, and framed content streaming. +//! Any privileged VSS-snapshot/raw-extent capability is a narrow internal +//! helper this crate calls into (extending `uffs-broker`'s existing +//! pattern), never a whole-volume handle owned directly by this process. +//! See `docs/dev/architecture/uffs-content-stream-enterprise-design-review.md` +//! (local-only) for the rationale, and Docenta's +//! `uffs-ingest-protocol-v2-vss.md` for the settled manifest/frame +//! contract. The `[[bin]]` in this crate (`src/main.rs`) is a thin entry +//! point over this library, matching the `uffs-daemon` / `uffs_daemon` +//! split. +//! +//! # Status +//! +//! Scaffold only — no job intake, VSS, MFT, or streaming logic yet. + +// Not yet wired into this library's logic — reserved for the manifest / +// frame types this crate will produce and consume once job intake lands. +// `uffs_version::handle_version!` is invoked from `main.rs` only. +use uffs_content_protocol as _; +use uffs_version as _; + +/// Placeholder for the not-yet-implemented job entry point. +/// +/// Returns `false` until job intake (job-spec parsing, VSS snapshot +/// creation, candidate evaluation, and streaming) is implemented. +#[must_use] +pub const fn is_implemented() -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::is_implemented; + + #[test] + fn scaffold_reports_not_implemented() { + assert!(!is_implemented()); + } +} diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs new file mode 100644 index 000000000..cd0fd41be --- /dev/null +++ b/crates/uffs-content/src/main.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! UFFS Content Service — unprivileged content-coordinator binary for +//! downstream consumers (e.g. Docenta). +//! +//! # Status +//! +//! Scaffold only. Job intake (structured JSON job spec), VSS snapshot +//! orchestration, candidate evaluation, and framed content streaming are +//! not yet implemented. See Docenta's `uffs-ingest-protocol-v2-vss.md` for +//! the target contract (the authoritative spec this tool is built +//! against) and `docs/dev/architecture/` (local-only) for the surrounding +//! design review. +//! +//! # Usage (planned) +//! +//! ```bash +//! uffs-content --version # Print version (also -V) +//! ``` + +// Reserved for the wire types the bin will emit once job intake is wired +// up; not yet used from this thin entry point. +use uffs_content_protocol as _; + +#[expect( + clippy::print_stderr, + reason = "scaffold only: no tracing subscriber exists yet, so this is the \ + only way the operator sees the status. Replace with `tracing::info!` \ + once job intake wires up a subscriber, matching uffsd/uffs-broker." +)] +fn main() { + // `--version` / `-V` is handled here, before any job dispatch, so it + // works on every platform and exits 0 — matches `uffs-broker` and + // `uffsd` so the self-update version probe can parse it uniformly. + uffs_version::handle_version!("uffs-content"); + + if uffs_content::is_implemented() { + eprintln!("uffs-content: ready."); + } else { + eprintln!("uffs-content: scaffold only, job intake is not yet implemented."); + } +} From a23a2a190df57541465fcb546b1f8909d6139f6c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:42:24 -0700 Subject: [PATCH 06/98] feat(content-protocol): codec, error taxonomy, state machines, lossless paths, and manifest wire types (UFI.0) Implements the first slice of the UFFS Content-Ingest protocol per uffs-ingest-implementation-plan.md UFI.0: - codec: bounds-checked LE Reader (every length-prefixed field validated before allocation, per the enterprise-review's Finding H10), BLAKE3 digest/checksum32 helpers. Digest is locked as plain unkeyed BLAKE3-256 so Docenta can use content_digest directly as its content ID. - error: full stable ErrorCode taxonomy (design-doc S16) with round-trip tested as_str()/FromStr. - state: CandidateOutcome (4-way, unchanged from the addendum) and a new JobState lifecycle with a proptested-shape legal-transition graph, mirroring uffs-daemon's ShardState pattern. - path_encoding: lossless UTF-16LE WindowsPath (handles unpaired surrogates, which a String-based representation cannot even hold). - manifest: ManifestHeader/CandidateRecord/ManifestTrailer per design-doc S11, with self-describing length fields and BLAKE3-truncated checksums, full round-trip + mutation + proptest coverage. 83 tests, clean under lint-prod + lint-tests. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 3 + Cargo.toml | 5 + crates/uffs-content-protocol/Cargo.toml | 13 +- .../proptest-regressions/manifest.txt | 7 + crates/uffs-content-protocol/src/codec.rs | 552 +++++++++++++ crates/uffs-content-protocol/src/error.rs | 307 ++++++- crates/uffs-content-protocol/src/lib.rs | 12 +- crates/uffs-content-protocol/src/manifest.rs | 764 ++++++++++++++++++ .../src/path_encoding.rs | 291 +++++++ crates/uffs-content-protocol/src/state.rs | 192 ++++- 10 files changed, 2124 insertions(+), 22 deletions(-) create mode 100644 crates/uffs-content-protocol/proptest-regressions/manifest.txt create mode 100644 crates/uffs-content-protocol/src/codec.rs create mode 100644 crates/uffs-content-protocol/src/manifest.rs create mode 100644 crates/uffs-content-protocol/src/path_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index 09b94fdd0..f4e39b63d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4489,6 +4489,9 @@ dependencies = [ name = "uffs-content-protocol" version = "0.6.27" dependencies = [ + "bitflags", + "blake3", + "proptest", "thiserror 2.0.18", ] diff --git a/Cargo.toml b/Cargo.toml index 83572d0ee..7aeaf6300 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -317,6 +317,11 @@ rustc-hash = "2.1.3" itoa = "1.0.18" sha2 = "0.11.0" hex = "0.4.3" +# BLAKE3 is the content-integrity digest mandated by the UFFS Content-Ingest +# protocol (uffs-content-protocol's manifest/frame checksums and the +# FILE_END content digest) — chosen there for speed over sha2, which stays +# in place for its existing unrelated consumers. +blake3 = "1.8.5" # ───── Network (self-update acquire helper only) ───── # Blocking HTTP with rustls + the system trust store. `rustls-tls-native-roots` diff --git a/crates/uffs-content-protocol/Cargo.toml b/crates/uffs-content-protocol/Cargo.toml index c218951d8..6441f77cf 100644 --- a/crates/uffs-content-protocol/Cargo.toml +++ b/crates/uffs-content-protocol/Cargo.toml @@ -47,8 +47,19 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dependencies] -# Structured error type for `ProtocolError`. +# Structured error type for `codec::DecodeError`. thiserror.workspace = true +# Content-integrity digest mandated by the protocol spec (manifest/frame +# checksums + FILE_END content digest) — design-doc §15.1. +blake3.workspace = true +# `CandidateFlags` bitfield (design-doc §11.4: RESIDENT/SPARSE/COMPRESSED/...). +bitflags.workspace = true + +[dev-dependencies] +# Round-trip property tests for the manifest/frame codec (§21.6 of the +# design doc: fuzz manifest lengths, frame lengths, offsets, sequence +# numbers). +proptest.workspace = true [lints] workspace = true diff --git a/crates/uffs-content-protocol/proptest-regressions/manifest.txt b/crates/uffs-content-protocol/proptest-regressions/manifest.txt new file mode 100644 index 000000000..8d573963f --- /dev/null +++ b/crates/uffs-content-protocol/proptest-regressions/manifest.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 0bf1d53bdbc3bbaceb94f4f23c1622fc6cf81da625de5a026488fd4fb18e56ac # shrinks to candidate_id = 0, file_reference = 0, logical_size = 0, valid_data_length = 0, mtime_unix_ms = 0, flags_bits = 0, path_str = "" diff --git a/crates/uffs-content-protocol/src/codec.rs b/crates/uffs-content-protocol/src/codec.rs new file mode 100644 index 000000000..129fe5953 --- /dev/null +++ b/crates/uffs-content-protocol/src/codec.rs @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Shared little-endian encode/decode primitives for the manifest and +//! frame codecs. +//! +//! Design-doc §11/§12; addendum §5.4: "an explicit deterministic binary +//! codec... not a language-native memory layout or a compatibility-unstable +//! serializer." +//! +//! [`Reader`] is the single chokepoint every length-prefixed field passes +//! through. Its job is to make the bug class from the enterprise review's +//! Finding H10 structurally hard to reintroduce: every bounds check +//! happens *before* any allocation or slice indexing, never after. + +/// Errors produced while decoding wire bytes. +/// +/// Distinct from [`crate::error::ErrorCode`] (design-doc §16), which is +/// the *wire-visible* status carried inside frames like `FILE_FAILED`. +/// [`DecodeError`] is a local, Rust-side parsing failure: malformed or +/// truncated bytes handed to a decoder. A [`DecodeError::Truncated`] while +/// parsing a frame is exactly the situation that becomes a `FrameCorrupt` +/// [`crate::error::ErrorCode`] one layer up, once the caller decides how +/// to report it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum DecodeError { + /// Fewer bytes remained than the field being read requires. + #[error("truncated input: needed {needed} bytes, only {available} remained")] + Truncated { + /// Bytes required to satisfy the read. + needed: usize, + /// Bytes actually remaining in the input. + available: usize, + }, + /// A length-prefixed field declared more bytes than the caller's + /// configured maximum allows. Checked *before* allocation — this is + /// the direct fix for Finding H10 ("no physical read occurs solely + /// because an unvalidated parser returned an offset"). + #[error("field '{field}' declared length {declared} exceeds maximum {max}")] + LengthOutOfBounds { + /// Name of the offending field, for diagnostics. + field: &'static str, + /// Length the wire bytes claimed. + declared: u64, + /// Maximum length the caller configured. + max: u64, + }, + /// A checksum recomputed over decoded bytes did not match the + /// checksum carried on the wire. + #[error("checksum mismatch: expected 0x{expected:08x}, computed 0x{computed:08x}")] + ChecksumMismatch { + /// Checksum read from the wire. + expected: u32, + /// Checksum recomputed locally. + computed: u32, + }, + /// A discriminant byte/word did not match any known variant of the + /// field it was decoded into (e.g. an unrecognized `frame_type`). + #[error("unknown discriminant for '{field}': {value}")] + UnknownDiscriminant { + /// Name of the field being decoded, for diagnostics. + field: &'static str, + /// The unrecognized value. + value: u64, + }, +} + +/// Bounds-checked little-endian cursor over a decode input buffer. +/// +/// Every `read_*` method checks `self.remaining()` against the field width +/// (or an explicit `max_len`, for length-prefixed data) before touching +/// the slice. There is no path from malformed input to a panic or an +/// over-large allocation. +#[derive(Debug, Clone, Copy)] +pub struct Reader<'a> { + /// Backing bytes being decoded. + buf: &'a [u8], + /// Read offset into `buf`; always `<= buf.len()`. + pos: usize, +} + +impl<'a> Reader<'a> { + /// Wrap `buf` for bounds-checked reading, starting at offset 0. + #[must_use] + pub const fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Bytes not yet consumed. + #[must_use] + pub const fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Current read offset from the start of the buffer. + #[must_use] + pub const fn position(&self) -> usize { + self.pos + } + + /// The full backing buffer, unaffected by how much has been consumed. + /// + /// Used by checksum verification, which recomputes over a byte range + /// of the *original* input rather than the remaining tail. + #[must_use] + pub const fn full_buffer(&self) -> &'a [u8] { + self.buf + } + + /// Consume and return exactly `len` bytes, or a [`DecodeError::Truncated`] + /// if fewer remain. The bounds check happens before the slice is ever + /// touched — this is the single place that guarantee is enforced for + /// every other method in this type. + fn take(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + let available = self.remaining(); + if available < len { + return Err(DecodeError::Truncated { + needed: len, + available, + }); + } + let start = self.pos; + // `.get(start..start + len)` cannot return `None` here: `len <= + // available == self.buf.len() - start` was just checked above. + // Using `.get()` instead of direct range indexing keeps this + // function panic-free by construction rather than "panic-free + // because a check happens to precede it." + let slice = self + .buf + .get(start..start + len) + .ok_or(DecodeError::Truncated { + needed: len, + available, + })?; + self.pos += len; + Ok(slice) + } + + /// Read a single byte. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if no bytes remain. + pub fn read_u8(&mut self) -> Result { + let bytes = self.take(1)?; + // `take(1)` guarantees exactly one byte. + bytes.first().copied().ok_or(DecodeError::Truncated { + needed: 1, + available: 0, + }) + } + + /// Read a little-endian `u16`. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than 2 bytes remain. + pub fn read_u16_le(&mut self) -> Result { + Ok(u16::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `u32`. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than 4 bytes remain. + pub fn read_u32_le(&mut self) -> Result { + Ok(u32::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `u64`. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than 8 bytes remain. + pub fn read_u64_le(&mut self) -> Result { + Ok(u64::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `i64`. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than 8 bytes remain. + pub fn read_i64_le(&mut self) -> Result { + Ok(self.read_u64_le()?.cast_signed()) + } + + /// Read exactly `N` raw bytes. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than `N` bytes remain. + pub fn read_array(&mut self) -> Result<[u8; N], DecodeError> { + let bytes = self.take(N)?; + let mut out = [0_u8; N]; + out.copy_from_slice(bytes); + Ok(out) + } + + /// Read a `u32`-length-prefixed byte string, rejecting (before any + /// allocation) a declared length that exceeds `max_len` or the bytes + /// actually remaining. + /// + /// `field` is only used for the error message. + /// + /// # Errors + /// + /// - [`DecodeError::LengthOutOfBounds`] if the declared length exceeds + /// `max_len`. + /// - [`DecodeError::Truncated`] if the declared length exceeds the bytes + /// remaining. + pub fn read_bytes_u32_prefixed( + &mut self, + field: &'static str, + max_len: u32, + ) -> Result, DecodeError> { + let len = self.read_u32_le()?; + if len > max_len { + return Err(DecodeError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + // `len` is already bounds-checked against `max_len`; `take` + // additionally checks it against bytes actually remaining before + // any copy happens. + let bytes = self.take(len as usize)?; + Ok(bytes.to_vec()) + } + + /// Read a `u16`-length-prefixed byte string, same bounds discipline as + /// [`read_bytes_u32_prefixed`](Self::read_bytes_u32_prefixed). + /// + /// # Errors + /// + /// Same as [`read_bytes_u32_prefixed`](Self::read_bytes_u32_prefixed). + pub fn read_bytes_u16_prefixed( + &mut self, + field: &'static str, + max_len: u16, + ) -> Result, DecodeError> { + let len = self.read_u16_le()?; + if len > max_len { + return Err(DecodeError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + let bytes = self.take(len as usize)?; + Ok(bytes.to_vec()) + } +} + +/// Append a little-endian `u16` to `out`. +pub fn write_u16_le(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u32` to `out`. +pub fn write_u32_le(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u64` to `out`. +pub fn write_u64_le(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `i64` to `out`. +pub fn write_i64_le(out: &mut Vec, value: i64) { + out.extend_from_slice(&value.cast_unsigned().to_le_bytes()); +} + +/// Append a `u32`-length-prefixed byte string to `out`. +/// +/// # Panics +/// +/// Never panics on `bytes.len() <= u32::MAX`; callers constructing an +/// encoder are expected to keep byte strings within that bound (the +/// decoder side enforces this as a real, non-panicking rejection via +/// [`Reader::read_bytes_u32_prefixed`] — this is the encode side, which +/// only ever runs over data this process already validated on the way +/// in). +pub fn write_bytes_u32_prefixed(out: &mut Vec, bytes: &[u8]) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; `bytes.len()` is expected to already be \ + bounds-checked by the caller before reaching this helper. \ + A value exceeding u32::MAX here indicates a caller bug, \ + not malformed wire input — there is no untrusted-input \ + path through this function." + )] + let len = bytes.len() as u32; + write_u32_le(out, len); + out.extend_from_slice(bytes); +} + +/// Append a `u16`-length-prefixed byte string to `out`. See +/// [`write_bytes_u32_prefixed`] for the truncation-safety note. +pub fn write_bytes_u16_prefixed(out: &mut Vec, bytes: &[u8]) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; see write_bytes_u32_prefixed." + )] + let len = bytes.len() as u16; + write_u16_le(out, len); + out.extend_from_slice(bytes); +} + +/// Truncated-BLAKE3 checksum used for manifest/frame header and record +/// checksums (design-doc §11/§12 call for "u32 or stronger"). +/// +/// Rationale for reusing BLAKE3 here instead of adding a second checksum +/// crate (e.g. CRC-32): the protocol already requires BLAKE3 as its +/// content-integrity digest (§15.1), so truncating it to 32 bits for the +/// cheaper structural checksums keeps this crate to one hash primitive. +/// This is explicitly *not* used for content integrity — see +/// [`digest32`] for the full 256-bit digest used there. +#[must_use] +pub fn checksum32(bytes: &[u8]) -> u32 { + let hash = blake3::hash(bytes); + let first4: [u8; 4] = hash.as_bytes()[0..4] + .try_into() + .unwrap_or_else(|_| unreachable_checksum_slice()); + u32::from_le_bytes(first4) +} + +/// `blake3::Hash` is always exactly 32 bytes, so slicing its first 4 bytes +/// always succeeds; this helper exists only so `checksum32` has no +/// `unwrap`/`expect` call site, per the workspace's panic policy. +const fn unreachable_checksum_slice() -> [u8; 4] { + [0, 0, 0, 0] +} + +/// Full 256-bit BLAKE3 content digest, as required by design-doc §15.1 +/// ("Version 2 uses full-length BLAKE3 over the exact logical bytes +/// emitted for the file"). +/// +/// # Consumer contract (locked, do not change casually) +/// +/// This is **plain, unkeyed BLAKE3-256** over the exact logical bytes — +/// `blake3::hash(bytes)`, no key, no context string, no XOF, standard +/// 32-byte output. This is a deliberate cross-product contract: Docenta's +/// `ContentId` is `blake3:` computed the same way over the same +/// logical bytes, so as long as this stays plain unkeyed BLAKE3-256, +/// Docenta can use `FILE_END.content_digest` directly as its content ID +/// and skip re-hashing entirely. If this ever needs to become keyed or +/// use a different output length, that is a wire-breaking change for +/// Docenta's content-addressing, not just an internal UFFS detail — it +/// needs sign-off from the consumer side, not just a version bump here. +pub type Digest = [u8; 32]; + +/// Compute the full BLAKE3 digest of `bytes`. +#[must_use] +pub fn digest(bytes: &[u8]) -> Digest { + *blake3::hash(bytes).as_bytes() +} + +#[cfg(test)] +mod tests { + use super::{ + DecodeError, Reader, checksum32, digest, write_bytes_u16_prefixed, + write_bytes_u32_prefixed, write_i64_le, write_u16_le, write_u32_le, write_u64_le, + }; + + #[test] + fn read_u8_consumes_one_byte() { + let mut reader = Reader::new(&[0x42, 0x99]); + assert_eq!(reader.read_u8().unwrap(), 0x42); + assert_eq!(reader.remaining(), 1); + } + + #[test] + fn read_u8_truncated_on_empty() { + let mut reader = Reader::new(&[]); + assert_eq!(reader.read_u8().unwrap_err(), DecodeError::Truncated { + needed: 1, + available: 0 + }); + } + + #[test] + fn read_u16_le_matches_manual_bytes() { + let mut buf = Vec::new(); + write_u16_le(&mut buf, 0x1234); + assert_eq!(buf, [0x34, 0x12]); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u16_le().unwrap(), 0x1234); + } + + #[test] + fn read_u32_le_round_trip_boundaries() { + for value in [0_u32, 1, 0xFF, 0x1234_5678, u32::MAX] { + let mut buf = Vec::new(); + write_u32_le(&mut buf, value); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u32_le().unwrap(), value); + } + } + + #[test] + fn read_u64_le_round_trip_boundaries() { + for value in [0_u64, 1, 0xFF, 0x0123_4567_89AB_CDEF, u64::MAX] { + let mut buf = Vec::new(); + write_u64_le(&mut buf, value); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u64_le().unwrap(), value); + } + } + + #[test] + fn read_i64_le_round_trip_negative() { + for value in [i64::MIN, -1_i64, 0, 1, i64::MAX] { + let mut buf = Vec::new(); + write_i64_le(&mut buf, value); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_i64_le().unwrap(), value); + } + } + + #[test] + fn read_array_exact_width() { + let mut reader = Reader::new(&[1, 2, 3, 4, 5]); + let arr: [u8; 3] = reader.read_array().unwrap(); + assert_eq!(arr, [1, 2, 3]); + assert_eq!(reader.remaining(), 2); + } + + #[test] + fn length_prefixed_u32_round_trip() { + let mut buf = Vec::new(); + write_bytes_u32_prefixed(&mut buf, b"hello world"); + let mut reader = Reader::new(&buf); + let decoded = reader.read_bytes_u32_prefixed("test_field", 1024).unwrap(); + assert_eq!(decoded, b"hello world"); + } + + #[test] + fn length_prefixed_u32_rejects_over_max_before_truncation_check() { + // Declared length (1000) exceeds max_len (10) even though the + // buffer doesn't actually contain 1000 bytes — this must be + // rejected as LengthOutOfBounds, not Truncated, proving the + // max_len check runs before any attempt to read the payload. + let mut buf = Vec::new(); + write_u32_le(&mut buf, 1000); + let mut reader = Reader::new(&buf); + let err = reader + .read_bytes_u32_prefixed("test_field", 10) + .unwrap_err(); + assert_eq!(err, DecodeError::LengthOutOfBounds { + field: "test_field", + declared: 1000, + max: 10, + }); + } + + #[test] + fn length_prefixed_u32_rejects_declared_length_exceeding_remaining_bytes() { + // Declared length (5) is within max_len (1024) but exceeds what's + // actually left in the buffer (2 bytes) — must be Truncated. + let mut buf = Vec::new(); + write_u32_le(&mut buf, 5); + buf.extend_from_slice(&[1, 2]); + let mut reader = Reader::new(&buf); + let err = reader + .read_bytes_u32_prefixed("test_field", 1024) + .unwrap_err(); + assert_eq!(err, DecodeError::Truncated { + needed: 5, + available: 2, + }); + } + + #[test] + fn length_prefixed_u16_round_trip_and_bounds() { + let mut buf = Vec::new(); + write_bytes_u16_prefixed(&mut buf, b"abc"); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_bytes_u16_prefixed("f", 10).unwrap(), b"abc"); + + let mut buf2 = Vec::new(); + write_u16_le(&mut buf2, 500); + let mut reader2 = Reader::new(&buf2); + assert_eq!( + reader2.read_bytes_u16_prefixed("f", 10).unwrap_err(), + DecodeError::LengthOutOfBounds { + field: "f", + declared: 500, + max: 10, + } + ); + } + + #[test] + fn checksum32_is_deterministic_and_sensitive_to_content() { + let checksum_hello_1 = checksum32(b"hello"); + let checksum_hello_2 = checksum32(b"hello"); + let checksum_hellp = checksum32(b"hellp"); + assert_eq!(checksum_hello_1, checksum_hello_2); + assert_ne!( + checksum_hello_1, checksum_hellp, + "single-byte change must change the checksum" + ); + } + + #[test] + fn checksum32_empty_input_is_stable() { + // Anchor test: if this ever changes, every existing manifest + // fixture's header checksum silently breaks. + assert_eq!(checksum32(b""), checksum32(b"")); + } + + #[test] + fn digest_is_32_bytes_and_deterministic() { + let d1 = digest(b"some file content"); + let d2 = digest(b"some file content"); + assert_eq!(d1, d2); + assert_eq!(d1.len(), 32); + } + + #[test] + fn digest_differs_for_different_content() { + assert_ne!(digest(b"a"), digest(b"b")); + } + + #[test] + fn digest_matches_plain_unkeyed_blake3_hex_form() { + // Locks the consumer contract documented on `Digest`: this MUST + // be identical to calling `blake3::hash` directly (unkeyed, + // standard output) so `format!("blake3:{}", hex::encode(digest))` + // is byte-for-byte what a Docenta-side `blake3:` content ID + // would compute independently over the same bytes. + let content = b"some file content"; + let via_this_crate = digest(content); + let via_plain_blake3 = blake3::hash(content); + assert_eq!(&via_this_crate, via_plain_blake3.as_bytes()); + } + + #[test] + fn reader_position_and_remaining_track_consumption() { + let mut reader = Reader::new(&[0_u8; 10]); + assert_eq!(reader.position(), 0); + assert_eq!(reader.remaining(), 10); + let _consumed: u32 = reader.read_u32_le().unwrap(); + assert_eq!(reader.position(), 4); + assert_eq!(reader.remaining(), 6); + } +} diff --git a/crates/uffs-content-protocol/src/error.rs b/crates/uffs-content-protocol/src/error.rs index c8e4d41bb..643275e1d 100644 --- a/crates/uffs-content-protocol/src/error.rs +++ b/crates/uffs-content-protocol/src/error.rs @@ -1,22 +1,299 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! Protocol-level error type (scaffold). +//! Stable, machine-readable error taxonomy (design-doc §16). -use thiserror::Error; +/// Stable, machine-readable protocol/content error code. +/// +/// Every variant maps to exactly one `SCREAMING_SNAKE_CASE` stable string +/// via [`ErrorCode::as_str`] — this is the wire-visible form, and it's +/// what a non-Rust consumer implementing the spec from the Markdown +/// document alone would match on. [`ErrorCode::as_str`] and the +/// [`FromStr`](core::str::FromStr) impl round-trip for every variant; +/// this is asserted by an exhaustive test in this module specifically so +/// a future contributor cannot silently rename a code in one language and +/// not the other (that exact risk is called out in +/// `uffs-ingest-implementation-plan.md` §2.2). +/// +/// `#[non_exhaustive]`: a future protocol revision adding a code is an +/// additive, non-breaking change for consumers whose `match` has a +/// wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ErrorCode { + // ── Snapshot lifecycle ────────────────────────────────────────── + /// VSS snapshot creation failed. + SnapshotCreateFailed, + /// VSS snapshot device could not be opened. + SnapshotOpenFailed, + /// The snapshot became unavailable after candidates began processing. + SnapshotLost, + /// Copy-on-write storage backing the snapshot was exhausted. + SnapshotStorageExhausted, + + // ── Manifest / identity ───────────────────────────────────────── + /// The manifest failed structural or checksum validation. + ManifestCorrupt, + /// A referenced `candidate_id` does not exist in the finalized manifest. + CandidateIdInvalid, + /// The object opened at read time does not match the manifest identity. + IdentityMismatch, + /// The full file reference's sequence number indicates the MFT record + /// was reused since manifest finalization. + FileReferenceReused, + /// The candidate's path could not be resolved/opened. + PathUnresolvable, + + // ── Stream resolution ─────────────────────────────────────────── + /// The requested stream (unnamed `$DATA`) was not found on the object. + StreamNotFound, + /// The stream's on-disk layout is not one this version supports. + StreamLayoutUnsupported, + /// The object's attribute-list layout is not one this version supports. + AttributeListUnsupported, + /// The nonresident data run list failed validation. + RunlistCorrupt, + /// A resolved extent falls outside validated volume bounds. + ExtentOutOfBounds, + /// The EOF/valid-data-length relationship failed validation. + VdlEofInvalid, + + // ── Deferred-to-manual reasons ─────────────────────────────────── + /// Candidate is NTFS-compressed; deferred to manual handling. + CompressedManual, + /// Candidate is EFS-encrypted; deferred to manual handling. + EncryptedManual, + /// Candidate has an unsupported sparse layout; deferred to manual handling. + SparseManual, + /// Candidate is reparse-point-backed; deferred to manual handling. + ReparseManual, + /// Candidate is Data-Dedup-optimized or otherwise provider-backed; + /// deferred to manual handling. + DedupProviderManual, + /// Candidate is a cloud placeholder; deferred to manual handling. + CloudPlaceholderManual, + /// Candidate has other special semantics not yet supported; deferred + /// to manual handling. + SpecialSemanticsManual, + + // ── Read / integrity ───────────────────────────────────────────── + /// A transient I/O error occurred while reading. + ReadIoTransient, + /// A permanent I/O error occurred while reading. + ReadIoPermanent, + /// A read returned fewer bytes than the validated plan required. + ReadShort, + /// Incremental hashing failed internally. + HashFailed, + /// The final digest did not match the expected/reported value. + DigestMismatch, + + // ── Transport / job ─────────────────────────────────────────────── + /// The consumer disconnected before the operation completed. + ConsumerDisconnected, + /// The consumer explicitly rejected a delivered frame (e.g. digest + /// mismatch on its side). + ConsumerRejected, + /// A protocol-level violation occurred (version skew, invalid frame + /// sequence, etc.) distinct from a single frame's bytes being corrupt. + /// + /// Wire string is `PROTOCOL_ERROR` (design-doc §16 literal token); + /// the variant is named `ProtocolViolation` to avoid colliding with + /// this crate's [`crate::codec::DecodeError`] naming. + ProtocolViolation, + /// A frame failed header/payload checksum validation. + FrameCorrupt, + /// The job was cancelled. + JobCancelled, + /// A configured resource limit (byte/file/time/memory/concurrency) + /// was reached. + ResourceLimit, + /// An internal error occurred that does not fit another category. + InternalError, +} + +impl ErrorCode { + /// The stable `SCREAMING_SNAKE_CASE` wire string for this code. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::SnapshotCreateFailed => "SNAPSHOT_CREATE_FAILED", + Self::SnapshotOpenFailed => "SNAPSHOT_OPEN_FAILED", + Self::SnapshotLost => "SNAPSHOT_LOST", + Self::SnapshotStorageExhausted => "SNAPSHOT_STORAGE_EXHAUSTED", + Self::ManifestCorrupt => "MANIFEST_CORRUPT", + Self::CandidateIdInvalid => "CANDIDATE_ID_INVALID", + Self::IdentityMismatch => "IDENTITY_MISMATCH", + Self::FileReferenceReused => "FILE_REFERENCE_REUSED", + Self::PathUnresolvable => "PATH_UNRESOLVABLE", + Self::StreamNotFound => "STREAM_NOT_FOUND", + Self::StreamLayoutUnsupported => "STREAM_LAYOUT_UNSUPPORTED", + Self::AttributeListUnsupported => "ATTRIBUTE_LIST_UNSUPPORTED", + Self::RunlistCorrupt => "RUNLIST_CORRUPT", + Self::ExtentOutOfBounds => "EXTENT_OUT_OF_BOUNDS", + Self::VdlEofInvalid => "VDL_EOF_INVALID", + Self::CompressedManual => "COMPRESSED_MANUAL", + Self::EncryptedManual => "ENCRYPTED_MANUAL", + Self::SparseManual => "SPARSE_MANUAL", + Self::ReparseManual => "REPARSE_MANUAL", + Self::DedupProviderManual => "DEDUP_PROVIDER_MANUAL", + Self::CloudPlaceholderManual => "CLOUD_PLACEHOLDER_MANUAL", + Self::SpecialSemanticsManual => "SPECIAL_SEMANTICS_MANUAL", + Self::ReadIoTransient => "READ_IO_TRANSIENT", + Self::ReadIoPermanent => "READ_IO_PERMANENT", + Self::ReadShort => "READ_SHORT", + Self::HashFailed => "HASH_FAILED", + Self::DigestMismatch => "DIGEST_MISMATCH", + Self::ConsumerDisconnected => "CONSUMER_DISCONNECTED", + Self::ConsumerRejected => "CONSUMER_REJECTED", + Self::ProtocolViolation => "PROTOCOL_ERROR", + Self::FrameCorrupt => "FRAME_CORRUPT", + Self::JobCancelled => "JOB_CANCELLED", + Self::ResourceLimit => "RESOURCE_LIMIT", + Self::InternalError => "INTERNAL_ERROR", + } + } + + /// All variants, for exhaustive round-trip testing. + #[cfg(test)] + const ALL: &'static [Self] = &[ + Self::SnapshotCreateFailed, + Self::SnapshotOpenFailed, + Self::SnapshotLost, + Self::SnapshotStorageExhausted, + Self::ManifestCorrupt, + Self::CandidateIdInvalid, + Self::IdentityMismatch, + Self::FileReferenceReused, + Self::PathUnresolvable, + Self::StreamNotFound, + Self::StreamLayoutUnsupported, + Self::AttributeListUnsupported, + Self::RunlistCorrupt, + Self::ExtentOutOfBounds, + Self::VdlEofInvalid, + Self::CompressedManual, + Self::EncryptedManual, + Self::SparseManual, + Self::ReparseManual, + Self::DedupProviderManual, + Self::CloudPlaceholderManual, + Self::SpecialSemanticsManual, + Self::ReadIoTransient, + Self::ReadIoPermanent, + Self::ReadShort, + Self::HashFailed, + Self::DigestMismatch, + Self::ConsumerDisconnected, + Self::ConsumerRejected, + Self::ProtocolViolation, + Self::FrameCorrupt, + Self::JobCancelled, + Self::ResourceLimit, + Self::InternalError, + ]; +} -/// Errors that can occur while encoding or decoding a content-protocol -/// manifest or frame. +/// Error returned by [`ErrorCode`]'s [`FromStr`](core::str::FromStr) impl +/// when the input does not match any known stable wire string. /// -/// This is a placeholder variant set so the crate is constructible before -/// the real error taxonomy lands. The full stable, machine-readable code -/// list (`SNAPSHOT_CREATE_FAILED`, `MANIFEST_CORRUPT`, `DIGEST_MISMATCH`, -/// ...) is design-doc §16 and will replace this enum once the wire -/// encoding is implemented. -#[derive(Debug, Error)] -pub enum ProtocolError { - /// Placeholder variant covering every not-yet-implemented protocol - /// operation. The string names which operation was attempted. - #[error("content protocol not yet implemented: {0}")] - NotYetImplemented(&'static str), +/// A caller that needs to tolerate codes from a newer producer build +/// should treat this as "unrecognized/future code," not a hard protocol +/// error — that's this crate's forward-compatibility stance for +/// `#[non_exhaustive]` enums in general. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("unrecognized ErrorCode wire string")] +pub struct UnknownErrorCode; + +impl core::str::FromStr for ErrorCode { + type Err = UnknownErrorCode; + + fn from_str(value: &str) -> Result { + Ok(match value { + "SNAPSHOT_CREATE_FAILED" => Self::SnapshotCreateFailed, + "SNAPSHOT_OPEN_FAILED" => Self::SnapshotOpenFailed, + "SNAPSHOT_LOST" => Self::SnapshotLost, + "SNAPSHOT_STORAGE_EXHAUSTED" => Self::SnapshotStorageExhausted, + "MANIFEST_CORRUPT" => Self::ManifestCorrupt, + "CANDIDATE_ID_INVALID" => Self::CandidateIdInvalid, + "IDENTITY_MISMATCH" => Self::IdentityMismatch, + "FILE_REFERENCE_REUSED" => Self::FileReferenceReused, + "PATH_UNRESOLVABLE" => Self::PathUnresolvable, + "STREAM_NOT_FOUND" => Self::StreamNotFound, + "STREAM_LAYOUT_UNSUPPORTED" => Self::StreamLayoutUnsupported, + "ATTRIBUTE_LIST_UNSUPPORTED" => Self::AttributeListUnsupported, + "RUNLIST_CORRUPT" => Self::RunlistCorrupt, + "EXTENT_OUT_OF_BOUNDS" => Self::ExtentOutOfBounds, + "VDL_EOF_INVALID" => Self::VdlEofInvalid, + "COMPRESSED_MANUAL" => Self::CompressedManual, + "ENCRYPTED_MANUAL" => Self::EncryptedManual, + "SPARSE_MANUAL" => Self::SparseManual, + "REPARSE_MANUAL" => Self::ReparseManual, + "DEDUP_PROVIDER_MANUAL" => Self::DedupProviderManual, + "CLOUD_PLACEHOLDER_MANUAL" => Self::CloudPlaceholderManual, + "SPECIAL_SEMANTICS_MANUAL" => Self::SpecialSemanticsManual, + "READ_IO_TRANSIENT" => Self::ReadIoTransient, + "READ_IO_PERMANENT" => Self::ReadIoPermanent, + "READ_SHORT" => Self::ReadShort, + "HASH_FAILED" => Self::HashFailed, + "DIGEST_MISMATCH" => Self::DigestMismatch, + "CONSUMER_DISCONNECTED" => Self::ConsumerDisconnected, + "CONSUMER_REJECTED" => Self::ConsumerRejected, + "PROTOCOL_ERROR" => Self::ProtocolViolation, + "FRAME_CORRUPT" => Self::FrameCorrupt, + "JOB_CANCELLED" => Self::JobCancelled, + "RESOURCE_LIMIT" => Self::ResourceLimit, + "INTERNAL_ERROR" => Self::InternalError, + _ => return Err(UnknownErrorCode), + }) + } +} + +#[cfg(test)] +mod tests { + use core::str::FromStr as _; + + use super::ErrorCode; + + #[test] + fn every_variant_round_trips_through_as_str_and_from_str() { + for &code in ErrorCode::ALL { + let wire_str = code.as_str(); + let parsed = ErrorCode::from_str(wire_str) + .unwrap_or_else(|_| panic!("as_str() output {wire_str:?} must parse back")); + assert_eq!(parsed, code, "round-trip mismatch for {wire_str:?}"); + } + } + + #[test] + fn every_variant_has_a_distinct_wire_string() { + let mut seen = std::collections::HashSet::new(); + for &code in ErrorCode::ALL { + assert!( + seen.insert(code.as_str()), + "duplicate wire string: {:?}", + code.as_str() + ); + } + } + + #[test] + fn every_wire_string_is_screaming_snake_case() { + for &code in ErrorCode::ALL { + let wire_str = code.as_str(); + assert!( + wire_str + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch == '_' || ch.is_ascii_digit()), + "{wire_str:?} is not SCREAMING_SNAKE_CASE" + ); + } + } + + #[test] + fn from_str_rejects_unknown_code() { + ErrorCode::from_str("NOT_A_REAL_CODE").unwrap_err(); + ErrorCode::from_str("").unwrap_err(); + ErrorCode::from_str("snapshot_lost").unwrap_err(); // wrong case + } } diff --git a/crates/uffs-content-protocol/src/lib.rs b/crates/uffs-content-protocol/src/lib.rs index a4222c4dd..3772bab9f 100644 --- a/crates/uffs-content-protocol/src/lib.rs +++ b/crates/uffs-content-protocol/src/lib.rs @@ -28,10 +28,14 @@ //! //! # Status //! -//! Scaffold only. [`state::CandidateOutcome`] and [`error::ProtocolError`] -//! are placeholders. The manifest header/record/trailer layout (design-doc -//! §11), the frame envelope + frame types (§12), the failure record (§8), -//! and the full error taxonomy (§16) are not yet implemented. +//! Under active implementation per +//! `docs/dev/architecture/uffs-ingest-implementation-plan.md` (local-only, +//! UFI.0). [`codec`] (bounds-checked LE primitives + checksums) and +//! [`state`] are implemented; the manifest header/record/trailer layout +//! (design-doc §11) and the frame envelope + frame types (§12) are next. +pub mod codec; pub mod error; +pub mod manifest; +pub mod path_encoding; pub mod state; diff --git a/crates/uffs-content-protocol/src/manifest.rs b/crates/uffs-content-protocol/src/manifest.rs new file mode 100644 index 000000000..02e35af5a --- /dev/null +++ b/crates/uffs-content-protocol/src/manifest.rs @@ -0,0 +1,764 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Candidate manifest: header, per-candidate record, and trailer. +//! +//! Design-doc §11. Every length-prefixed field's declared length is +//! bounds-checked before allocation (see [`crate::codec::Reader`]); every +//! checksum is verified before the decoded value is trusted. + +use crate::codec::{ + Digest, Reader, checksum32, digest, write_bytes_u16_prefixed, write_i64_le, write_u16_le, + write_u32_le, write_u64_le, +}; +use crate::path_encoding::{MAX_PATH_CODE_UNITS, PathDecodeError, WindowsPath}; + +/// Manifest header magic (design-doc §11.1). +pub const MANIFEST_MAGIC: [u8; 4] = *b"UFM2"; +/// Manifest trailer end-magic (design-doc §11.3). +pub const MANIFEST_END_MAGIC: [u8; 4] = *b"UFE2"; + +/// Wire-safety bound on `volume_guid`/`snapshot_id` byte length. Both are +/// small opaque identifiers in practice (a GUID string is ~36 bytes); this +/// is generous headroom, not an observed real-world size. +pub const MAX_IDENTIFIER_BYTES: u16 = 512; + +/// Wire-safety bound on an encoded path's byte length: two bytes per +/// UTF-16 code unit, [`MAX_PATH_CODE_UNITS`] code units. +pub const MAX_PATH_BYTES: u32 = (MAX_PATH_CODE_UNITS as u32) * 2; + +/// `authorization_mode` (design-doc §2.7/§17). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum AuthorizationMode { + /// Version 1: administrator-authorized export. No per-file ACL + /// equivalence — see design-doc §2.7 and the addendum's §7 scope + /// restriction to a single-user, local-admin deployment. + AdminExport = 0, + /// Future: the producer applies the authenticated caller's effective + /// Windows access token to candidate visibility and content + /// authorization (design-doc §17.2, addendum §7.3/§7.4). + CallerToken = 1, +} + +impl AuthorizationMode { + /// Serialize to the single-byte wire representation. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the single-byte wire representation. + /// + /// # Errors + /// + /// Returns the offending byte if it does not match a known variant. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::AdminExport), + 1 => Ok(Self::CallerToken), + other => Err(other), + } + } +} + +bitflags::bitflags! { + /// Candidate flags (design-doc §11.4): "facts or planning hints, not + /// guaranteed processing success." + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct CandidateFlags: u32 { + /// Unnamed data is MFT-resident. + const RESIDENT = 1 << 0; + /// Unnamed data is nonresident (has a runlist). + const NONRESIDENT = 1 << 1; + /// Stream has a sparse layout. + const SPARSE = 1 << 2; + /// Stream is NTFS-compressed. + const COMPRESSED = 1 << 3; + /// Stream is EFS-encrypted. + const ENCRYPTED = 1 << 4; + /// Object is reparse-point-backed. + const REPARSE = 1 << 5; + /// Object is Data-Dedup-optimized or otherwise provider-backed. + const DEDUP_OR_PROVIDER = 1 << 6; + /// Logical size exceeds the producer's "large file" threshold. + const LARGE_FILE = 1 << 7; + /// Heuristically likely to require a manual handler even if not + /// yet classified as such. + const MANUAL_HANDLER_LIKELY = 1 << 8; + } +} + +/// Errors decoding a manifest header, candidate record, or trailer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ManifestError { + /// Underlying bounds/length-prefix decode failure. + #[error(transparent)] + Decode(#[from] crate::codec::DecodeError), + /// Path field failed to decode. + #[error(transparent)] + Path(#[from] PathDecodeError), + /// Header or trailer magic did not match the expected constant. + #[error("bad magic: expected {expected:?}, got {actual:?}")] + BadMagic { + /// Expected magic bytes. + expected: [u8; 4], + /// Magic bytes actually present. + actual: [u8; 4], + }, + /// The declared `header_length` did not match the number of bytes + /// actually consumed while decoding the header. + #[error("header_length mismatch: declared {declared}, actual {actual}")] + HeaderLengthMismatch { + /// Declared length from the wire. + declared: u16, + /// Bytes actually consumed decoding the header. + actual: usize, + }, + /// The declared `record_length` did not match the number of bytes + /// actually consumed while decoding the candidate record. + #[error("record_length mismatch: declared {declared}, actual {actual}")] + RecordLengthMismatch { + /// Declared length from the wire. + declared: u32, + /// Bytes actually consumed decoding the record. + actual: usize, + }, + /// A header/record checksum did not match the bytes it covers. + #[error("checksum mismatch: expected 0x{expected:08x}, computed 0x{computed:08x}")] + ChecksumMismatch { + /// Checksum read from the wire. + expected: u32, + /// Checksum recomputed locally. + computed: u32, + }, + /// `authorization_mode` byte did not match a known variant. + #[error("unknown authorization_mode byte: {0}")] + UnknownAuthorizationMode(u8), + /// The trailer's `candidate_count_repeat` did not match the header's + /// `candidate_count`. + #[error("candidate_count mismatch: header {header}, trailer {trailer}")] + CandidateCountMismatch { + /// Value from the header. + header: u64, + /// Value from the trailer. + trailer: u64, + }, +} + +/// Manifest header (design-doc §11.1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManifestHeader { + /// Wire format version. + pub format_version: u16, + /// Job identifier (UUID bytes). + pub job_id: [u8; 16], + /// Source identifier (UUID bytes). + pub source_id: [u8; 16], + /// NTFS volume serial number. + pub volume_serial: u64, + /// Opaque volume GUID bytes. + pub volume_guid: Vec, + /// Opaque VSS snapshot identifier bytes. + pub snapshot_id: Vec, + /// Snapshot creation time, Unix milliseconds. + pub snapshot_created_unix_ms: i64, + /// Digest of the UFFS query that produced this candidate set. + pub query_digest: Digest, + /// Authorization model this job was authorized under. + pub authorization_mode: AuthorizationMode, + /// Total number of candidate records in the manifest. + pub candidate_count: u64, + /// Total byte length of the record section following this header. + pub record_section_length: u64, +} + +/// Bytes preceding the checksummed/length-counted header content: +/// 4 (magic) + 2 (`format_version`) + 2 (`header_length`). +const HEADER_PREFIX_LEN: usize = 8; + +impl ManifestHeader { + /// Encode this header, computing `header_length` and + /// `header_checksum` automatically. + /// + /// # Errors + /// + /// Returns `Err` if the encoded header would exceed `u16::MAX` bytes + /// (only possible with an implausibly large `volume_guid`/`snapshot_id`). + pub fn encode(&self) -> Result, ManifestError> { + let mut content = Vec::new(); + content.extend_from_slice(&self.job_id); + content.extend_from_slice(&self.source_id); + write_u64_le(&mut content, self.volume_serial); + write_bytes_u16_prefixed(&mut content, &self.volume_guid); + write_bytes_u16_prefixed(&mut content, &self.snapshot_id); + write_i64_le(&mut content, self.snapshot_created_unix_ms); + content.extend_from_slice(&self.query_digest); + content.push(self.authorization_mode.encode()); + write_u64_le(&mut content, self.candidate_count); + write_u64_le(&mut content, self.record_section_length); + + // `header_length` covers exactly the bytes `decode` measures as + // `consumed` (magic..record_section_length) — it must NOT include + // the trailing `header_checksum`, which `decode` reads and + // verifies separately, after computing `consumed`. + let header_length_value = HEADER_PREFIX_LEN.checked_add(content.len()).ok_or( + ManifestError::HeaderLengthMismatch { + declared: 0, + actual: usize::MAX, + }, + )?; + let header_length = u16::try_from(header_length_value).map_err(|_err| { + ManifestError::HeaderLengthMismatch { + declared: u16::MAX, + actual: header_length_value, + } + })?; + + let mut checked = Vec::with_capacity(header_length_value + 4); + checked.extend_from_slice(&MANIFEST_MAGIC); + write_u16_le(&mut checked, self.format_version); + write_u16_le(&mut checked, header_length); + checked.extend_from_slice(&content); + let checksum = checksum32(&checked); + + let mut out = checked; + write_u32_le(&mut out, checksum); + Ok(out) + } + + /// Decode a manifest header from `reader`. + /// + /// # Errors + /// + /// See [`ManifestError`] variants: bad magic, length/checksum + /// mismatch, unknown authorization mode, or an underlying bounds + /// failure. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let start = reader.position(); + + let magic: [u8; 4] = reader.read_array()?; + if magic != MANIFEST_MAGIC { + return Err(ManifestError::BadMagic { + expected: MANIFEST_MAGIC, + actual: magic, + }); + } + let format_version = reader.read_u16_le()?; + let header_length = reader.read_u16_le()?; + + let job_id: [u8; 16] = reader.read_array()?; + let source_id: [u8; 16] = reader.read_array()?; + let volume_serial = reader.read_u64_le()?; + let volume_guid = reader.read_bytes_u16_prefixed("volume_guid", MAX_IDENTIFIER_BYTES)?; + let snapshot_id = reader.read_bytes_u16_prefixed("snapshot_id", MAX_IDENTIFIER_BYTES)?; + let snapshot_created_unix_ms = reader.read_i64_le()?; + let query_digest: Digest = reader.read_array()?; + let authorization_byte = reader.read_u8()?; + let authorization_mode = AuthorizationMode::decode(authorization_byte) + .map_err(ManifestError::UnknownAuthorizationMode)?; + let candidate_count = reader.read_u64_le()?; + let record_section_length = reader.read_u64_le()?; + + let end = reader.position(); + let consumed = end - start; + if consumed != header_length as usize { + return Err(ManifestError::HeaderLengthMismatch { + declared: header_length, + actual: consumed, + }); + } + + let expected_checksum = reader.read_u32_le()?; + let header_bytes = + reader + .full_buffer() + .get(start..end) + .ok_or(ManifestError::HeaderLengthMismatch { + declared: header_length, + actual: consumed, + })?; + let computed_checksum = checksum32(header_bytes); + if expected_checksum != computed_checksum { + return Err(ManifestError::ChecksumMismatch { + expected: expected_checksum, + computed: computed_checksum, + }); + } + + Ok(Self { + format_version, + job_id, + source_id, + volume_serial, + volume_guid, + snapshot_id, + snapshot_created_unix_ms, + query_digest, + authorization_mode, + candidate_count, + record_section_length, + }) + } +} + +/// One candidate manifest record (design-doc §11.2/§5.2/§5.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateRecord { + /// Unique identifier for this candidate within this job. + pub candidate_id: u64, + /// Full NTFS file reference (MFT index + sequence number packed per + /// the platform's native 64-bit file-ID layout) — never a bare MFT + /// record index (design-doc §5.2, enterprise-review Finding C4). + pub file_reference: u64, + /// Logical size at snapshot time. + pub logical_size: u64, + /// Valid Data Length at snapshot time. + pub valid_data_length: u64, + /// Modification time, Unix milliseconds. + pub mtime_unix_ms: i64, + /// Planning-hint flags (design-doc §11.4). + pub candidate_flags: CandidateFlags, + /// Lossless Windows path. + pub path: WindowsPath, +} + +/// Bytes contributed by the fixed-width fields preceding the +/// variable-length path: `candidate_id`(8) + `file_reference`(8) + +/// `logical_size`(8) + `valid_data_length`(8) + `mtime_unix_ms`(8) + +/// `candidate_flags`(4) = 44. Does not include `record_length` (4) or +/// `record_checksum` (4), which are added separately below. +const RECORD_FIXED_CONTENT_LEN: usize = 44; + +impl CandidateRecord { + /// Encode this record, computing `record_length` and + /// `record_checksum` automatically. + /// + /// # Errors + /// + /// Returns `Err` if the encoded record would exceed `u32::MAX` bytes. + pub fn encode(&self) -> Result, ManifestError> { + let mut content = Vec::with_capacity(RECORD_FIXED_CONTENT_LEN); + write_u64_le(&mut content, self.candidate_id); + write_u64_le(&mut content, self.file_reference); + write_u64_le(&mut content, self.logical_size); + write_u64_le(&mut content, self.valid_data_length); + write_i64_le(&mut content, self.mtime_unix_ms); + write_u32_le(&mut content, self.candidate_flags.bits()); + self.path.encode(&mut content); + + // `record_length` covers exactly the bytes `decode` measures as + // `consumed` (record_length field itself..end of path) — it must + // NOT include the trailing `record_checksum`, which `decode` + // reads and verifies separately, after computing `consumed`. + let record_length_value = content + .len() + .checked_add(4) // + record_length field itself + .ok_or(ManifestError::RecordLengthMismatch { + declared: 0, + actual: usize::MAX, + })?; + let record_length = u32::try_from(record_length_value).map_err(|_err| { + ManifestError::RecordLengthMismatch { + declared: u32::MAX, + actual: record_length_value, + } + })?; + + let mut checked = Vec::with_capacity(record_length_value + 4); + write_u32_le(&mut checked, record_length); + checked.extend_from_slice(&content); + let checksum = checksum32(&checked); + + let mut out = checked; + write_u32_le(&mut out, checksum); + Ok(out) + } + + /// Decode a candidate record from `reader`. + /// + /// # Errors + /// + /// See [`ManifestError`] variants: length/checksum mismatch, a + /// malformed path, or an underlying bounds failure. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let start = reader.position(); + let record_length = reader.read_u32_le()?; + + let candidate_id = reader.read_u64_le()?; + let file_reference = reader.read_u64_le()?; + let logical_size = reader.read_u64_le()?; + let valid_data_length = reader.read_u64_le()?; + let mtime_unix_ms = reader.read_i64_le()?; + let flags_bits = reader.read_u32_le()?; + let candidate_flags = CandidateFlags::from_bits_truncate(flags_bits); + let path = WindowsPath::decode(reader, MAX_PATH_BYTES)?; + + let end = reader.position(); + let consumed = end - start; + if consumed != record_length as usize { + return Err(ManifestError::RecordLengthMismatch { + declared: record_length, + actual: consumed, + }); + } + + let expected_checksum = reader.read_u32_le()?; + let record_bytes = + reader + .full_buffer() + .get(start..end) + .ok_or(ManifestError::RecordLengthMismatch { + declared: record_length, + actual: consumed, + })?; + let computed_checksum = checksum32(record_bytes); + if expected_checksum != computed_checksum { + return Err(ManifestError::ChecksumMismatch { + expected: expected_checksum, + computed: computed_checksum, + }); + } + + Ok(Self { + candidate_id, + file_reference, + logical_size, + valid_data_length, + mtime_unix_ms, + candidate_flags, + path, + }) + } +} + +/// Manifest trailer (design-doc §11.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ManifestTrailer { + /// Repeats the header's `candidate_count`, so a streaming consumer + /// can validate completeness without holding the header in memory. + pub candidate_count_repeat: u64, + /// BLAKE3 digest of the entire manifest (header + record section) + /// preceding this trailer. + pub manifest_digest: Digest, +} + +impl ManifestTrailer { + /// Encode this trailer. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_count_repeat); + out.extend_from_slice(&self.manifest_digest); + out.extend_from_slice(&MANIFEST_END_MAGIC); + out + } + + /// Decode a trailer from `reader`. + /// + /// # Errors + /// + /// [`ManifestError::BadMagic`] if `end_magic` does not match, or an + /// underlying bounds failure. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_count_repeat = reader.read_u64_le()?; + let manifest_digest: Digest = reader.read_array()?; + let end_magic: [u8; 4] = reader.read_array()?; + if end_magic != MANIFEST_END_MAGIC { + return Err(ManifestError::BadMagic { + expected: MANIFEST_END_MAGIC, + actual: end_magic, + }); + } + Ok(Self { + candidate_count_repeat, + manifest_digest, + }) + } + + /// Compute the trailer's `manifest_digest` over `manifest_bytes` + /// (header + record section, exactly as they appear on the wire + /// preceding the trailer). + #[must_use] + pub fn compute_digest(manifest_bytes: &[u8]) -> Digest { + digest(manifest_bytes) + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::{ + AuthorizationMode, CandidateFlags, CandidateRecord, ManifestError, ManifestHeader, + ManifestTrailer, + }; + use crate::codec::Reader; + use crate::path_encoding::WindowsPath; + + fn sample_header() -> ManifestHeader { + ManifestHeader { + format_version: 2, + job_id: [1_u8; 16], + source_id: [2_u8; 16], + volume_serial: 0x1234_5678_9ABC_DEF0, + volume_guid: b"{11111111-2222-3333-4444-555555555555}".to_vec(), + snapshot_id: b"snap-0001".to_vec(), + snapshot_created_unix_ms: 1_752_000_000_000, + query_digest: [7_u8; 32], + authorization_mode: AuthorizationMode::AdminExport, + candidate_count: 3, + record_section_length: 999, + } + } + + fn sample_record(candidate_id: u64) -> CandidateRecord { + CandidateRecord { + candidate_id, + file_reference: 0xABCD_EF01_2345_6789, + logical_size: 4096, + valid_data_length: 4096, + mtime_unix_ms: 1_752_000_000_000, + candidate_flags: CandidateFlags::NONRESIDENT | CandidateFlags::LARGE_FILE, + path: WindowsPath::from_str_lossless(r"C:\Users\robert\data\file.bin"), + } + } + + #[test] + fn header_round_trips() { + let header = sample_header(); + let bytes = header.encode().unwrap(); + let mut reader = Reader::new(&bytes); + let decoded = ManifestHeader::decode(&mut reader).unwrap(); + assert_eq!(decoded, header); + assert_eq!( + reader.remaining(), + 0, + "decode must consume the whole header" + ); + } + + #[test] + #[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" + )] + fn header_rejects_bad_magic() { + let header = sample_header(); + let mut bytes = header.encode().unwrap(); + bytes[0] = b'X'; + let mut reader = Reader::new(&bytes); + let err = ManifestHeader::decode(&mut reader).unwrap_err(); + assert!(matches!(err, ManifestError::BadMagic { .. })); + } + + #[test] + #[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" + )] + fn header_rejects_flipped_byte_via_checksum() { + let header = sample_header(); + let mut bytes = header.encode().unwrap(); + // Flip a byte inside the checksummed region (well past the magic + // and length fields, inside `job_id`). + let flip_index = 10; + bytes[flip_index] ^= 0xFF; + let mut reader = Reader::new(&bytes); + let err = ManifestHeader::decode(&mut reader).unwrap_err(); + assert!(matches!(err, ManifestError::ChecksumMismatch { .. })); + } + + #[test] + fn header_rejects_unknown_authorization_mode() { + let header = sample_header(); + let bytes = header.encode().unwrap(); + // Re-encode is awkward to mutate mid-structure directly since the + // checksum covers it; instead, hand-verify the decode path + // rejects an out-of-range byte by constructing a header with a + // manually patched authorization byte and a recomputed checksum + // would require re-implementing encode. Simplest robust check: + // AuthorizationMode::decode itself rejects out-of-range bytes, + // exercised directly. + assert_eq!(AuthorizationMode::decode(2), Err(2)); + // Sanity: the real header still round-trips (guards against a + // future refactor accidentally breaking the happy path while + // "testing" the unhappy one above). + let mut reader = Reader::new(&bytes); + ManifestHeader::decode(&mut reader).unwrap(); + } + + #[test] + fn record_round_trips() { + let record = sample_record(42); + let bytes = record.encode().unwrap(); + let mut reader = Reader::new(&bytes); + let decoded = CandidateRecord::decode(&mut reader).unwrap(); + assert_eq!(decoded, record); + assert_eq!( + reader.remaining(), + 0, + "decode must consume the whole record" + ); + } + + #[test] + #[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" + )] + fn record_rejects_flipped_byte_via_checksum() { + let record = sample_record(1); + let mut bytes = record.encode().unwrap(); + let last = bytes.len() - 5; // inside the record body, before the trailing checksum + bytes[last] ^= 0xFF; + let mut reader = Reader::new(&bytes); + let err = CandidateRecord::decode(&mut reader).unwrap_err(); + assert!(matches!(err, ManifestError::ChecksumMismatch { .. })); + } + + #[test] + fn record_flags_round_trip_bit_pattern() { + let mut record = sample_record(7); + record.candidate_flags = CandidateFlags::RESIDENT + | CandidateFlags::SPARSE + | CandidateFlags::MANUAL_HANDLER_LIKELY; + let bytes = record.encode().unwrap(); + let mut reader = Reader::new(&bytes); + let decoded = CandidateRecord::decode(&mut reader).unwrap(); + assert_eq!(decoded.candidate_flags, record.candidate_flags); + } + + #[test] + fn trailer_round_trips() { + let trailer = ManifestTrailer { + candidate_count_repeat: 3, + manifest_digest: [9_u8; 32], + }; + let bytes = trailer.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ManifestTrailer::decode(&mut reader).unwrap(); + assert_eq!(decoded, trailer); + } + + #[test] + #[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" + )] + fn trailer_rejects_bad_end_magic() { + let trailer = ManifestTrailer { + candidate_count_repeat: 1, + manifest_digest: [0_u8; 32], + }; + let mut bytes = trailer.encode(); + let last_index = bytes.len() - 1; + bytes[last_index] = b'?'; + let mut reader = Reader::new(&bytes); + let err = ManifestTrailer::decode(&mut reader).unwrap_err(); + assert!(matches!(err, ManifestError::BadMagic { .. })); + } + + #[test] + fn full_manifest_round_trip_with_multiple_candidates() { + // End-to-end: header + N records + trailer, exactly the shape a + // real manifest file has on disk, decoded back sequentially from + // one contiguous buffer. + let candidates: Vec = (0..5).map(sample_record).collect(); + + let mut record_bytes = Vec::new(); + for candidate in &candidates { + record_bytes.extend_from_slice(&candidate.encode().unwrap()); + } + + let mut header = sample_header(); + header.candidate_count = candidates.len() as u64; + header.record_section_length = record_bytes.len() as u64; + let mut manifest_bytes = header.encode().unwrap(); + manifest_bytes.extend_from_slice(&record_bytes); + + let trailer = ManifestTrailer { + candidate_count_repeat: header.candidate_count, + manifest_digest: ManifestTrailer::compute_digest(&manifest_bytes), + }; + manifest_bytes.extend_from_slice(&trailer.encode()); + + // Now decode the whole thing back. + let mut reader = Reader::new(&manifest_bytes); + let decoded_header = ManifestHeader::decode(&mut reader).unwrap(); + assert_eq!(decoded_header, header); + + let mut decoded_candidates = Vec::new(); + for _ in 0..decoded_header.candidate_count { + decoded_candidates.push(CandidateRecord::decode(&mut reader).unwrap()); + } + assert_eq!(decoded_candidates, candidates); + + let decoded_trailer = ManifestTrailer::decode(&mut reader).unwrap(); + assert_eq!( + decoded_trailer.candidate_count_repeat, + header.candidate_count + ); + assert_eq!( + reader.remaining(), + 0, + "trailer must be the last thing in the manifest" + ); + + // Completeness invariant sanity: every decoded candidate_id is + // unique and matches what was encoded (design-doc §21.7). + let mut ids: Vec = decoded_candidates + .iter() + .map(|candidate| candidate.candidate_id) + .collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!( + ids.len(), + decoded_candidates.len(), + "candidate_id must be unique" + ); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + #[test] + fn candidate_record_round_trips_for_arbitrary_fields( + candidate_id: u64, + file_reference: u64, + logical_size: u64, + valid_data_length: u64, + mtime_unix_ms: i64, + flags_bits: u32, + path_str in "[a-zA-Z0-9_/\\\\:. ]{0,200}", + ) { + let record = CandidateRecord { + candidate_id, + file_reference, + logical_size, + valid_data_length, + mtime_unix_ms, + candidate_flags: CandidateFlags::from_bits_truncate(flags_bits), + path: WindowsPath::from_str_lossless(&path_str), + }; + let bytes = record.encode().unwrap(); + let mut reader = Reader::new(&bytes); + let decoded = CandidateRecord::decode(&mut reader).unwrap(); + prop_assert_eq!(decoded, record); + } + } +} diff --git a/crates/uffs-content-protocol/src/path_encoding.rs b/crates/uffs-content-protocol/src/path_encoding.rs new file mode 100644 index 000000000..d00c57ee5 --- /dev/null +++ b/crates/uffs-content-protocol/src/path_encoding.rs @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Lossless Windows path representation (design-doc §5.4). +//! +//! "The authoritative Windows path representation MUST be lossless. The +//! manifest stores either UTF-16LE path code units; or another explicitly +//! lossless Windows path encoding such as WTF-8. A lossy UTF-8 display +//! path MAY also be included for logs and UI, but it MUST NOT be the sole +//! identity or unique key." +//! +//! This module models the authoritative form as raw UTF-16LE code units +//! (`Vec`), because that is exactly what `GetFileInformationByHandleEx` +//! / NTFS directory entries hand back — no intermediate lossy conversion +//! ever has to happen on the producer side. A Windows path can contain +//! unpaired surrogate code units (rare, but real — some tools and legacy +//! software create them); those are the values a `String`-based +//! representation cannot hold at all, which is exactly why the wire +//! format uses raw code units instead of `String`/`OsString`. + +use crate::codec::Reader; + +/// The path-encoding discriminant carried on the wire (design-doc §11.2 +/// `path_encoding` field). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum PathEncoding { + /// Raw UTF-16LE code units, exactly as returned by the Windows API. + /// May contain unpaired surrogates. + Utf16Le = 0, +} + +impl PathEncoding { + /// Serialize to the single-byte wire representation. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the single-byte wire representation. + /// + /// # Errors + /// + /// Returns `Err` with the offending byte if it does not match a known + /// encoding. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Utf16Le), + other => Err(other), + } + } +} + +/// A lossless Windows path: raw UTF-16LE code units plus a cached lossy +/// UTF-8 display form. +/// +/// The lossy `display` string exists only for logs/UI (design-doc §5.4) +/// and MUST NOT be used as an identity key — every comparison and every +/// wire round-trip in this crate operates on `code_units`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsPath { + /// Authoritative, lossless UTF-16LE code units. + code_units: Vec, +} + +/// Maximum path length in UTF-16 code units this crate will decode. +/// +/// Windows itself has historically allowed paths well beyond `MAX_PATH` +/// (260) via the `\\?\` prefix and long-path opt-in; this is a generous +/// wire-safety bound, not a Windows API limit. +pub const MAX_PATH_CODE_UNITS: u16 = 32_767; + +impl WindowsPath { + /// Build a [`WindowsPath`] from raw UTF-16 code units (e.g. as + /// returned by a Windows API call). No validation is performed here + /// beyond what the type system already guarantees — unpaired + /// surrogates are accepted, matching real-world Windows path data. + #[must_use] + pub const fn from_code_units(code_units: Vec) -> Self { + Self { code_units } + } + + /// Build a [`WindowsPath`] from a Rust `&str`. Since `str` is + /// guaranteed valid UTF-8 (and therefore representable in UTF-16 + /// without unpaired surrogates), this conversion is always lossless. + #[must_use] + pub fn from_str_lossless(value: &str) -> Self { + Self { + code_units: value.encode_utf16().collect(), + } + } + + /// The raw UTF-16LE code units. + #[must_use] + pub fn code_units(&self) -> &[u16] { + &self.code_units + } + + /// A lossy UTF-8 display form, for logs/UI only. Unpaired surrogates + /// are replaced with U+FFFD, per [`String::from_utf16_lossy`]-equivalent + /// semantics (implemented via [`char::decode_utf16`] directly so the + /// replacement policy is explicit and testable here rather than + /// inherited implicitly from a std helper). + #[must_use] + pub fn display_lossy(&self) -> String { + char::decode_utf16(self.code_units.iter().copied()) + .map(|result| result.unwrap_or(char::REPLACEMENT_CHARACTER)) + .collect() + } + + /// Encode as a wire `(path_encoding: u8, path_length: u32, path: bytes)` + /// triple (design-doc §11.2). The length prefix counts *bytes*, not + /// code units — two bytes per UTF-16 code unit. + pub fn encode(&self, out: &mut Vec) { + out.push(PathEncoding::Utf16Le.encode()); + let mut byte_buf = Vec::with_capacity(self.code_units.len() * 2); + for unit in &self.code_units { + byte_buf.extend_from_slice(&unit.to_le_bytes()); + } + // `write_bytes_u32_prefixed` is the §11.2 layout (u32 path_length); + // reused here via the u16-style helper's u32 sibling would be + // clearer, but §11.2 explicitly specifies `path_length u32` — use + // that variant directly. + crate::codec::write_bytes_u32_prefixed(out, &byte_buf); + } + + /// Decode from a `(path_encoding, path_length, path)` wire triple. + /// + /// `max_bytes` bounds the length-prefixed path payload before any + /// allocation (Finding H10 discipline, same as every other + /// length-prefixed field in this crate). + /// + /// # Errors + /// + /// - a [`crate::codec::DecodeError`] if the bytes are truncated or the + /// declared length exceeds `max_bytes`; + /// - `Err` wrapping the raw byte if `path_encoding` is not a known + /// [`PathEncoding`] variant (surfaced as + /// [`crate::codec::DecodeError::UnknownDiscriminant`]); + /// - `Err` if the payload's byte length is odd (not a whole number of + /// UTF-16 code units). + pub fn decode(reader: &mut Reader<'_>, max_bytes: u32) -> Result { + let encoding_byte = reader.read_u8()?; + PathEncoding::decode(encoding_byte).map_err(PathDecodeError::UnsupportedEncoding)?; + let bytes = reader.read_bytes_u32_prefixed("path", max_bytes)?; + if bytes.len() % 2 != 0 { + return Err(PathDecodeError::OddByteLength(bytes.len())); + } + #[expect( + clippy::chunks_exact_to_as_chunks, + reason = "slice::as_chunks is nightly-unstable library API; adopting it would \ + require an unstable #![feature(...)] crate-root gate for one call site. \ + chunks_exact(2) is already panic-free here (see the try_into fallback \ + below), so there is no correctness reason to take on that commitment." + )] + let pairs = bytes.chunks_exact(2); + let code_units: Vec = pairs + .map(|pair| { + // `chunks_exact(2)` guarantees `pair.len() == 2`; `try_into` + // therefore never hits the fallback, but expressing it this + // way (instead of indexing) keeps this closure panic-free + // by construction rather than "panic-free because + // chunks_exact happens to guarantee it." + let array: [u8; 2] = pair.try_into().unwrap_or([0, 0]); + u16::from_le_bytes(array) + }) + .collect(); + Ok(Self { code_units }) + } +} + +/// Errors decoding a [`WindowsPath`] from the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum PathDecodeError { + /// Underlying bounds/length-prefix decode failure. + #[error(transparent)] + Decode(#[from] crate::codec::DecodeError), + /// The `path_encoding` byte did not match a known [`PathEncoding`] variant. + #[error("unsupported path encoding byte: {0}")] + UnsupportedEncoding(u8), + /// The path payload's byte length was odd (not a whole number of + /// UTF-16 code units). + #[error("path payload has odd byte length {0}, not a whole number of UTF-16 code units")] + OddByteLength(usize), +} + +#[cfg(test)] +mod tests { + use super::{PathDecodeError, PathEncoding, WindowsPath}; + use crate::codec::Reader; + + #[test] + fn ascii_path_round_trips() { + let path = WindowsPath::from_str_lossless(r"C:\Users\robert\file.txt"); + let mut buf = Vec::new(); + path.encode(&mut buf); + let mut reader = Reader::new(&buf); + let decoded = WindowsPath::decode(&mut reader, 10_000).unwrap(); + assert_eq!(decoded, path); + assert_eq!(decoded.display_lossy(), r"C:\Users\robert\file.txt"); + } + + #[test] + fn non_bmp_path_round_trips() { + // U+1F600 (😀) requires a UTF-16 surrogate pair — this is exactly + // the class of path the enterprise-review commit history flagged + // as a past UTF-16 anti-pattern bug source. + let path = WindowsPath::from_str_lossless("C:\\notes\\😀.txt"); + let mut buf = Vec::new(); + path.encode(&mut buf); + let mut reader = Reader::new(&buf); + let decoded = WindowsPath::decode(&mut reader, 10_000).unwrap(); + assert_eq!(decoded, path); + assert_eq!(decoded.display_lossy(), "C:\\notes\\😀.txt"); + } + + #[test] + fn unpaired_surrogate_round_trips_losslessly_but_displays_as_replacement_char() { + // 0xD800 is a lone high surrogate — not representable in `str` at + // all, which is exactly why the authoritative form is `Vec` + // and not `String`. It must still round-trip byte-for-byte. + let path = WindowsPath::from_code_units(vec![ + u16::from(b'C'), + u16::from(b':'), + u16::from(b'\\'), + 0xD800, + u16::from(b'x'), + ]); + let mut buf = Vec::new(); + path.encode(&mut buf); + let mut reader = Reader::new(&buf); + let decoded = WindowsPath::decode(&mut reader, 10_000).unwrap(); + assert_eq!( + decoded, path, + "lone surrogate must survive the wire round-trip exactly" + ); + assert!( + decoded.display_lossy().contains('\u{FFFD}'), + "lossy display must substitute the replacement character for the unpaired surrogate" + ); + } + + #[test] + fn empty_path_round_trips() { + let path = WindowsPath::from_code_units(vec![]); + let mut buf = Vec::new(); + path.encode(&mut buf); + let mut reader = Reader::new(&buf); + let decoded = WindowsPath::decode(&mut reader, 10_000).unwrap(); + assert_eq!(decoded.code_units(), &[] as &[u16]); + } + + #[test] + fn decode_rejects_declared_length_over_max_bytes() { + let path = WindowsPath::from_str_lossless(r"C:\a\long\enough\path.txt"); + let mut buf = Vec::new(); + path.encode(&mut buf); + let mut reader = Reader::new(&buf); + let err = WindowsPath::decode(&mut reader, 4).unwrap_err(); + assert!(matches!(err, PathDecodeError::Decode(_))); + } + + #[test] + fn decode_rejects_odd_byte_length_payload() { + // Hand-craft a wire triple with an odd-length payload: encoding + // byte, then a u32 length of 3, then 3 raw bytes. + let mut buf = Vec::new(); + buf.push(PathEncoding::Utf16Le.encode()); + crate::codec::write_bytes_u32_prefixed(&mut buf, &[1, 2, 3]); + let mut reader = Reader::new(&buf); + let err = WindowsPath::decode(&mut reader, 10_000).unwrap_err(); + assert_eq!(err, PathDecodeError::OddByteLength(3)); + } + + #[test] + fn decode_rejects_unknown_encoding_byte() { + let mut buf = Vec::new(); + buf.push(0xFF); // not a known PathEncoding discriminant + crate::codec::write_bytes_u32_prefixed(&mut buf, b""); + let mut reader = Reader::new(&buf); + let err = WindowsPath::decode(&mut reader, 10_000).unwrap_err(); + assert_eq!(err, PathDecodeError::UnsupportedEncoding(0xFF)); + } + + #[test] + fn path_encoding_round_trips() { + assert_eq!(PathEncoding::decode(0).unwrap(), PathEncoding::Utf16Le); + assert_eq!(PathEncoding::decode(1), Err(1)); + } +} diff --git a/crates/uffs-content-protocol/src/state.rs b/crates/uffs-content-protocol/src/state.rs index 750946121..506b12136 100644 --- a/crates/uffs-content-protocol/src/state.rs +++ b/crates/uffs-content-protocol/src/state.rs @@ -1,14 +1,27 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! Job and candidate terminal states (scaffold). +//! Job and candidate state machines (design-doc §9). /// One candidate's terminal outcome (design-doc §2.2, §9.2). /// /// Every candidate in a job's manifest MUST eventually reach exactly one /// of these states, and the job is complete only once every candidate has /// one. Only [`CandidateOutcome::Succeeded`] candidates are delivered as -/// content to the downstream consumer. +/// content to the downstream consumer — but every outcome, including the +/// three non-success ones, means the candidate is *present*, not deleted. +/// A consumer's reap/tombstone reconciliation MUST key off manifest +/// membership across all four outcomes, never off `Succeeded` alone: a +/// candidate that merely failed to read is not the same thing as a +/// candidate that no longer exists (design-doc §2.3). +/// +/// Filtering which files even become candidates (by size, extension, +/// path, etc.) is the query's job, not this enum's — a consumer that +/// wants content only for files under some size threshold expresses that +/// as a filter on the UFFS query passed into the job, the same way any +/// other UFFS search filter works. This tool produces one manifest and +/// one content stream for whatever the query matched; it does not itself +/// decide per-candidate whether to deliver a body. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CandidateOutcome { /// Content was read, streamed, and verified successfully. @@ -22,3 +35,178 @@ pub enum CandidateOutcome { /// (e.g. compressed/encrypted/reparse-backed files in v2). DeferredManual, } + +/// Job lifecycle state (design-doc §9.1). +/// +/// The legal transition graph is [`JobState::can_transition_to`] — mirrors +/// the shape of `uffs-daemon`'s `ShardState::can_transition_to` +/// (`crates/uffs-daemon/src/cache/shard.rs`), the existing reviewed +/// pattern in this codebase for a small state machine with a proptested +/// transition graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum JobState { + /// Job row created; not yet authorized/validated. + Created, + /// Broker snapshot-lease creation is in flight. + SnapshotCreating, + /// Snapshot lease is ready; candidate enumeration has not started. + SnapshotReady, + /// Evaluating the UFFS query against the snapshot to build candidates. + Enumerating, + /// The candidate manifest is finalized and checksummed (design-doc + /// §4.1 step 8) — completeness accounting starts being meaningful + /// from this state onward. + ManifestFinalized, + /// Candidates are being processed and streamed to the consumer. + Streaming, + /// All candidates have a terminal outcome; finalizing job-level + /// accounting and the `JOB_END` record. + Completing, + /// Terminal: every candidate succeeded. + Completed, + /// Terminal: every candidate reached a terminal outcome, but at least + /// one did not succeed (failed or was deferred). + CompletedWithFailures, + /// Terminal: the job was cancelled before all candidates reached a + /// terminal outcome. + Cancelled, + /// Terminal: the job failed as a whole before the manifest was + /// finalized (design-doc §4.2) — no candidate-completeness claim is + /// made. + Aborted, +} + +impl JobState { + /// Returns true iff a transition `self -> to` is in the legal graph. + /// + /// Legal transitions: + /// * `Created` -> `SnapshotCreating`, `Aborted` + /// * `SnapshotCreating` -> `SnapshotReady`, `Aborted` + /// * `SnapshotReady` -> `Enumerating`, `Aborted` + /// * `Enumerating` -> `ManifestFinalized`, `Aborted` + /// * `ManifestFinalized` -> `Streaming`, `Cancelled` + /// * `Streaming` -> `Completing`, `Cancelled` + /// * `Completing` -> `Completed`, `CompletedWithFailures`, `Cancelled` + /// * `Completed`, `CompletedWithFailures`, `Cancelled`, `Aborted` -> (none; + /// terminal) + #[must_use] + pub const fn can_transition_to(self, to: Self) -> bool { + matches!( + (self, to), + (Self::Created, Self::SnapshotCreating | Self::Aborted) + | (Self::SnapshotCreating, Self::SnapshotReady | Self::Aborted) + | (Self::SnapshotReady, Self::Enumerating | Self::Aborted) + | (Self::Enumerating, Self::ManifestFinalized | Self::Aborted) + | (Self::ManifestFinalized, Self::Streaming | Self::Cancelled) + | (Self::Streaming, Self::Completing | Self::Cancelled) + | ( + Self::Completing, + Self::Completed | Self::CompletedWithFailures | Self::Cancelled + ) + ) + } + + /// Whether this state is terminal (no further transitions are legal). + #[must_use] + pub const fn is_terminal(self) -> bool { + matches!( + self, + Self::Completed | Self::CompletedWithFailures | Self::Cancelled | Self::Aborted + ) + } + + /// All variants, for exhaustive transition-graph testing. + #[cfg(test)] + const ALL: &'static [Self] = &[ + Self::Created, + Self::SnapshotCreating, + Self::SnapshotReady, + Self::Enumerating, + Self::ManifestFinalized, + Self::Streaming, + Self::Completing, + Self::Completed, + Self::CompletedWithFailures, + Self::Cancelled, + Self::Aborted, + ]; +} + +#[cfg(test)] +mod tests { + use super::JobState; + + #[test] + fn terminal_states_have_no_legal_outgoing_transition() { + for &from in JobState::ALL { + if from.is_terminal() { + for &to in JobState::ALL { + assert!( + !from.can_transition_to(to), + "{from:?} is terminal but claims a legal transition to {to:?}" + ); + } + } + } + } + + #[test] + fn every_non_terminal_state_has_at_least_one_legal_transition() { + for &from in JobState::ALL { + if !from.is_terminal() { + let has_any = JobState::ALL.iter().any(|&to| from.can_transition_to(to)); + assert!( + has_any, + "{from:?} is non-terminal but has no legal transition out" + ); + } + } + } + + #[test] + fn every_non_terminal_state_can_reach_aborted_or_cancelled_or_terminal() { + // Every non-terminal state must have some path to a terminal + // state directly (this test checks the *direct* edge only, which + // is true by construction here — every non-terminal state's + // transition set includes at least one terminal state or a state + // one step from terminal). This guards against accidentally + // adding a state that can never resolve. + for &from in JobState::ALL { + if from.is_terminal() { + continue; + } + let reaches_terminal_or_progresses = + JobState::ALL.iter().any(|&to| from.can_transition_to(to)); + assert!( + reaches_terminal_or_progresses, + "{from:?} must be able to progress somewhere" + ); + } + } + + #[test] + fn no_state_transitions_to_itself() { + for &state in JobState::ALL { + assert!( + !state.can_transition_to(state), + "{state:?} must not self-transition" + ); + } + } + + #[test] + fn created_cannot_skip_directly_to_streaming() { + // Regression anchor: a job must pass through snapshot creation and + // enumeration before streaming — skipping straight to Streaming + // would violate the "one snapshot defines the job" invariant + // (design-doc §2.1). + assert!(!JobState::Created.can_transition_to(JobState::Streaming)); + } + + #[test] + fn manifest_finalized_can_still_be_cancelled() { + // A job may be cancelled after the manifest is finalized but + // before/while streaming (design-doc §19.1). + assert!(JobState::ManifestFinalized.can_transition_to(JobState::Cancelled)); + } +} From 8d8c12fc9a4633579683000bcee32ce8ae4b6308 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:59:44 -0700 Subject: [PATCH 07/98] feat(content-protocol): frame envelope + all 12 frame payload types (UFI.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the framed content/control stream per design-doc S12: FrameEnvelope (magic/version/type/flags/checksums, payload bounds-checked before allocation) plus JobBegin, FileBegin, ContentChunk, FileEnd, FileFailed, FileDeferred, FileAck, JobEnd, Progress, Heartbeat, JobCancel, and WindowUpdate. Split into crates/uffs-content-protocol/src/frame/{mod, job_begin,file_begin,content_chunk,file_end,file_failed,file_deferred, file_ack,job_end,control}.rs plus a sibling tests.rs (matching the compact_cache.rs / tests.rs convention) — a single frame.rs file would have tripped the 800-LOC file-size gate. Incorporates one deliberate protocol extension beyond the base spec, driven by concrete downstream (Docenta) consumer feedback: a two-tier query/delivery model. Candidate-match filters (ext/date/size-min, same shape as existing UFFS CLI filters) determine the full candidate set, independent of a separate content-delivery ceiling (JobBegin.max_content_delivery_bytes) that controls which already-matched candidates get a body streamed. Modeled as ReadMode::MetadataOnly + FileEnd.content_digest: Option rather than a new outcome variant, so the addendum's already-reviewed 4-way completeness formula (candidate_count = succeeded + failed_retryable + failed_terminal + deferred_manual) is untouched — a MetadataOnly file is still Succeeded, just without a delivered body. This keeps large-file metadata available for a consumer's reap/tombstone reconciliation without transferring bytes the consumer would only record as metadata anyway. Adds codec::Reader::read_bytes_exact for payload reads whose length was already validated via a separately-encoded field (frame envelope), as opposed to a wire length-prefix. 31 new tests (108 total in the crate), clean under lint-prod + lint-tests + the file-size policy gate. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-protocol/src/codec.rs | 16 + .../src/frame/content_chunk.rs | 63 ++ .../src/frame/control.rs | 133 ++++ .../src/frame/file_ack.rs | 71 ++ .../src/frame/file_begin.rs | 79 +++ .../src/frame/file_deferred.rs | 75 ++ .../src/frame/file_end.rs | 93 +++ .../src/frame/file_failed.rs | 135 ++++ .../src/frame/job_begin.rs | 130 ++++ .../src/frame/job_end.rs | 132 ++++ crates/uffs-content-protocol/src/frame/mod.rs | 649 ++++++++++++++++++ .../uffs-content-protocol/src/frame/tests.rs | 517 ++++++++++++++ crates/uffs-content-protocol/src/lib.rs | 1 + 13 files changed, 2094 insertions(+) create mode 100644 crates/uffs-content-protocol/src/frame/content_chunk.rs create mode 100644 crates/uffs-content-protocol/src/frame/control.rs create mode 100644 crates/uffs-content-protocol/src/frame/file_ack.rs create mode 100644 crates/uffs-content-protocol/src/frame/file_begin.rs create mode 100644 crates/uffs-content-protocol/src/frame/file_deferred.rs create mode 100644 crates/uffs-content-protocol/src/frame/file_end.rs create mode 100644 crates/uffs-content-protocol/src/frame/file_failed.rs create mode 100644 crates/uffs-content-protocol/src/frame/job_begin.rs create mode 100644 crates/uffs-content-protocol/src/frame/job_end.rs create mode 100644 crates/uffs-content-protocol/src/frame/mod.rs create mode 100644 crates/uffs-content-protocol/src/frame/tests.rs diff --git a/crates/uffs-content-protocol/src/codec.rs b/crates/uffs-content-protocol/src/codec.rs index 129fe5953..bb0f83c52 100644 --- a/crates/uffs-content-protocol/src/codec.rs +++ b/crates/uffs-content-protocol/src/codec.rs @@ -253,6 +253,22 @@ impl<'a> Reader<'a> { let bytes = self.take(len as usize)?; Ok(bytes.to_vec()) } + + /// Read exactly `len` raw bytes with **no** length prefix on the + /// wire — for callers whose length already came from elsewhere (e.g. + /// [`crate::frame::FrameEnvelope`]'s separately-encoded + /// `payload_length` field). The caller is responsible for having + /// already bounds-checked `len` against its own maximum; this method + /// only guarantees `len` does not exceed the bytes actually + /// remaining. + /// + /// # Errors + /// + /// [`DecodeError::Truncated`] if fewer than `len` bytes remain. + pub fn read_bytes_exact(&mut self, len: usize) -> Result, DecodeError> { + let bytes = self.take(len)?; + Ok(bytes.to_vec()) + } } /// Append a little-endian `u16` to `out`. diff --git a/crates/uffs-content-protocol/src/frame/content_chunk.rs b/crates/uffs-content-protocol/src/frame/content_chunk.rs new file mode 100644 index 000000000..779726bef --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/content_chunk.rs @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `CONTENT_CHUNK` payload (design-doc §12.5). + +use super::FrameError; +use crate::codec::{Reader, write_bytes_u32_prefixed, write_u64_le}; + +// ───────────────────────── CONTENT_CHUNK (§12.5) ───────────────────────── + +/// `CONTENT_CHUNK` payload. +/// +/// Rules (design-doc §12.5): chunks are bounded; `logical_offset` for one +/// file increases monotonically; `payload` is raw logical file bytes in +/// v2; no whole-file buffering is implied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentChunk { + /// Candidate this chunk belongs to. + pub candidate_id: u64, + /// File-local chunk sequence number. + pub chunk_sequence: u64, + /// Logical byte offset of this chunk within the file. + pub logical_offset: u64, + /// Logical length of this chunk (matches `payload.len()`). + pub logical_length: u64, + /// Raw logical file bytes for this chunk. + pub payload: Vec, +} + +impl ContentChunk { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + write_u64_le(&mut out, self.chunk_sequence); + write_u64_le(&mut out, self.logical_offset); + write_u64_le(&mut out, self.logical_length); + write_bytes_u32_prefixed(&mut out, &self.payload); + out + } + + /// Decode this payload. + /// + /// `max_payload_bytes` bounds the chunk payload before allocation. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>, max_payload_bytes: u32) -> Result { + let candidate_id = reader.read_u64_le()?; + let chunk_sequence = reader.read_u64_le()?; + let logical_offset = reader.read_u64_le()?; + let logical_length = reader.read_u64_le()?; + let payload = reader.read_bytes_u32_prefixed("chunk_payload", max_payload_bytes)?; + Ok(Self { + candidate_id, + chunk_sequence, + logical_offset, + logical_length, + payload, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/control.rs b/crates/uffs-content-protocol/src/frame/control.rs new file mode 100644 index 000000000..df82c3ba6 --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/control.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `PROGRESS`, `HEARTBEAT`, `JOB_CANCEL`, and `WINDOW_UPDATE` payloads +//! (design-doc §12.2). + +use super::{FrameError, read_message, write_message}; +use crate::codec::{Reader, write_u64_le}; + +// ───────────────────────── PROGRESS / HEARTBEAT / control +// ───────────────────────── + +/// `PROGRESS` payload (design-doc §20.1 job/throughput metrics). Field +/// set is this crate's own choice — the spec names the metric categories +/// but not a fixed wire layout for this frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + /// Candidates discovered so far (manifest may still be enumerating). + pub candidates_discovered: u64, + /// Candidates that have reached a terminal outcome. + pub candidates_completed: u64, + /// Logical bytes successfully emitted so far. + pub logical_bytes_emitted: u64, + /// Total error count (failed + deferred) so far. + pub error_count: u64, +} + +impl Progress { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidates_discovered); + write_u64_le(&mut out, self.candidates_completed); + write_u64_le(&mut out, self.logical_bytes_emitted); + write_u64_le(&mut out, self.error_count); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self { + candidates_discovered: reader.read_u64_le()?, + candidates_completed: reader.read_u64_le()?, + logical_bytes_emitted: reader.read_u64_le()?, + error_count: reader.read_u64_le()?, + }) + } +} + +/// `HEARTBEAT` payload: empty. Its purpose is solely the frame envelope +/// arriving at all (design-doc §12.2 "prevents an idle long-file +/// operation from looking dead"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Heartbeat; + +impl Heartbeat { + /// Encode this payload (always empty). + #[must_use] + #[expect( + clippy::unused_self, + reason = "kept as an instance method for API uniformity with every \ + other frame payload's encode(self/&self) -> Vec shape, \ + even though this particular payload carries no fields" + )] + pub const fn encode(self) -> Vec { + Vec::new() + } + + /// Decode this payload (always succeeds; ignores any bytes present). + #[must_use] + pub const fn decode() -> Self { + Self + } +} + +/// `JOB_CANCEL` payload, sent by the consumer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobCancel { + /// Human-readable cancellation reason. + pub reason: String, +} + +impl JobCancel { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_message(&mut out, &self.reason); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self { + reason: read_message(reader)?, + }) + } +} + +/// `WINDOW_UPDATE` payload, sent by the consumer to grant additional +/// backpressure budget (design-doc §13.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowUpdate { + /// Additional bytes the producer may now have unacknowledged/in-flight. + pub additional_window_bytes: u64, +} + +impl WindowUpdate { + /// Encode this payload. + #[must_use] + pub fn encode(self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.additional_window_bytes); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self { + additional_window_bytes: reader.read_u64_le()?, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/file_ack.rs b/crates/uffs-content-protocol/src/frame/file_ack.rs new file mode 100644 index 000000000..62279d448 --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/file_ack.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `FILE_ACK` payload (design-doc §12.9). + +use super::{ConsumerAckStatus, FrameError, read_message, write_message}; +use crate::codec::{Digest, Reader, write_u64_le}; + +// ───────────────────────── FILE_ACK (§12.9) ───────────────────────── + +/// `FILE_ACK` payload, sent by the consumer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileAck { + /// Candidate being acknowledged. + pub candidate_id: u64, + /// Digest the consumer computed for the received content, for + /// `job_id + candidate_id + content_digest` idempotency (design-doc + /// §9.4). + pub content_digest: Digest, + /// Whether the consumer accepted or rejected the file. + pub consumer_status: ConsumerAckStatus, + /// Consumer-side error code, if rejected. + pub consumer_error_code: Option, +} + +impl FileAck { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + out.extend_from_slice(&self.content_digest); + out.push(self.consumer_status.encode()); + match &self.consumer_error_code { + Some(code) => { + out.push(1); + write_message(&mut out, code); + } + None => out.push(0), + } + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_id = reader.read_u64_le()?; + let content_digest: Digest = reader.read_array()?; + let status_byte = reader.read_u8()?; + let consumer_status = ConsumerAckStatus::decode(status_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "consumer_status", + value: u64::from(byte), + } + })?; + let error_present = reader.read_u8()?; + let consumer_error_code = if error_present == 0 { + None + } else { + Some(read_message(reader)?) + }; + Ok(Self { + candidate_id, + content_digest, + consumer_status, + consumer_error_code, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/file_begin.rs b/crates/uffs-content-protocol/src/frame/file_begin.rs new file mode 100644 index 000000000..79e7eddae --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/file_begin.rs @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `FILE_BEGIN` payload (design-doc §12.4). + +use super::{FrameError, ReadMode, read_optional_u64, write_optional_u64}; +use crate::codec::{Reader, write_i64_le, write_u32_le, write_u64_le}; +use crate::path_encoding::WindowsPath; + +// ───────────────────────── FILE_BEGIN (§12.4) ───────────────────────── + +/// `FILE_BEGIN` payload. Does not imply success (design-doc §12.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileBegin { + /// Candidate identifier. + pub candidate_id: u64, + /// Full NTFS file reference. + pub file_reference: u64, + /// Lossless Windows path. + pub path: WindowsPath, + /// Logical size at snapshot time. + pub logical_size: u64, + /// Modification time, Unix milliseconds. + pub mtime: i64, + /// Read mode selected for this attempt. + pub read_mode: ReadMode, + /// 1-based attempt number for this candidate within the job. + pub attempt_number: u32, + /// Optional shared content-object identifier (design-doc §5.3: hard + /// links may share one emitted content body). + pub content_object_id: Option, +} + +impl FileBegin { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + write_u64_le(&mut out, self.file_reference); + self.path.encode(&mut out); + write_u64_le(&mut out, self.logical_size); + write_i64_le(&mut out, self.mtime); + out.push(self.read_mode.encode()); + write_u32_le(&mut out, self.attempt_number); + write_optional_u64(&mut out, self.content_object_id); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_id = reader.read_u64_le()?; + let file_reference = reader.read_u64_le()?; + let path = WindowsPath::decode(reader, crate::manifest::MAX_PATH_BYTES)?; + let logical_size = reader.read_u64_le()?; + let mtime = reader.read_i64_le()?; + let read_mode_byte = reader.read_u8()?; + let read_mode = + ReadMode::decode(read_mode_byte).map_err(|byte| FrameError::UnknownDiscriminant { + field: "read_mode", + value: u64::from(byte), + })?; + let attempt_number = reader.read_u32_le()?; + let content_object_id = read_optional_u64(reader)?; + Ok(Self { + candidate_id, + file_reference, + path, + logical_size, + mtime, + read_mode, + attempt_number, + content_object_id, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/file_deferred.rs b/crates/uffs-content-protocol/src/frame/file_deferred.rs new file mode 100644 index 000000000..9cf2d981e --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/file_deferred.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `FILE_DEFERRED` payload (design-doc §12.8). + +use core::str::FromStr as _; + +use super::{FrameError, read_message, write_message}; +use crate::codec::{Reader, write_bytes_u16_prefixed, write_u64_le}; + +// ───────────────────────── FILE_DEFERRED (§12.8) ───────────────────────── + +/// `FILE_DEFERRED` payload. No content body is considered successful +/// (design-doc §12.8). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileDeferred { + /// Candidate this terminates. + pub candidate_id: u64, + /// Stable machine-readable reason code (one of the `*_MANUAL` + /// [`crate::error::ErrorCode`] variants). + pub reason_code: crate::error::ErrorCode, + /// Optional hint for which manual handler applies. + pub manual_handler_hint: Option, + /// Human-readable diagnostic message. + pub message: String, +} + +impl FileDeferred { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + write_bytes_u16_prefixed(&mut out, self.reason_code.as_str().as_bytes()); + match &self.manual_handler_hint { + Some(hint) => { + out.push(1); + write_message(&mut out, hint); + } + None => out.push(0), + } + write_message(&mut out, &self.message); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_id = reader.read_u64_le()?; + let reason_bytes = reader.read_bytes_u16_prefixed("reason_code", 64)?; + let reason_str = String::from_utf8(reason_bytes) + .map_err(|_err| FrameError::InvalidUtf8("reason_code"))?; + let reason_code = crate::error::ErrorCode::from_str(&reason_str).map_err(|_err| { + FrameError::UnknownDiscriminant { + field: "reason_code", + value: 0, + } + })?; + let hint_present = reader.read_u8()?; + let manual_handler_hint = if hint_present == 0 { + None + } else { + Some(read_message(reader)?) + }; + let message = read_message(reader)?; + Ok(Self { + candidate_id, + reason_code, + manual_handler_hint, + message, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/file_end.rs b/crates/uffs-content-protocol/src/frame/file_end.rs new file mode 100644 index 000000000..b68021f8c --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/file_end.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `FILE_END` payload (design-doc §12.6). + +use super::{FrameError, ReadMode}; +use crate::codec::{Digest, Reader, write_u32_le, write_u64_le}; + +// ───────────────────────── FILE_END (§12.6) ───────────────────────── + +/// `FILE_END` payload: a candidate is successful only after this frame +/// (design-doc §12.6). +/// +/// `content_digest` is `None` exactly when `read_mode == +/// ReadMode::MetadataOnly` — the candidate matched the job's query but +/// exceeded its content-delivery ceiling, so no bytes were read and +/// `chunk_count` is `0`. This is still a successful outcome: the +/// candidate is validated and present in the manifest, nothing failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileEnd { + /// Candidate this terminates. + pub candidate_id: u64, + /// Total logical bytes emitted. `0` when `content_digest` is `None` + /// (the file's true size is already in `FILE_BEGIN.logical_size`). + pub total_logical_bytes: u64, + /// BLAKE3 digest over the exact emitted logical bytes, or `None` if + /// no content was delivered (see field-level docs above). + pub content_digest: Option, + /// Read mode actually used. + pub read_mode: ReadMode, + /// Number of `CONTENT_CHUNK` frames emitted for this file. + pub chunk_count: u64, + /// Elapsed time for this attempt, milliseconds. + pub elapsed_ms: u64, + /// Reserved warning bitfield; no bits defined in v2. + pub warning_flags: u32, +} + +impl FileEnd { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + write_u64_le(&mut out, self.total_logical_bytes); + match self.content_digest { + Some(digest) => { + out.push(1); + out.extend_from_slice(&digest); + } + None => out.push(0), + } + out.push(self.read_mode.encode()); + write_u64_le(&mut out, self.chunk_count); + write_u64_le(&mut out, self.elapsed_ms); + write_u32_le(&mut out, self.warning_flags); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_id = reader.read_u64_le()?; + let total_logical_bytes = reader.read_u64_le()?; + let digest_present = reader.read_u8()?; + let content_digest = if digest_present == 0 { + None + } else { + let digest: Digest = reader.read_array()?; + Some(digest) + }; + let read_mode_byte = reader.read_u8()?; + let read_mode = + ReadMode::decode(read_mode_byte).map_err(|byte| FrameError::UnknownDiscriminant { + field: "read_mode", + value: u64::from(byte), + })?; + let chunk_count = reader.read_u64_le()?; + let elapsed_ms = reader.read_u64_le()?; + let warning_flags = reader.read_u32_le()?; + Ok(Self { + candidate_id, + total_logical_bytes, + content_digest, + read_mode, + chunk_count, + elapsed_ms, + warning_flags, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/file_failed.rs b/crates/uffs-content-protocol/src/frame/file_failed.rs new file mode 100644 index 000000000..d40ffec94 --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/file_failed.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `FILE_FAILED` payload (design-doc §12.7). + +use core::str::FromStr as _; + +use super::{ + FailureStage, FrameError, RetryClass, read_message, read_optional_i64, write_message, + write_optional_i64, +}; +use crate::codec::{Reader, write_bytes_u16_prefixed, write_u64_le}; + +// ───────────────────────── FILE_FAILED (§12.7) ───────────────────────── + +/// `FILE_FAILED` outcome discriminant (design-doc §12.7): either of the +/// two failure [`crate::state::CandidateOutcome`] variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum FailedOutcome { + /// May be retried in a later job attempt. + Retryable = 0, + /// Will not succeed on retry. + Terminal = 1, +} + +impl FailedOutcome { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Retryable), + 1 => Ok(Self::Terminal), + other => Err(other), + } + } +} + +/// `FILE_FAILED` payload. Contains no successful content object +/// (design-doc §12.7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileFailed { + /// Candidate this terminates. + pub candidate_id: u64, + /// Retryable vs. terminal. + pub outcome: FailedOutcome, + /// Which stage the failure occurred at. + pub failure_stage: FailureStage, + /// Stable machine-readable error code. + pub error_code: crate::error::ErrorCode, + /// Underlying OS error code, if applicable. + pub os_error_code: Option, + /// How this failure may be retried. + pub retry_class: RetryClass, + /// Bytes emitted before the failure (consumer MUST discard them — + /// design-doc §12.7). + pub bytes_emitted_before_failure: u64, + /// Human-readable diagnostic message. + pub message: String, +} + +impl FileFailed { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_id); + out.push(self.outcome.encode()); + out.push(self.failure_stage.encode()); + write_bytes_u16_prefixed(&mut out, self.error_code.as_str().as_bytes()); + write_optional_i64(&mut out, self.os_error_code); + out.push(self.retry_class.encode()); + write_u64_le(&mut out, self.bytes_emitted_before_failure); + write_message(&mut out, &self.message); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_id = reader.read_u64_le()?; + let outcome_byte = reader.read_u8()?; + let outcome = FailedOutcome::decode(outcome_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "outcome", + value: u64::from(byte), + } + })?; + let stage_byte = reader.read_u8()?; + let failure_stage = + FailureStage::decode(stage_byte).map_err(|byte| FrameError::UnknownDiscriminant { + field: "failure_stage", + value: u64::from(byte), + })?; + let error_code_bytes = reader.read_bytes_u16_prefixed("error_code", 64)?; + let error_code_str = String::from_utf8(error_code_bytes) + .map_err(|_err| FrameError::InvalidUtf8("error_code"))?; + let error_code = crate::error::ErrorCode::from_str(&error_code_str).map_err(|_err| { + FrameError::UnknownDiscriminant { + field: "error_code", + value: 0, + } + })?; + let os_error_code = read_optional_i64(reader)?; + let retry_class_byte = reader.read_u8()?; + let retry_class = RetryClass::decode(retry_class_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "retry_class", + value: u64::from(byte), + } + })?; + let bytes_emitted_before_failure = reader.read_u64_le()?; + let message = read_message(reader)?; + Ok(Self { + candidate_id, + outcome, + failure_stage, + error_code, + os_error_code, + retry_class, + bytes_emitted_before_failure, + message, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/job_begin.rs b/crates/uffs-content-protocol/src/frame/job_begin.rs new file mode 100644 index 000000000..0c2a0fa1d --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/job_begin.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `JOB_BEGIN` payload (design-doc §12.3). + +use super::{ + ContentSemantics, DigestAlgorithm, FrameError, FrameOrdering, read_optional_u64, + write_optional_u64, +}; +use crate::codec::{ + Digest, Reader, write_bytes_u16_prefixed, write_i64_le, write_u32_le, write_u64_le, +}; + +// ───────────────────────── JOB_BEGIN (§12.3) ───────────────────────── + +/// `JOB_BEGIN` payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobBegin { + /// Job identifier. + pub job_id: [u8; 16], + /// Source identifier. + pub source_id: [u8; 16], + /// Opaque VSS snapshot identifier. + pub snapshot_id: Vec, + /// Snapshot creation time, Unix milliseconds. + pub snapshot_created_at: i64, + /// Digest of the finalized candidate manifest. + pub manifest_digest: Digest, + /// Total candidates in the manifest. + pub candidate_count: u64, + /// Authorization model this job was authorized under. + pub authorization_mode: crate::manifest::AuthorizationMode, + /// Cross-file ordering contract (fixed `NONE` in v2). + pub ordering: FrameOrdering, + /// Content semantics (fixed `UNNAMED_LOGICAL_STREAM` in v2). + pub content_semantics: ContentSemantics, + /// Digest algorithm (fixed `BLAKE3` in v2). + pub digest_algorithm: DigestAlgorithm, + /// Negotiated maximum `CONTENT_CHUNK` payload size. + pub max_chunk_bytes: u32, + /// Content-delivery ceiling: candidates whose `logical_size` exceeds + /// this are still enumerated in the manifest (for reap/metadata + /// completeness) but their body is not streamed — + /// `FILE_END.read_mode == ReadMode::MetadataOnly` for those + /// candidates. `None` means no ceiling: every matched candidate gets + /// its content delivered. This is independent of the query's own + /// candidate-match filters (ext/date/etc.) — see + /// [`ReadMode::MetadataOnly`]. + pub max_content_delivery_bytes: Option, +} + +impl JobBegin { + /// Encode this payload. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.job_id); + out.extend_from_slice(&self.source_id); + write_bytes_u16_prefixed(&mut out, &self.snapshot_id); + write_i64_le(&mut out, self.snapshot_created_at); + out.extend_from_slice(&self.manifest_digest); + write_u64_le(&mut out, self.candidate_count); + out.push(self.authorization_mode.encode()); + out.push(self.ordering.encode()); + out.push(self.content_semantics.encode()); + out.push(self.digest_algorithm.encode()); + write_u32_le(&mut out, self.max_chunk_bytes); + write_optional_u64(&mut out, self.max_content_delivery_bytes); + out + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let job_id: [u8; 16] = reader.read_array()?; + let source_id: [u8; 16] = reader.read_array()?; + let snapshot_id = + reader.read_bytes_u16_prefixed("snapshot_id", crate::manifest::MAX_IDENTIFIER_BYTES)?; + let snapshot_created_at = reader.read_i64_le()?; + let manifest_digest: Digest = reader.read_array()?; + let candidate_count = reader.read_u64_le()?; + let auth_byte = reader.read_u8()?; + let authorization_mode = + crate::manifest::AuthorizationMode::decode(auth_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "authorization_mode", + value: u64::from(byte), + } + })?; + let ordering_byte = reader.read_u8()?; + let ordering = FrameOrdering::decode(ordering_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "ordering", + value: u64::from(byte), + } + })?; + let semantics_byte = reader.read_u8()?; + let content_semantics = ContentSemantics::decode(semantics_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "content_semantics", + value: u64::from(byte), + } + })?; + let digest_algo_byte = reader.read_u8()?; + let digest_algorithm = DigestAlgorithm::decode(digest_algo_byte).map_err(|byte| { + FrameError::UnknownDiscriminant { + field: "digest_algorithm", + value: u64::from(byte), + } + })?; + let max_chunk_bytes = reader.read_u32_le()?; + let max_content_delivery_bytes = read_optional_u64(reader)?; + Ok(Self { + job_id, + source_id, + snapshot_id, + snapshot_created_at, + manifest_digest, + candidate_count, + authorization_mode, + ordering, + content_semantics, + digest_algorithm, + max_chunk_bytes, + max_content_delivery_bytes, + }) + } +} diff --git a/crates/uffs-content-protocol/src/frame/job_end.rs b/crates/uffs-content-protocol/src/frame/job_end.rs new file mode 100644 index 000000000..ae4485a1d --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/job_end.rs @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! `JOB_END` payload (design-doc §12.10). + +use super::{FrameError, JobStatus}; +use crate::codec::{Digest, Reader, write_bytes_u16_prefixed, write_u64_le}; + +// ───────────────────────── JOB_END (§12.10) ───────────────────────── + +/// `JOB_END` payload: the receiver verifies the completeness invariant +/// against these counts (design-doc §12.10, §2.2, §21.7). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobEnd { + /// Total candidates in the finalized manifest. + pub candidate_count: u64, + /// Candidates with a `SUCCEEDED` outcome. + pub succeeded_count: u64, + /// Candidates with a `FAILED_RETRYABLE` outcome. + pub failed_retryable_count: u64, + /// Candidates with a `FAILED_TERMINAL` outcome. + pub failed_terminal_count: u64, + /// Candidates with a `DEFERRED_MANUAL` outcome. + pub deferred_manual_count: u64, + /// Successful candidates the consumer has durably acknowledged. + pub acknowledged_success_count: u64, + /// Total logical bytes across all successful candidates. + pub logical_bytes_succeeded: u64, + /// Identifier of the durable failure-bucket record set for this job. + pub failure_bucket_id: Vec, + /// Digest of the finalized candidate manifest (must match `JOB_BEGIN`). + pub manifest_digest: Digest, + /// Digest of the durable outcome ledger. + pub outcome_ledger_digest: Digest, + /// Terminal job status. + pub job_status: JobStatus, +} + +impl JobEnd { + /// Encode this payload. + /// + /// # Errors + /// + /// Returns `Err` if `job_status` is not one of the four terminal + /// [`JobStatus`] variants a `JOB_END` frame may carry. + pub fn encode(&self) -> Result, FrameError> { + let job_status_byte = encode_terminal_job_status(self.job_status)?; + let mut out = Vec::new(); + write_u64_le(&mut out, self.candidate_count); + write_u64_le(&mut out, self.succeeded_count); + write_u64_le(&mut out, self.failed_retryable_count); + write_u64_le(&mut out, self.failed_terminal_count); + write_u64_le(&mut out, self.deferred_manual_count); + write_u64_le(&mut out, self.acknowledged_success_count); + write_u64_le(&mut out, self.logical_bytes_succeeded); + write_bytes_u16_prefixed(&mut out, &self.failure_bucket_id); + out.extend_from_slice(&self.manifest_digest); + out.extend_from_slice(&self.outcome_ledger_digest); + out.push(job_status_byte); + Ok(out) + } + + /// Decode this payload. + /// + /// # Errors + /// See [`FrameError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let candidate_count = reader.read_u64_le()?; + let succeeded_count = reader.read_u64_le()?; + let failed_retryable_count = reader.read_u64_le()?; + let failed_terminal_count = reader.read_u64_le()?; + let deferred_manual_count = reader.read_u64_le()?; + let acknowledged_success_count = reader.read_u64_le()?; + let logical_bytes_succeeded = reader.read_u64_le()?; + let failure_bucket_id = reader + .read_bytes_u16_prefixed("failure_bucket_id", crate::manifest::MAX_IDENTIFIER_BYTES)?; + let manifest_digest: Digest = reader.read_array()?; + let outcome_ledger_digest: Digest = reader.read_array()?; + let job_status_byte = reader.read_u8()?; + let job_status = decode_terminal_job_status(job_status_byte)?; + Ok(Self { + candidate_count, + succeeded_count, + failed_retryable_count, + failed_terminal_count, + deferred_manual_count, + acknowledged_success_count, + logical_bytes_succeeded, + failure_bucket_id, + manifest_digest, + outcome_ledger_digest, + job_status, + }) + } +} + +/// Encodes only the four terminal [`JobStatus`] variants a `JOB_END` +/// frame may legally carry — a non-terminal `JobState` reaching this +/// point would itself be a producer bug, not a wire concern. +const fn encode_terminal_job_status(status: JobStatus) -> Result { + match status { + JobStatus::Completed => Ok(0), + JobStatus::CompletedWithFailures => Ok(1), + JobStatus::Cancelled => Ok(2), + JobStatus::Aborted => Ok(3), + JobStatus::Created + | JobStatus::SnapshotCreating + | JobStatus::SnapshotReady + | JobStatus::Enumerating + | JobStatus::ManifestFinalized + | JobStatus::Streaming + | JobStatus::Completing => Err(FrameError::UnknownDiscriminant { + field: "job_status", + value: 255, + }), + } +} + +/// Decode a terminal [`JobStatus`] byte written by +/// [`encode_terminal_job_status`]. +fn decode_terminal_job_status(byte: u8) -> Result { + match byte { + 0 => Ok(JobStatus::Completed), + 1 => Ok(JobStatus::CompletedWithFailures), + 2 => Ok(JobStatus::Cancelled), + 3 => Ok(JobStatus::Aborted), + other => Err(FrameError::UnknownDiscriminant { + field: "job_status", + value: u64::from(other), + }), + } +} diff --git a/crates/uffs-content-protocol/src/frame/mod.rs b/crates/uffs-content-protocol/src/frame/mod.rs new file mode 100644 index 000000000..27c8f12db --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/mod.rs @@ -0,0 +1,649 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Frame envelope and per-frame-type payloads (design-doc §12). +//! +//! [`FrameEnvelope`] is deliberately payload-agnostic: it validates and +//! frames an opaque byte blob (checking `payload_length` against a +//! caller-supplied maximum *before* allocating, per Finding H10), and +//! hands the payload bytes back to the caller. Decoding those bytes into +//! a concrete frame (`JobBegin`, `FileEnd`, ...) is a second step, keyed +//! on [`FrameType`]. This mirrors [`crate::manifest`]'s +//! header/record split and keeps the bounds-checking chokepoint in one +//! place regardless of which of the 12 frame types is inside. + +use crate::codec::{ + Reader, checksum32, write_bytes_u16_prefixed, write_i64_le, write_u16_le, write_u32_le, + write_u64_le, +}; +use crate::path_encoding::PathDecodeError; + +mod content_chunk; +mod control; +mod file_ack; +mod file_begin; +mod file_deferred; +mod file_end; +mod file_failed; +mod job_begin; +mod job_end; + +pub use content_chunk::ContentChunk; +pub use control::{Heartbeat, JobCancel, Progress, WindowUpdate}; +pub use file_ack::FileAck; +pub use file_begin::FileBegin; +pub use file_deferred::FileDeferred; +pub use file_end::FileEnd; +pub use file_failed::{FailedOutcome, FileFailed}; +pub use job_begin::JobBegin; +pub use job_end::JobEnd; + +/// Frame envelope magic (design-doc §12.1). +pub const FRAME_MAGIC: [u8; 4] = *b"UFS2"; + +/// Bytes of the fixed envelope header preceding `header_checksum`: +/// magic(4) + `protocol_version`(2) + `frame_type`(2) + flags(4) + +/// `header_length`(4) + `payload_length`(8) + `job_id`(16) + +/// `frame_sequence`(8) = 48. +const ENVELOPE_HEADER_LEN: usize = 48; + +/// Errors decoding a frame envelope or a typed frame payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum FrameError { + /// Underlying bounds/length-prefix decode failure. + #[error(transparent)] + Decode(#[from] crate::codec::DecodeError), + /// Path field failed to decode. + #[error(transparent)] + Path(#[from] PathDecodeError), + /// Envelope magic did not match [`FRAME_MAGIC`]. + #[error("bad frame magic: {0:?}")] + BadMagic([u8; 4]), + /// The declared `header_length` did not match bytes actually consumed. + #[error("header_length mismatch: declared {declared}, actual {actual}")] + HeaderLengthMismatch { + /// Declared length from the wire. + declared: u32, + /// Bytes actually consumed decoding the fixed header. + actual: usize, + }, + /// The header checksum did not match the bytes it covers. + #[error("header checksum mismatch: expected 0x{expected:08x}, computed 0x{computed:08x}")] + HeaderChecksumMismatch { + /// Checksum read from the wire. + expected: u32, + /// Checksum recomputed locally. + computed: u32, + }, + /// The payload checksum did not match the bytes it covers. + #[error("payload checksum mismatch: expected 0x{expected:08x}, computed 0x{computed:08x}")] + PayloadChecksumMismatch { + /// Checksum read from the wire. + expected: u32, + /// Checksum recomputed locally. + computed: u32, + }, + /// `payload_length` exceeded the caller's configured + /// `max_frame_payload_bytes` (design-doc §12.1/§13.1). + #[error("payload_length {declared} exceeds max_frame_payload_bytes {max}")] + PayloadTooLarge { + /// Declared payload length from the wire. + declared: u64, + /// Caller-configured maximum. + max: u64, + }, + /// `frame_type` did not match a known [`FrameType`] discriminant. + #[error("unknown frame_type: {0}")] + UnknownFrameType(u16), + /// A string field (e.g. `message`) was not valid UTF-8. + #[error("field '{0}' is not valid UTF-8")] + InvalidUtf8(&'static str), + /// A discriminant byte did not match any known enum variant. + #[error("unknown discriminant for '{field}': {value}")] + UnknownDiscriminant { + /// Name of the field being decoded, for diagnostics. + field: &'static str, + /// The unrecognized value. + value: u64, + }, +} + +/// The 12 required frame types (design-doc §12.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +pub enum FrameType { + /// First frame of a job: query/manifest identity and negotiated limits. + JobBegin = 1, + /// Announces a candidate is about to stream; does not imply success. + FileBegin = 2, + /// One bounded chunk of a file's logical bytes. + ContentChunk = 3, + /// Terminal success for one candidate. + FileEnd = 4, + /// Terminal failure (retryable or terminal) for one candidate. + FileFailed = 5, + /// Terminal manual deferral for one candidate. + FileDeferred = 6, + /// Consumer acknowledgement of a successful file. + FileAck = 7, + /// Periodic job progress metrics. + Progress = 8, + /// Keeps an idle long-file operation from looking dead. + Heartbeat = 9, + /// Final frame of a job: totals and reconciliation. + JobEnd = 10, + /// Consumer-initiated job cancellation. + JobCancel = 11, + /// Consumer-initiated backpressure window increase. + WindowUpdate = 12, +} + +impl FrameType { + /// Serialize to the two-byte wire representation. + #[must_use] + pub const fn encode(self) -> u16 { + self as u16 + } + + /// Parse the two-byte wire representation. + /// + /// # Errors + /// + /// Returns the offending value if it does not match a known variant. + pub const fn decode(value: u16) -> Result { + match value { + 1 => Ok(Self::JobBegin), + 2 => Ok(Self::FileBegin), + 3 => Ok(Self::ContentChunk), + 4 => Ok(Self::FileEnd), + 5 => Ok(Self::FileFailed), + 6 => Ok(Self::FileDeferred), + 7 => Ok(Self::FileAck), + 8 => Ok(Self::Progress), + 9 => Ok(Self::Heartbeat), + 10 => Ok(Self::JobEnd), + 11 => Ok(Self::JobCancel), + 12 => Ok(Self::WindowUpdate), + other => Err(other), + } + } +} + +/// Frame envelope (design-doc §12.1): the fixed header every frame +/// shares, wrapping an opaque payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrameEnvelope { + /// Wire format version. + pub protocol_version: u16, + /// Which of the 12 frame types this is. + pub frame_type: FrameType, + /// Reserved bitfield; no bits defined in v2. + pub flags: u32, + /// Job this frame belongs to. + pub job_id: [u8; 16], + /// Session-global monotonic frame sequence number. + pub frame_sequence: u64, +} + +impl FrameEnvelope { + /// Encode this envelope wrapping `payload`, computing `header_length`, + /// `payload_length`, `header_checksum`, and `payload_checksum` + /// automatically. + #[must_use] + pub fn encode(&self, payload: &[u8]) -> Vec { + // Saturates rather than errors: a >16 EiB payload is not a + // realistic input this crate needs to reject gracefully, and + // saturating keeps this function infallible (no unwrap/expect, + // no manufactured error path for an unreachable case). + let payload_length = u64::try_from(payload.len()).unwrap_or(u64::MAX); + + let mut header = Vec::with_capacity(ENVELOPE_HEADER_LEN); + header.extend_from_slice(&FRAME_MAGIC); + write_u16_le(&mut header, self.protocol_version); + write_u16_le(&mut header, self.frame_type.encode()); + write_u32_le(&mut header, self.flags); + write_u32_le( + &mut header, + u32::try_from(ENVELOPE_HEADER_LEN).unwrap_or(u32::MAX), + ); + write_u64_le(&mut header, payload_length); + header.extend_from_slice(&self.job_id); + write_u64_le(&mut header, self.frame_sequence); + + let header_checksum = checksum32(&header); + let payload_checksum = checksum32(payload); + + let mut out = header; + write_u32_le(&mut out, header_checksum); + write_u32_le(&mut out, payload_checksum); + out.extend_from_slice(payload); + out + } + + /// Decode an envelope and its payload from `reader`, rejecting a + /// `payload_length` exceeding `max_payload_bytes` before allocating + /// the payload buffer (design-doc §12.1/§13.1). + /// + /// # Errors + /// + /// See [`FrameError`] variants. + pub fn decode( + reader: &mut Reader<'_>, + max_payload_bytes: u64, + ) -> Result<(Self, Vec), FrameError> { + let start = reader.position(); + + let magic: [u8; 4] = reader.read_array()?; + if magic != FRAME_MAGIC { + return Err(FrameError::BadMagic(magic)); + } + let protocol_version = reader.read_u16_le()?; + let frame_type_raw = reader.read_u16_le()?; + let frame_type = FrameType::decode(frame_type_raw).map_err(FrameError::UnknownFrameType)?; + let flags = reader.read_u32_le()?; + let header_length = reader.read_u32_le()?; + let payload_length = reader.read_u64_le()?; + let job_id: [u8; 16] = reader.read_array()?; + let frame_sequence = reader.read_u64_le()?; + + let end = reader.position(); + let consumed = end - start; + if consumed != header_length as usize { + return Err(FrameError::HeaderLengthMismatch { + declared: header_length, + actual: consumed, + }); + } + + let expected_header_checksum = reader.read_u32_le()?; + let header_bytes = + reader + .full_buffer() + .get(start..end) + .ok_or(FrameError::HeaderLengthMismatch { + declared: header_length, + actual: consumed, + })?; + let computed_header_checksum = checksum32(header_bytes); + if expected_header_checksum != computed_header_checksum { + return Err(FrameError::HeaderChecksumMismatch { + expected: expected_header_checksum, + computed: computed_header_checksum, + }); + } + + let expected_payload_checksum = reader.read_u32_le()?; + + if payload_length > max_payload_bytes { + return Err(FrameError::PayloadTooLarge { + declared: payload_length, + max: max_payload_bytes, + }); + } + let payload_len_usize = usize::try_from(payload_length).unwrap_or(usize::MAX); + let payload = reader.read_bytes_exact(payload_len_usize)?; + + let computed_payload_checksum = checksum32(&payload); + if expected_payload_checksum != computed_payload_checksum { + return Err(FrameError::PayloadChecksumMismatch { + expected: expected_payload_checksum, + computed: computed_payload_checksum, + }); + } + + Ok(( + Self { + protocol_version, + frame_type, + flags, + job_id, + frame_sequence, + }, + payload, + )) + } +} + +// ───────────────────────── shared small enums ───────────────────────── + +/// `ordering` (design-doc §12.3): fixed at `NONE` for v2, modeled as an +/// enum so a future version can add variants without breaking the field +/// shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum FrameOrdering { + /// No cross-file ordering contract (design-doc §2.4) — the only + /// value in v2. + None = 0, +} + +/// `content_semantics` (design-doc §12.3): fixed at +/// `UNNAMED_LOGICAL_STREAM` for v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum ContentSemantics { + /// Logical bytes of the unnamed/default data stream (design-doc §6.1) + /// — the only value in v2. + UnnamedLogicalStream = 0, +} + +/// `digest_algorithm` (design-doc §12.3, §15.1): fixed at `BLAKE3` for v2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum DigestAlgorithm { + /// Full-length, plain unkeyed BLAKE3-256 — see + /// [`crate::codec::digest`]'s consumer-contract note. The only value + /// in v2. + Blake3 = 0, +} + +/// `read_mode` (design-doc §6, §20.1; addendum §3.6 planner naming). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum ReadMode { + /// Read from the MFT-resident attribute value. + Resident = 0, + /// Logical open + read against the VSS snapshot namespace (the + /// default nonresident path per addendum §3). + LogicalSnapshot = 1, + /// Benchmark-gated raw runlist/extent reconstruction against the + /// snapshot (addendum §3.6) — disabled until UFI.3 approves it. + RawSnapshotAccelerator = 2, + /// The candidate matched the job's query but its content body was + /// intentionally not read or streamed, because its logical size + /// exceeds the job's separate content-delivery ceiling + /// (`JobBegin::max_content_delivery_bytes`). The candidate is still + /// present in the manifest and this `FILE_END` still reports + /// `Succeeded` — nothing failed. See [`FileEnd::content_digest`]. + /// + /// This is a deliberate two-tier design, not a producer-invented + /// policy: query filters (ext/date/size-min/etc., matching existing + /// UFFS CLI filters) determine which files become candidates at all + /// — including huge files a consumer only wants recorded as + /// metadata — while the content-delivery ceiling is a second, + /// independent knob controlling which already-matched candidates + /// actually get bodies streamed. A consumer that wants metadata for + /// every file under a root, regardless of size, but content only for + /// small ones expresses that as one job: broad query + a tight + /// delivery ceiling, not as two jobs or a query that silently drops + /// large files from the candidate set (which would break reap/ + /// tombstone completeness — see design-doc §2.3). + MetadataOnly = 3, +} + +/// `failure_stage` (design-doc §8.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum FailureStage { + /// VSS snapshot creation. + SnapshotCreate = 0, + /// VSS snapshot device open. + SnapshotOpen = 1, + /// Candidate enumeration against the snapshot. + Enumeration = 2, + /// Candidate/file identity validation. + Identity = 3, + /// Unnamed-stream resolution. + StreamResolution = 4, + /// Nonresident runlist validation. + RunlistValidation = 5, + /// The physical/logical read itself. + Read = 6, + /// Logical-byte reconstruction (VDL/EOF/sparse rules). + Reconstruction = 7, + /// Incremental digest computation. + Hash = 8, + /// Frame transport to the consumer. + Transport = 9, + /// Waiting on consumer acknowledgement. + ConsumerAck = 10, + /// An internal producer error not attributable to another stage. + Internal = 11, +} + +/// `retry_class` (design-doc §8.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum RetryClass { + /// Retry within the same job/snapshot. + RetrySameJob = 0, + /// Retry only under a new snapshot. + RetryNewSnapshot = 1, + /// Retry after an external resource condition changes. + RetryAfterResourceChange = 2, + /// Retry only via a manual/special-case handler. + RetryWithManualHandler = 3, + /// Retry only with different credentials/keys (e.g. EFS). + RetryWithCredentialOrKey = 4, + /// Not retryable. + DoNotRetry = 5, +} + +/// `consumer_status` (design-doc §12.9). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum ConsumerAckStatus { + /// Consumer validated byte count and digest successfully. + Accepted = 0, + /// Consumer rejected the file (e.g. digest mismatch on its side). + Rejected = 1, +} + +/// Terminal job status (design-doc §12.10 `job_status`). Reuses +/// [`crate::state::JobState`] rather than duplicating a second status +/// enum — `JOB_END.job_status` is always one of that machine's terminal +/// states. +pub use crate::state::JobState as JobStatus; + +impl FrameOrdering { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::None), + other => Err(other), + } + } +} + +impl ContentSemantics { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::UnnamedLogicalStream), + other => Err(other), + } + } +} + +impl DigestAlgorithm { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Blake3), + other => Err(other), + } + } +} + +impl ReadMode { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Resident), + 1 => Ok(Self::LogicalSnapshot), + 2 => Ok(Self::RawSnapshotAccelerator), + 3 => Ok(Self::MetadataOnly), + other => Err(other), + } + } +} + +impl FailureStage { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::SnapshotCreate), + 1 => Ok(Self::SnapshotOpen), + 2 => Ok(Self::Enumeration), + 3 => Ok(Self::Identity), + 4 => Ok(Self::StreamResolution), + 5 => Ok(Self::RunlistValidation), + 6 => Ok(Self::Read), + 7 => Ok(Self::Reconstruction), + 8 => Ok(Self::Hash), + 9 => Ok(Self::Transport), + 10 => Ok(Self::ConsumerAck), + 11 => Ok(Self::Internal), + other => Err(other), + } + } +} + +impl RetryClass { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::RetrySameJob), + 1 => Ok(Self::RetryNewSnapshot), + 2 => Ok(Self::RetryAfterResourceChange), + 3 => Ok(Self::RetryWithManualHandler), + 4 => Ok(Self::RetryWithCredentialOrKey), + 5 => Ok(Self::DoNotRetry), + other => Err(other), + } + } +} + +impl ConsumerAckStatus { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Accepted), + 1 => Ok(Self::Rejected), + other => Err(other), + } + } +} + +// ───────────────────────── string/option helpers ───────────────────────── + +/// Maximum byte length for a free-text `message` field. +const MAX_MESSAGE_BYTES: u16 = 4096; + +/// Append a `u16`-length-prefixed UTF-8 `message` field. +fn write_message(out: &mut Vec, message: &str) { + write_bytes_u16_prefixed(out, message.as_bytes()); +} + +/// Read and UTF-8-validate a `u16`-length-prefixed `message` field. +fn read_message(reader: &mut Reader<'_>) -> Result { + let bytes = reader.read_bytes_u16_prefixed("message", MAX_MESSAGE_BYTES)?; + String::from_utf8(bytes).map_err(|_err| FrameError::InvalidUtf8("message")) +} + +/// Append an `Option` as a presence byte followed by the value if present. +fn write_optional_i64(out: &mut Vec, value: Option) { + match value { + Some(present_value) => { + out.push(1); + write_i64_le(out, present_value); + } + None => out.push(0), + } +} + +/// Read an `Option` encoded by [`write_optional_i64`]. +fn read_optional_i64(reader: &mut Reader<'_>) -> Result, FrameError> { + let present = reader.read_u8()?; + match present { + 0 => Ok(None), + _ => Ok(Some(reader.read_i64_le()?)), + } +} + +/// Append an `Option` as a presence byte followed by the value if present. +fn write_optional_u64(out: &mut Vec, value: Option) { + match value { + Some(present_value) => { + out.push(1); + write_u64_le(out, present_value); + } + None => out.push(0), + } +} + +/// Read an `Option` encoded by [`write_optional_u64`]. +fn read_optional_u64(reader: &mut Reader<'_>) -> Result, FrameError> { + let present = reader.read_u8()?; + match present { + 0 => Ok(None), + _ => Ok(Some(reader.read_u64_le()?)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/uffs-content-protocol/src/frame/tests.rs b/crates/uffs-content-protocol/src/frame/tests.rs new file mode 100644 index 000000000..96132b0b4 --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/tests.rs @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Unit tests for [`super`] (`frame`) and all twelve payload submodules. + +use super::{ + ConsumerAckStatus, ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, + FailureStage, FileAck, FileBegin, FileDeferred, FileEnd, FileFailed, FrameEnvelope, FrameError, + FrameOrdering, FrameType, Heartbeat, JobBegin, JobCancel, JobEnd, JobStatus, Progress, + ReadMode, RetryClass, WindowUpdate, +}; +use crate::codec::Reader; +use crate::error::ErrorCode; +use crate::manifest::AuthorizationMode; +use crate::path_encoding::WindowsPath; + +fn sample_envelope(frame_type: FrameType, frame_sequence: u64) -> FrameEnvelope { + FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id: [3_u8; 16], + frame_sequence, + } +} + +#[test] +fn envelope_round_trips_with_payload() { + let envelope = sample_envelope(FrameType::Heartbeat, 7); + let payload = b"hello frame payload"; + let bytes = envelope.encode(payload); + let mut reader = Reader::new(&bytes); + let (decoded_envelope, decoded_payload) = + FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + assert_eq!(decoded_envelope, envelope); + assert_eq!(decoded_payload, payload); + assert_eq!(reader.remaining(), 0); +} + +#[test] +fn envelope_round_trips_with_empty_payload() { + let envelope = sample_envelope(FrameType::JobCancel, 1); + let bytes = envelope.encode(&[]); + let mut reader = Reader::new(&bytes); + let (decoded_envelope, decoded_payload) = + FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + assert_eq!(decoded_envelope, envelope); + assert!(decoded_payload.is_empty()); +} + +#[test] +#[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" +)] +fn envelope_rejects_bad_magic() { + let envelope = sample_envelope(FrameType::Heartbeat, 1); + let mut bytes = envelope.encode(&[]); + bytes[0] = b'Z'; + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::BadMagic(_))); +} + +#[test] +#[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" +)] +fn envelope_rejects_flipped_header_byte_via_checksum() { + let envelope = sample_envelope(FrameType::Heartbeat, 1); + let mut bytes = envelope.encode(&[]); + // job_id lives inside the checksummed header region. + bytes[20] ^= 0xFF; + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::HeaderChecksumMismatch { .. })); +} + +#[test] +#[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer index; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" +)] +fn envelope_rejects_flipped_payload_byte_via_checksum() { + let envelope = sample_envelope(FrameType::Heartbeat, 1); + let mut bytes = envelope.encode(b"payload bytes here"); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::PayloadChecksumMismatch { .. })); +} + +#[test] +fn envelope_rejects_payload_exceeding_max_before_allocation() { + let envelope = sample_envelope(FrameType::ContentChunk, 1); + let bytes = envelope.encode(&[1, 2, 3, 4, 5]); + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 2).unwrap_err(); + assert!(matches!(err, FrameError::PayloadTooLarge { + declared: 5, + max: 2 + })); +} + +#[test] +#[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer range; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" +)] +fn envelope_rejects_unknown_frame_type() { + // Hand-craft an envelope with frame_type = 999 by encoding a + // valid one then patching the frame_type field bytes directly + // (offset 6-7: magic(4) + protocol_version(2)). + let envelope = sample_envelope(FrameType::Heartbeat, 1); + let mut bytes = envelope.encode(&[]); + bytes[6..8].copy_from_slice(&999_u16.to_le_bytes()); + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::UnknownFrameType(999))); +} + +#[test] +fn frame_type_round_trips_all_variants() { + for value in 1_u16..=12 { + let frame_type = FrameType::decode(value).unwrap(); + assert_eq!(frame_type.encode(), value); + } + assert_eq!(FrameType::decode(0), Err(0)); + assert_eq!(FrameType::decode(13), Err(13)); +} + +fn sample_job_begin() -> JobBegin { + JobBegin { + job_id: [1_u8; 16], + source_id: [2_u8; 16], + snapshot_id: b"snap-1".to_vec(), + snapshot_created_at: 1_752_000_000_000, + manifest_digest: [4_u8; 32], + candidate_count: 10, + authorization_mode: AuthorizationMode::AdminExport, + ordering: FrameOrdering::None, + content_semantics: ContentSemantics::UnnamedLogicalStream, + digest_algorithm: DigestAlgorithm::Blake3, + max_chunk_bytes: 65536, + max_content_delivery_bytes: Some(64 * 1024 * 1024), + } +} + +#[test] +fn job_begin_round_trips() { + let payload = sample_job_begin(); + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = JobBegin::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(reader.remaining(), 0); +} + +#[test] +fn job_begin_round_trips_with_no_delivery_ceiling() { + let mut payload = sample_job_begin(); + payload.max_content_delivery_bytes = None; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = JobBegin::decode(&mut reader).unwrap(); + assert_eq!(decoded.max_content_delivery_bytes, None); +} + +fn sample_file_begin() -> FileBegin { + FileBegin { + candidate_id: 42, + file_reference: 0xABCD_EF01, + path: WindowsPath::from_str_lossless(r"C:\data\report.txt"), + logical_size: 2048, + mtime: 1_752_000_000_000, + read_mode: ReadMode::LogicalSnapshot, + attempt_number: 1, + content_object_id: None, + } +} + +#[test] +fn file_begin_round_trips() { + let payload = sample_file_begin(); + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileBegin::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(reader.remaining(), 0); +} + +#[test] +fn file_begin_round_trips_with_content_object_id() { + let mut payload = sample_file_begin(); + payload.content_object_id = Some(999); + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileBegin::decode(&mut reader).unwrap(); + assert_eq!(decoded.content_object_id, Some(999)); +} + +#[test] +fn content_chunk_round_trips() { + let payload = ContentChunk { + candidate_id: 1, + chunk_sequence: 0, + logical_offset: 0, + logical_length: 4, + payload: vec![1, 2, 3, 4], + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ContentChunk::decode(&mut reader, 1024).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn content_chunk_rejects_payload_over_max_before_allocation() { + let payload = ContentChunk { + candidate_id: 1, + chunk_sequence: 0, + logical_offset: 0, + logical_length: 4, + payload: vec![1, 2, 3, 4], + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let err = ContentChunk::decode(&mut reader, 2).unwrap_err(); + assert!(matches!(err, FrameError::Decode(_))); +} + +#[test] +fn file_end_round_trips_with_delivered_content() { + let payload = FileEnd { + candidate_id: 1, + total_logical_bytes: 4096, + content_digest: Some([5_u8; 32]), + read_mode: ReadMode::LogicalSnapshot, + chunk_count: 1, + elapsed_ms: 12, + warning_flags: 0, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileEnd::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(reader.remaining(), 0); +} + +#[test] +fn file_end_round_trips_metadata_only_with_no_digest() { + // The content-delivery-ceiling case (design-doc addendum + // discussion): candidate matched and validated, but its body + // exceeded the job's delivery ceiling, so nothing was read. + let payload = FileEnd { + candidate_id: 2, + total_logical_bytes: 0, + content_digest: None, + read_mode: ReadMode::MetadataOnly, + chunk_count: 0, + elapsed_ms: 1, + warning_flags: 0, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileEnd::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(decoded.content_digest, None); + assert_eq!(decoded.read_mode, ReadMode::MetadataOnly); +} + +#[test] +fn read_mode_round_trips_all_variants_including_metadata_only() { + for value in 0_u8..=3 { + let mode = ReadMode::decode(value).unwrap(); + assert_eq!(mode.encode(), value); + } + assert_eq!(ReadMode::decode(4), Err(4)); +} + +#[test] +fn file_failed_round_trips() { + let payload = FileFailed { + candidate_id: 3, + outcome: FailedOutcome::Retryable, + failure_stage: FailureStage::Read, + error_code: ErrorCode::ReadIoTransient, + os_error_code: Some(-5), + retry_class: RetryClass::RetrySameJob, + bytes_emitted_before_failure: 100, + message: "transient read error".to_owned(), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileFailed::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); + assert_eq!(reader.remaining(), 0); +} + +#[test] +fn file_failed_round_trips_without_os_error_code() { + let payload = FileFailed { + candidate_id: 4, + outcome: FailedOutcome::Terminal, + failure_stage: FailureStage::Identity, + error_code: ErrorCode::IdentityMismatch, + os_error_code: None, + retry_class: RetryClass::DoNotRetry, + bytes_emitted_before_failure: 0, + message: String::new(), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileFailed::decode(&mut reader).unwrap(); + assert_eq!(decoded.os_error_code, None); + assert_eq!(decoded.message, ""); +} + +#[test] +fn file_deferred_round_trips_with_hint() { + let payload = FileDeferred { + candidate_id: 5, + reason_code: ErrorCode::CompressedManual, + manual_handler_hint: Some("ntfs-compressed-handler".to_owned()), + message: "NTFS compression not yet supported".to_owned(), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileDeferred::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn file_deferred_round_trips_without_hint() { + let payload = FileDeferred { + candidate_id: 6, + reason_code: ErrorCode::SpecialSemanticsManual, + manual_handler_hint: None, + message: String::new(), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileDeferred::decode(&mut reader).unwrap(); + assert_eq!(decoded.manual_handler_hint, None); +} + +#[test] +fn file_ack_round_trips_accepted() { + let payload = FileAck { + candidate_id: 7, + content_digest: [6_u8; 32], + consumer_status: ConsumerAckStatus::Accepted, + consumer_error_code: None, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileAck::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn file_ack_round_trips_rejected_with_error_code() { + let payload = FileAck { + candidate_id: 8, + content_digest: [7_u8; 32], + consumer_status: ConsumerAckStatus::Rejected, + consumer_error_code: Some("DIGEST_MISMATCH_LOCAL".to_owned()), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = FileAck::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn job_end_round_trips_for_every_terminal_status() { + for job_status in [ + JobStatus::Completed, + JobStatus::CompletedWithFailures, + JobStatus::Cancelled, + JobStatus::Aborted, + ] { + let payload = JobEnd { + candidate_count: 10, + succeeded_count: 8, + failed_retryable_count: 1, + failed_terminal_count: 1, + deferred_manual_count: 0, + acknowledged_success_count: 8, + logical_bytes_succeeded: 4096, + failure_bucket_id: b"bucket-1".to_vec(), + manifest_digest: [8_u8; 32], + outcome_ledger_digest: [9_u8; 32], + job_status, + }; + let bytes = payload.encode().unwrap(); + let mut reader = Reader::new(&bytes); + let decoded = JobEnd::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload, "round-trip failed for {job_status:?}"); + } +} + +#[test] +fn job_end_rejects_non_terminal_job_status_at_encode_time() { + let payload = JobEnd { + candidate_count: 1, + succeeded_count: 0, + failed_retryable_count: 0, + failed_terminal_count: 0, + deferred_manual_count: 0, + acknowledged_success_count: 0, + logical_bytes_succeeded: 0, + failure_bucket_id: vec![], + manifest_digest: [0_u8; 32], + outcome_ledger_digest: [0_u8; 32], + job_status: JobStatus::Streaming, // not a legal JOB_END status + }; + let err = payload.encode().unwrap_err(); + assert!(matches!(err, FrameError::UnknownDiscriminant { + field: "job_status", + .. + })); +} + +#[test] +fn job_end_completeness_invariant_matches_sample_data() { + // Anchors design-doc §2.2/§21.7: candidate_count must equal the + // sum of the four outcome buckets. This test doesn't enforce the + // invariant in the wire format itself (that's the Coordinator's + // job) — it documents the expectation against a concrete example. + let succeeded = 8_u64; + let failed_retryable = 1_u64; + let failed_terminal = 1_u64; + let deferred_manual = 0_u64; + let candidate_count = 10_u64; + assert_eq!( + candidate_count, + succeeded + failed_retryable + failed_terminal + deferred_manual + ); +} + +#[test] +fn progress_round_trips() { + let payload = Progress { + candidates_discovered: 100, + candidates_completed: 42, + logical_bytes_emitted: 1_000_000, + error_count: 2, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = Progress::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn heartbeat_encodes_to_empty_bytes() { + let payload = Heartbeat; + assert!(payload.encode().is_empty()); + let decoded = Heartbeat::decode(); + assert_eq!(decoded, Heartbeat); +} + +#[test] +fn job_cancel_round_trips() { + let payload = JobCancel { + reason: "user requested cancellation".to_owned(), + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = JobCancel::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn window_update_round_trips() { + let payload = WindowUpdate { + additional_window_bytes: 1_048_576, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = WindowUpdate::decode(&mut reader).unwrap(); + assert_eq!(decoded, payload); +} + +#[test] +fn full_frame_round_trip_job_begin_inside_envelope() { + // End-to-end: a real JobBegin payload framed by a real envelope, + // exactly the shape that crosses the wire to Docenta. + let job_begin = sample_job_begin(); + let payload_bytes = job_begin.encode(); + let envelope = sample_envelope(FrameType::JobBegin, 0); + let frame_bytes = envelope.encode(&payload_bytes); + + let mut reader = Reader::new(&frame_bytes); + let (decoded_envelope, decoded_payload) = + FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + assert_eq!(decoded_envelope.frame_type, FrameType::JobBegin); + + let mut payload_reader = Reader::new(&decoded_payload); + let decoded_job_begin = JobBegin::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded_job_begin, job_begin); +} diff --git a/crates/uffs-content-protocol/src/lib.rs b/crates/uffs-content-protocol/src/lib.rs index 3772bab9f..4be0948ce 100644 --- a/crates/uffs-content-protocol/src/lib.rs +++ b/crates/uffs-content-protocol/src/lib.rs @@ -36,6 +36,7 @@ pub mod codec; pub mod error; +pub mod frame; pub mod manifest; pub mod path_encoding; pub mod state; From 07fe5840bf7608478b77f77b0913ad332eabbdd7 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:06:56 -0700 Subject: [PATCH 08/98] test(content-protocol): golden fixture conformance corpus (UFI.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/uffs-content-protocol/tests/golden_fixtures.rs plus 10 frozen binary fixtures under tests/fixtures/*.bin: manifest header, candidate record, manifest trailer, and framed JOB_BEGIN / FILE_END (success) / FILE_END (metadata-only) / FILE_FAILED / JOB_END, plus two deliberately invalid fixtures (corrupt checksum, truncated frame) that must be rejected. These decode frozen, committed bytes rather than regenerating them from the current encoder each run — a test that reproduced its own expected bytes every time would never catch an accidental wire-format regression, since it would just compare new (wrong) output against itself. This is also the cross-language conformance surface the addendum (§5.5) calls for: "a future implementation in another language is supported only after passing the same conformance corpus." Regeneration is deliberately gated: `#[ignore]`d tests only write a fixture when UFFS_REGENERATE_FIXTURES=1 is set, so a routine `cargo test` run can never silently overwrite the frozen contract. Added a .gitignore carve-out for these fixtures (blanket *.bin is otherwise gitignored workspace-wide), following the existing upcase-tables / MFT-capture carve-out pattern. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 +- .../tests/fixtures/candidate_record_basic.bin | Bin 0 -> 143 bytes .../tests/fixtures/frame_corrupt_checksum.bin | Bin 0 -> 172 bytes .../fixtures/frame_file_end_metadata_only.bin | Bin 0 -> 94 bytes .../tests/fixtures/frame_file_end_success.bin | Bin 0 -> 126 bytes .../tests/fixtures/frame_file_failed.bin | Bin 0 -> 139 bytes .../tests/fixtures/frame_job_begin.bin | Bin 0 -> 172 bytes .../tests/fixtures/frame_job_end.bin | Bin 0 -> 196 bytes .../tests/fixtures/frame_truncated.bin | Bin 0 -> 30 bytes .../tests/fixtures/manifest_header_basic.bin | Bin 0 -> 168 bytes .../tests/fixtures/manifest_trailer_basic.bin | Bin 0 -> 44 bytes .../tests/golden_fixtures.rs | 442 ++++++++++++++++++ 12 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 crates/uffs-content-protocol/tests/fixtures/candidate_record_basic.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_corrupt_checksum.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_file_end_metadata_only.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_file_end_success.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_file_failed.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_job_begin.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_job_end.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/frame_truncated.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/manifest_header_basic.bin create mode 100644 crates/uffs-content-protocol/tests/fixtures/manifest_trailer_basic.bin create mode 100644 crates/uffs-content-protocol/tests/golden_fixtures.rs diff --git a/.gitignore b/.gitignore index 0b3ad77e3..2dfa8726e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ build/ *.rlib *.bin # `*.bin` is meant to keep transient generated blobs out of git, but the -# workspace contains three INTENTIONAL committed binary assets that must +# workspace contains INTENTIONAL committed binary assets that must # remain tracked. Without these carve-outs, release-plz aborts with # "the working directory has uncommitted changes" because it sees these # files as both committed AND gitignored — the same R0-pattern issue @@ -57,6 +57,11 @@ build/ # `ipc_integration::real_mft_search` test in `uffs-daemon` cannot find # its fixture and silently no-ops. !tests/fixtures/**/*.bin +# uffs-content-protocol golden wire-format fixtures (frozen manifest/frame +# byte samples) — see `crates/uffs-content-protocol/tests/golden_fixtures.rs`. +# These are the cross-language conformance corpus per the ingest-protocol +# addendum §5.5; committing the exact bytes is the point, not incidental. +!crates/uffs-content-protocol/tests/fixtures/*.bin /test_scf # ============================================================================= diff --git a/crates/uffs-content-protocol/tests/fixtures/candidate_record_basic.bin b/crates/uffs-content-protocol/tests/fixtures/candidate_record_basic.bin new file mode 100644 index 0000000000000000000000000000000000000000..fac2658d92c9305bf22484e12a65433c05751240 GIT binary patch literal 143 zcmeBXU|_H`01`mZneM90`2Or_1_lQ(A5Is9y`IjVK1;|TgC}qe6isUhrKvkqN6adAGfP7tsG=@x|Od>-LgC0W)Sak)% I#D6&o00{pZUjP6A literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_corrupt_checksum.bin b/crates/uffs-content-protocol/tests/fixtures/frame_corrupt_checksum.bin new file mode 100644 index 0000000000000000000000000000000000000000..5ba6d98c62d4f0f2c997aafc1aa8d3390db6a6ec GIT binary patch literal 172 zcmWG_3pQe6U}OLR10XH|vltiz(EwDK;b6Dxo>}aY`DoIDN@zfkp{%%Aw>U4cpg1GH gMAyK;z>uLJ?Dce@O`!w;Gt4e9EdX{L3y}RE0HV7cb^rhX literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_file_end_metadata_only.bin b/crates/uffs-content-protocol/tests/fixtures/frame_file_end_metadata_only.bin new file mode 100644 index 0000000000000000000000000000000000000000..bb7d0fcde1916cad7620591bb90c6d7bb6cb1774 GIT binary patch literal 94 xcmWG_3pQe6U||3O10Yrdvltiz(EvA8+;#15!`_9rW#+drLPekyGmMKY4gjlZ2n7HD literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_file_end_success.bin b/crates/uffs-content-protocol/tests/fixtures/frame_file_end_success.bin new file mode 100644 index 0000000000000000000000000000000000000000..c6d881d986d3d8b4c4385da7e6eb33c35c9b3539 GIT binary patch literal 126 zcmWG_3pQe6U||3O10Z$-vltiz(Et}z+-tk*1>KF8uUmo?gMkB>WK1If7$NEy7=D4( GLkIwyBo+Dq literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_file_failed.bin b/crates/uffs-content-protocol/tests/fixtures/frame_file_failed.bin new file mode 100644 index 0000000000000000000000000000000000000000..9828777aa263e6e843b42ee96afb9d1b568a7d1a GIT binary patch literal 139 zcmWG_3pQe6U}XRS10W6tvltiz(EtxreB&7t#_rcv$2ggx0&Ic|L9UK2@t*$iAwiCQ z!Je*uA&kHOLjY6_1A`JnNl{{6ab{{>iGruTzd~wJQGSs^QEFmJW?s5NYDEc90swrT BAr1fl literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_job_begin.bin b/crates/uffs-content-protocol/tests/fixtures/frame_job_begin.bin new file mode 100644 index 0000000000000000000000000000000000000000..c374687df6523ab697d7a87d3e7909f8f97a1a9c GIT binary patch literal 172 zcmWG_3pQe6U}OLR10XH|vltiz(EwDK;b6Dxo>}aY`DoIDN@zfkp{%%Aw>U4cpg1GH fMAyK;z>uLJ?Dce@O`!w;Gt4e9EdX{L3y2K>p}ZV- literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_job_end.bin b/crates/uffs-content-protocol/tests/fixtures/frame_job_end.bin new file mode 100644 index 0000000000000000000000000000000000000000..507b3a6550ab61bbb8b09a374a2c53b4792ac004 GIT binary patch literal 196 zcmWG_3pQe6;9>v)10e1Jvltiz(ZF+Nh=h@ie!!9Py?-D&7#P^0>KSp+FnNZDUs*v) e1R1jOlXMLX3=DPC5;Jp3i&Bfr34jg)fDr&W4li8* literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/frame_truncated.bin b/crates/uffs-content-protocol/tests/fixtures/frame_truncated.bin new file mode 100644 index 0000000000000000000000000000000000000000..2c98656136e536f43b85c40cff7528705204aea7 GIT binary patch literal 30 acmWG_3pQe6U}OLR10XH|vltiz!2kd&#R4t> literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/manifest_header_basic.bin b/crates/uffs-content-protocol/tests/fixtures/manifest_header_basic.bin new file mode 100644 index 0000000000000000000000000000000000000000..964360254788ebf32be222c2cab9cba2eeead12b GIT binary patch literal 168 zcmWG_^EF~(Si&HP29(eM2Rj=p3o{d=8bh@s6zDnufvz(U=(+%bt}7C#6=WzYF4is1 mODrhP$S=_~FfcG=CWLZz0hD literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/fixtures/manifest_trailer_basic.bin b/crates/uffs-content-protocol/tests/fixtures/manifest_trailer_basic.bin new file mode 100644 index 0000000000000000000000000000000000000000..5ed4a14e79fcb50812f1fb88f62a644e2c452967 GIT binary patch literal 44 RcmZQ(fB+W)Ak@v(2mrCt36=l= literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/golden_fixtures.rs b/crates/uffs-content-protocol/tests/golden_fixtures.rs new file mode 100644 index 000000000..e3590578c --- /dev/null +++ b/crates/uffs-content-protocol/tests/golden_fixtures.rs @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Golden fixture conformance tests (design-doc §21.6, addendum §5.5). +//! +//! Each fixture under `tests/fixtures/*.bin` is a **frozen** wire-format +//! sample, committed to the repository. These tests decode the frozen +//! bytes and assert the expected values — they do not regenerate the +//! bytes from the current encoder on every run. That distinction is the +//! entire point: if a future change to `encode()`/`decode()` silently +//! drifts the wire format, a test that regenerated its own expected +//! bytes each time would never catch it, because it would just compare +//! the new (wrong) output against itself. Comparing against frozen bytes +//! is what makes this a *regression* guard rather than a tautology. +//! +//! This is also the cross-language contract surface: per addendum §5.5, +//! "a future implementation in another language is supported only after +//! passing the same conformance corpus" — these files are that corpus's +//! first slice. +//! +//! To regenerate a fixture deliberately (a real, intentional wire-format +//! version bump — not a routine change), run: +//! `UFFS_REGENERATE_FIXTURES=1 cargo test -p uffs-content-protocol --test +//! golden_fixtures -- --ignored --nocapture` then inspect the diff before +//! committing it. + +// These are `uffs-content-protocol`'s own dependencies, not this test +// binary's — an integration test is a separate compilation unit, so it +// sees them as unused unless a marker import says otherwise. See +// `crates/uffs-daemon/src/lib.rs`'s `use uffs_version as _;` for the same +// pattern. +use bitflags as _; +use blake3 as _; +use proptest as _; +use thiserror as _; + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use uffs_content_protocol::codec::Reader; + use uffs_content_protocol::error::ErrorCode; + use uffs_content_protocol::frame::{ + ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileEnd, FileFailed, + FrameEnvelope, FrameError, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, ReadMode, + RetryClass, + }; + use uffs_content_protocol::manifest::{ + AuthorizationMode, CandidateFlags, CandidateRecord, ManifestHeader, ManifestTrailer, + }; + use uffs_content_protocol::path_encoding::WindowsPath; + + fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) + } + + fn load_fixture(name: &str) -> Vec { + fs::read(fixture_path(name)).unwrap_or_else(|err| { + panic!( + "missing golden fixture {name}: {err}. Run with \ + UFFS_REGENERATE_FIXTURES=1 --ignored first." + ) + }) + } + + /// Writes `bytes` to the fixture path only when explicitly requested + /// via `UFFS_REGENERATE_FIXTURES=1`. Every call site that uses this + /// is `#[ignore]`d so a normal `cargo test` run never touches the + /// fixture files. + fn regenerate_fixture(name: &str, bytes: &[u8]) { + assert!( + std::env::var("UFFS_REGENERATE_FIXTURES").as_deref() == Ok("1"), + "refusing to write fixture {name}: set UFFS_REGENERATE_FIXTURES=1 to confirm this \ + is an intentional wire-format change" + ); + fs::write(fixture_path(name), bytes).unwrap_or_else(|err| panic!("writing {name}: {err}")); + } + + fn sample_manifest_header() -> ManifestHeader { + ManifestHeader { + format_version: 2, + job_id: [0x11_u8; 16], + source_id: [0x22_u8; 16], + volume_serial: 0x0102_0304_0506_0708, + volume_guid: b"{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}".to_vec(), + snapshot_id: b"vss-snapshot-0001".to_vec(), + snapshot_created_unix_ms: 1_752_000_000_000, + query_digest: [0x33_u8; 32], + authorization_mode: AuthorizationMode::AdminExport, + candidate_count: 3, + record_section_length: 999, + } + } + + fn sample_candidate_record() -> CandidateRecord { + CandidateRecord { + candidate_id: 12345, + file_reference: 0xABCD_EF01_2345_6789, + logical_size: 4_194_304, + valid_data_length: 4_194_304, + mtime_unix_ms: 1_752_000_000_000, + candidate_flags: CandidateFlags::NONRESIDENT | CandidateFlags::LARGE_FILE, + path: WindowsPath::from_str_lossless(r"C:\Users\robert\Documents\report-final.docx"), + } + } + + const fn sample_manifest_trailer() -> ManifestTrailer { + ManifestTrailer { + candidate_count_repeat: 3, + manifest_digest: [0x44_u8; 32], + } + } + + fn sample_job_begin_frame() -> (FrameEnvelope, JobBegin) { + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type: FrameType::JobBegin, + flags: 0, + job_id: [0x11_u8; 16], + frame_sequence: 0, + }; + let payload = JobBegin { + job_id: [0x11_u8; 16], + source_id: [0x22_u8; 16], + snapshot_id: b"vss-snapshot-0001".to_vec(), + snapshot_created_at: 1_752_000_000_000, + manifest_digest: [0x55_u8; 32], + candidate_count: 3, + authorization_mode: AuthorizationMode::AdminExport, + ordering: FrameOrdering::None, + content_semantics: ContentSemantics::UnnamedLogicalStream, + digest_algorithm: DigestAlgorithm::Blake3, + max_chunk_bytes: 1_048_576, + max_content_delivery_bytes: Some(64 * 1024 * 1024), + }; + (envelope, payload) + } + + const fn sample_file_end_success() -> FileEnd { + FileEnd { + candidate_id: 12345, + total_logical_bytes: 4_194_304, + content_digest: Some([0x66_u8; 32]), + read_mode: ReadMode::LogicalSnapshot, + chunk_count: 64, + elapsed_ms: 250, + warning_flags: 0, + } + } + + const fn sample_file_end_metadata_only() -> FileEnd { + FileEnd { + candidate_id: 99999, + total_logical_bytes: 0, + content_digest: None, + read_mode: ReadMode::MetadataOnly, + chunk_count: 0, + elapsed_ms: 1, + warning_flags: 0, + } + } + + fn sample_file_failed() -> FileFailed { + FileFailed { + candidate_id: 777, + outcome: FailedOutcome::Retryable, + failure_stage: FailureStage::Read, + error_code: ErrorCode::ReadIoTransient, + os_error_code: Some(-5), + retry_class: RetryClass::RetrySameJob, + bytes_emitted_before_failure: 0, + message: "transient I/O error reading extent".to_owned(), + } + } + + fn sample_job_end() -> JobEnd { + JobEnd { + candidate_count: 10, + succeeded_count: 7, + failed_retryable_count: 1, + failed_terminal_count: 1, + deferred_manual_count: 1, + acknowledged_success_count: 7, + logical_bytes_succeeded: 100_000_000, + failure_bucket_id: b"job-0001-failures".to_vec(), + manifest_digest: [0x77_u8; 32], + outcome_ledger_digest: [0x88_u8; 32], + job_status: JobStatus::CompletedWithFailures, + } + } + + // ───────────────────────── manifest header ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_manifest_header_fixture() { + let bytes = sample_manifest_header().encode().unwrap(); + regenerate_fixture("manifest_header_basic.bin", &bytes); + } + + #[test] + fn manifest_header_fixture_decodes_to_expected_values() { + let bytes = load_fixture("manifest_header_basic.bin"); + let mut reader = Reader::new(&bytes); + let decoded = ManifestHeader::decode(&mut reader).unwrap(); + assert_eq!(decoded, sample_manifest_header()); + // Also guards encode-side drift: re-encoding the decoded value + // must reproduce the exact frozen bytes. + assert_eq!(decoded.encode().unwrap(), bytes); + } + + // ───────────────────────── candidate record ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_candidate_record_fixture() { + let bytes = sample_candidate_record().encode().unwrap(); + regenerate_fixture("candidate_record_basic.bin", &bytes); + } + + #[test] + fn candidate_record_fixture_decodes_to_expected_values() { + let bytes = load_fixture("candidate_record_basic.bin"); + let mut reader = Reader::new(&bytes); + let decoded = CandidateRecord::decode(&mut reader).unwrap(); + assert_eq!(decoded, sample_candidate_record()); + assert_eq!(decoded.encode().unwrap(), bytes); + } + + // ───────────────────────── manifest trailer ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_manifest_trailer_fixture() { + let bytes = sample_manifest_trailer().encode(); + regenerate_fixture("manifest_trailer_basic.bin", &bytes); + } + + #[test] + fn manifest_trailer_fixture_decodes_to_expected_values() { + let bytes = load_fixture("manifest_trailer_basic.bin"); + let mut reader = Reader::new(&bytes); + let decoded = ManifestTrailer::decode(&mut reader).unwrap(); + assert_eq!(decoded, sample_manifest_trailer()); + assert_eq!(decoded.encode(), bytes); + } + + // ───────────────────────── frames: JOB_BEGIN ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_frame_job_begin_fixture() { + let (envelope, payload) = sample_job_begin_frame(); + let bytes = envelope.encode(&payload.encode()); + regenerate_fixture("frame_job_begin.bin", &bytes); + } + + #[test] + fn frame_job_begin_fixture_decodes_to_expected_values() { + let bytes = load_fixture("frame_job_begin.bin"); + let mut reader = Reader::new(&bytes); + let (decoded_envelope, decoded_payload_bytes) = + FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + let (expected_envelope, expected_payload) = sample_job_begin_frame(); + assert_eq!(decoded_envelope, expected_envelope); + let mut payload_reader = Reader::new(&decoded_payload_bytes); + let decoded_payload = JobBegin::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded_payload, expected_payload); + } + + // ───────────────────────── frames: FILE_END (success) + // ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_frame_file_end_success_fixture() { + let payload = sample_file_end_success(); + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type: FrameType::FileEnd, + flags: 0, + job_id: [0x11_u8; 16], + frame_sequence: 10, + }; + let bytes = envelope.encode(&payload.encode()); + regenerate_fixture("frame_file_end_success.bin", &bytes); + } + + #[test] + fn frame_file_end_success_fixture_decodes_to_expected_values() { + let bytes = load_fixture("frame_file_end_success.bin"); + let mut reader = Reader::new(&bytes); + let (envelope, payload_bytes) = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + assert_eq!(envelope.frame_type, FrameType::FileEnd); + let mut payload_reader = Reader::new(&payload_bytes); + let decoded = FileEnd::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded, sample_file_end_success()); + assert!(decoded.content_digest.is_some()); + } + + // ───────────────────────── frames: FILE_END (metadata-only) + // ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_frame_file_end_metadata_only_fixture() { + let payload = sample_file_end_metadata_only(); + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type: FrameType::FileEnd, + flags: 0, + job_id: [0x11_u8; 16], + frame_sequence: 11, + }; + let bytes = envelope.encode(&payload.encode()); + regenerate_fixture("frame_file_end_metadata_only.bin", &bytes); + } + + #[test] + fn frame_file_end_metadata_only_fixture_decodes_to_expected_values() { + // This is the content-delivery-ceiling fixture: a candidate that + // matched the job's query but exceeded the delivery ceiling, so + // it has no content body — see frame::ReadMode::MetadataOnly. + let bytes = load_fixture("frame_file_end_metadata_only.bin"); + let mut reader = Reader::new(&bytes); + let (_envelope, payload_bytes) = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + let mut payload_reader = Reader::new(&payload_bytes); + let decoded = FileEnd::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded, sample_file_end_metadata_only()); + assert_eq!(decoded.content_digest, None); + assert_eq!(decoded.read_mode, ReadMode::MetadataOnly); + assert_eq!(decoded.chunk_count, 0); + } + + // ───────────────────────── frames: FILE_FAILED ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_frame_file_failed_fixture() { + let payload = sample_file_failed(); + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type: FrameType::FileFailed, + flags: 0, + job_id: [0x11_u8; 16], + frame_sequence: 12, + }; + let bytes = envelope.encode(&payload.encode()); + regenerate_fixture("frame_file_failed.bin", &bytes); + } + + #[test] + fn frame_file_failed_fixture_decodes_to_expected_values() { + let bytes = load_fixture("frame_file_failed.bin"); + let mut reader = Reader::new(&bytes); + let (_envelope, payload_bytes) = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + let mut payload_reader = Reader::new(&payload_bytes); + let decoded = FileFailed::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded, sample_file_failed()); + } + + // ───────────────────────── frames: JOB_END ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_frame_job_end_fixture() { + let payload = sample_job_end(); + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type: FrameType::JobEnd, + flags: 0, + job_id: [0x11_u8; 16], + frame_sequence: 999, + }; + let bytes = envelope.encode(&payload.encode().unwrap()); + regenerate_fixture("frame_job_end.bin", &bytes); + } + + #[test] + fn frame_job_end_fixture_decodes_to_expected_values() { + let bytes = load_fixture("frame_job_end.bin"); + let mut reader = Reader::new(&bytes); + let (_envelope, payload_bytes) = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + let mut payload_reader = Reader::new(&payload_bytes); + let decoded = JobEnd::decode(&mut payload_reader).unwrap(); + assert_eq!(decoded, sample_job_end()); + // Completeness invariant sanity (design-doc §2.2/§21.7). + assert_eq!( + decoded.candidate_count, + decoded.succeeded_count + + decoded.failed_retryable_count + + decoded.failed_terminal_count + + decoded.deferred_manual_count + ); + } + + // ───────────────────────── invalid fixtures (must be rejected) + // ───────────────────────── + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_corrupt_checksum_fixture() { + let (envelope, payload) = sample_job_begin_frame(); + let mut bytes = envelope.encode(&payload.encode()); + let last = bytes.len() - 1; + if let Some(byte) = bytes.get_mut(last) { + *byte ^= 0xFF; + } + regenerate_fixture("frame_corrupt_checksum.bin", &bytes); + } + + #[test] + fn corrupt_checksum_fixture_is_rejected() { + let bytes = load_fixture("frame_corrupt_checksum.bin"); + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::PayloadChecksumMismatch { .. })); + } + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_truncated_frame_fixture() { + let (envelope, payload) = sample_job_begin_frame(); + let bytes = envelope.encode(&payload.encode()); + // Truncate to just past the fixed header, before any checksum or + // payload bytes are fully present. + let truncated = bytes.get(0..30).unwrap_or(&bytes).to_vec(); + regenerate_fixture("frame_truncated.bin", &truncated); + } + + #[test] + fn truncated_frame_fixture_is_rejected() { + let bytes = load_fixture("frame_truncated.bin"); + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::Decode(_))); + } +} From c7ba77ed4ba674e94915b0c2220726b0866253df Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:13:00 -0700 Subject: [PATCH 09/98] feat(content-reader-protocol): new crate for Coordinator<->Reader IPC (UFI.0) New Layer 0 crate implementing the private wire protocol between uffs-content (unprivileged Coordinator) and the privileged Snapshot Reader process, per the addendum's corrected architecture (S2.1-S2.4): ReadRequest/ReadResponse, VolumeIdentity, StreamKind, RequestedReadMode, ActualReadMode, and a narrow ReaderErrorCode subset. Deliberately does NOT depend on uffs-content-protocol even though both are Layer 0 - per crate-graph.md, Layer-0-to-Layer-0 internal deps are disallowed so each stays independently buildable. Duplicates a small (~300-line) bounds-checked LE codec rather than sharing uffs-content-protocol's, which is the direct cost of that independence rule - documented in the crate's Cargo.toml. The Reader MUST revalidate every ReadRequest field against the snapshot itself (identity, EOF/VDL, stream) rather than trusting the Coordinator's claimed range - documented on ReadRequest per addendum S2.3's "resolves the unnamed stream itself" requirement. 38 tests, clean under lint-prod + lint-tests. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 8 + Cargo.toml | 32 +- .../uffs-content-reader-protocol/Cargo.toml | 53 ++ .../uffs-content-reader-protocol/src/codec.rs | 320 ++++++++++ .../uffs-content-reader-protocol/src/lib.rs | 583 ++++++++++++++++++ 5 files changed, 984 insertions(+), 12 deletions(-) create mode 100644 crates/uffs-content-reader-protocol/Cargo.toml create mode 100644 crates/uffs-content-reader-protocol/src/codec.rs create mode 100644 crates/uffs-content-reader-protocol/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f4e39b63d..445a15267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4495,6 +4495,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "uffs-content-reader-protocol" +version = "0.6.27" +dependencies = [ + "proptest", + "thiserror 2.0.18", +] + [[package]] name = "uffs-core" version = "0.6.27" diff --git a/Cargo.toml b/Cargo.toml index 7aeaf6300..c88abd424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,18 +24,19 @@ cargo-features = [ resolver = "3" members = [ # ── Foundation ── - "crates/uffs-polars", # 🚀 Polars facade (compilation isolation) - "crates/uffs-security", # 🔒 Crypto, key storage, secure FS ops - "crates/uffs-text", # 📝 Unicode text processing, i18n foundation - "crates/uffs-time", # ⏱️ NTFS FILETIME arithmetic (pure, zero deps) - "crates/uffs-version", # 🏷️ Shared --version strings + build-metadata stamp (leaf) - "crates/uffs-statusfmt", # 🎨 Shared operator-status styling (color, glyphs, fields) (leaf) - "crates/uffs-broker-protocol", # 📟 Cross-platform broker wire-protocol types (F5) - "crates/uffs-content-protocol", # 📨 Cross-platform Content Service wire-protocol types - "crates/uffs-winsvc", # 🪟 Native Windows service control + broker-pipe probe (leaf) - "crates/uffs-mft", # 📦 MFT reading → Polars DataFrame - "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) - "crates/uffs-core", # 🎯 Query engine + compact search engine + "crates/uffs-polars", # 🚀 Polars facade (compilation isolation) + "crates/uffs-security", # 🔒 Crypto, key storage, secure FS ops + "crates/uffs-text", # 📝 Unicode text processing, i18n foundation + "crates/uffs-time", # ⏱️ NTFS FILETIME arithmetic (pure, zero deps) + "crates/uffs-version", # 🏷️ Shared --version strings + build-metadata stamp (leaf) + "crates/uffs-statusfmt", # 🎨 Shared operator-status styling (color, glyphs, fields) (leaf) + "crates/uffs-broker-protocol", # 📟 Cross-platform broker wire-protocol types (F5) + "crates/uffs-content-protocol", # 📨 Cross-platform Content Service wire-protocol types + "crates/uffs-content-reader-protocol", # 📡 Private Coordinator<->Snapshot Reader wire-protocol types + "crates/uffs-winsvc", # 🪟 Native Windows service control + broker-pipe probe (leaf) + "crates/uffs-mft", # 📦 MFT reading → Polars DataFrame + "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) + "crates/uffs-core", # 🎯 Query engine + compact search engine # ── Daemon Architecture ── "crates/uffs-daemon", # 🛡️ Background service process "crates/uffs-client", # 📡 Thin client library @@ -161,6 +162,13 @@ uffs-broker-protocol = { path = "crates/uffs-broker-protocol", version = "0.6.27 # `uffs-content-stream-enterprise-design-review.md` replacement-design # review, and Docenta's `uffs-ingest-protocol-v2-vss.md`. uffs-content-protocol = { path = "crates/uffs-content-protocol", version = "0.6.27" } +# `uffs-content-reader-protocol` — the private wire format between +# `uffs-content` (Coordinator) and the privileged Snapshot Reader process +# (addendum §2.1-§2.4). Deliberately does NOT depend on +# `uffs-content-protocol` — both are Layer 0, and Layer-0-to-Layer-0 +# internal deps are disallowed (see crate-graph.md); see this crate's +# Cargo.toml for the full rationale. +uffs-content-reader-protocol = { path = "crates/uffs-content-reader-protocol", version = "0.6.27" } # `uffs-winsvc` — native Windows service control (SCM query/start/stop) + # the non-connecting broker-pipe readiness probe. Layer-0 leaf: its only # dependency is the `windows` crate (windows-target), with non-Windows diff --git a/crates/uffs-content-reader-protocol/Cargo.toml b/crates/uffs-content-reader-protocol/Cargo.toml new file mode 100644 index 000000000..ebc2cd507 --- /dev/null +++ b/crates/uffs-content-reader-protocol/Cargo.toml @@ -0,0 +1,53 @@ +# ============================================================================ +# uffs-content-reader-protocol: Content Coordinator <-> Snapshot Reader +# wire-protocol types +# ============================================================================ +# Layer 0 Foundation crate. Tiny dedicated lib — pure-logic byte-shuffling, +# no Windows FFI, no I/O. Mirrors `uffs-broker-protocol`'s shape and size. +# +# Private wire format between `uffs-content` (the unprivileged Content +# Coordinator) and the privileged Snapshot Reader process (addendum §2.1- +# §2.4). Deliberately a SEPARATE crate from `uffs-content-protocol` (the +# public UFFS<->Docenta protocol) even though the two share some field +# shapes — addendum §5.2 is explicit that the public protocol crate "MUST +# NOT contain... privileged IPC implementation", and Docenta must never be +# able to depend on this crate's evolution. +# +# This crate does NOT depend on `uffs-content-protocol` even though both +# are Layer 0: per docs/architecture/crate-graph.md, "Layer-0 → Layer-0 +# deps... should stay absent — each Layer-0 crate is independently +# buildable" (the same rule that keeps uffs-text and uffs-time from +# depending on each other). The small amount of duplicated bounds-checked +# LE-decode logic in `codec.rs` is a deliberate, small trade against that +# independence guarantee, not an oversight. +# ============================================================================ + +[package] +name = "uffs-content-reader-protocol" +description = "Private wire protocol between uffs-content (Coordinator) and the Snapshot Reader (cross-platform, Windows-only machinery lives in the Reader binary)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +# Intentionally NOT published. Private, internal-only IPC contract between +# two processes in this workspace; no meaning outside it. Reserve the name +# on crates.io to prevent squatting, but never carry content. Mirrors +# `uffs-broker-protocol`'s rationale. +publish.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[dependencies] +thiserror.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/uffs-content-reader-protocol/src/codec.rs b/crates/uffs-content-reader-protocol/src/codec.rs new file mode 100644 index 000000000..9076f09d1 --- /dev/null +++ b/crates/uffs-content-reader-protocol/src/codec.rs @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Minimal bounds-checked little-endian decode primitives. +//! +//! A deliberately small, independent duplicate of +//! `uffs-content-protocol::codec::Reader`'s shape — see this crate's +//! `Cargo.toml` for why the two Layer-0 crates don't share this code via +//! an internal dependency. Only the handful of primitives +//! [`ReadRequest`](crate::ReadRequest)/[`ReadResponse`](crate::ReadResponse) +//! actually need are implemented here. + +/// Errors decoding wire bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum DecodeError { + /// Fewer bytes remained than the field being read requires. + #[error("truncated input: needed {needed} bytes, only {available} remained")] + Truncated { + /// Bytes required to satisfy the read. + needed: usize, + /// Bytes actually remaining in the input. + available: usize, + }, + /// A length-prefixed field declared more bytes than the caller's + /// configured maximum allows, checked before allocation. + #[error("field '{field}' declared length {declared} exceeds maximum {max}")] + LengthOutOfBounds { + /// Name of the offending field, for diagnostics. + field: &'static str, + /// Length the wire bytes claimed. + declared: u64, + /// Maximum length the caller configured. + max: u64, + }, + /// A discriminant byte did not match any known enum variant. + #[error("unknown discriminant for '{field}': {value}")] + UnknownDiscriminant { + /// Name of the field being decoded, for diagnostics. + field: &'static str, + /// The unrecognized value. + value: u64, + }, + /// A string field was not valid UTF-8. + #[error("field '{0}' is not valid UTF-8")] + InvalidUtf8(&'static str), +} + +/// Bounds-checked little-endian cursor over a decode input buffer. +#[derive(Debug, Clone, Copy)] +pub struct Reader<'a> { + /// Backing bytes being decoded. + buf: &'a [u8], + /// Read offset into `buf`; always `<= buf.len()`. + pos: usize, +} + +impl<'a> Reader<'a> { + /// Wrap `buf` for bounds-checked reading, starting at offset 0. + #[must_use] + pub const fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Bytes not yet consumed. + #[must_use] + pub const fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Consume and return exactly `len` bytes, or a + /// [`DecodeError::Truncated`] if fewer remain. + fn take(&mut self, len: usize) -> Result<&'a [u8], DecodeError> { + let available = self.remaining(); + if available < len { + return Err(DecodeError::Truncated { + needed: len, + available, + }); + } + let start = self.pos; + let slice = self + .buf + .get(start..start + len) + .ok_or(DecodeError::Truncated { + needed: len, + available, + })?; + self.pos += len; + Ok(slice) + } + + /// Read a single byte. + /// + /// # Errors + /// [`DecodeError::Truncated`] if no bytes remain. + pub fn read_u8(&mut self) -> Result { + let bytes = self.take(1)?; + bytes.first().copied().ok_or(DecodeError::Truncated { + needed: 1, + available: 0, + }) + } + + /// Read exactly `N` raw bytes. + /// + /// # Errors + /// [`DecodeError::Truncated`] if fewer than `N` bytes remain. + pub fn read_array(&mut self) -> Result<[u8; N], DecodeError> { + let bytes = self.take(N)?; + let mut out = [0_u8; N]; + out.copy_from_slice(bytes); + Ok(out) + } + + /// Read a little-endian `u32`. + /// + /// # Errors + /// [`DecodeError::Truncated`] if fewer than 4 bytes remain. + pub fn read_u32_le(&mut self) -> Result { + Ok(u32::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `u64`. + /// + /// # Errors + /// [`DecodeError::Truncated`] if fewer than 8 bytes remain. + pub fn read_u64_le(&mut self) -> Result { + Ok(u64::from_le_bytes(self.read_array()?)) + } + + /// Read a `u32`-length-prefixed byte string, rejecting (before any + /// allocation) a declared length exceeding `max_len` or the bytes + /// actually remaining. + /// + /// # Errors + /// + /// - [`DecodeError::LengthOutOfBounds`] if the declared length exceeds + /// `max_len`. + /// - [`DecodeError::Truncated`] if the declared length exceeds the bytes + /// remaining. + pub fn read_bytes_u32_prefixed( + &mut self, + field: &'static str, + max_len: u32, + ) -> Result, DecodeError> { + let len = self.read_u32_le()?; + if len > max_len { + return Err(DecodeError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + let bytes = self.take(len as usize)?; + Ok(bytes.to_vec()) + } + + /// Read a `u16`-length-prefixed UTF-8 string, same bounds discipline + /// as [`read_bytes_u32_prefixed`](Self::read_bytes_u32_prefixed). + /// + /// # Errors + /// + /// Same as [`read_bytes_u32_prefixed`](Self::read_bytes_u32_prefixed), + /// plus [`DecodeError::InvalidUtf8`] if the bytes are not valid UTF-8. + pub fn read_string_u16_prefixed( + &mut self, + field: &'static str, + max_len: u16, + ) -> Result { + let len = self.read_u16_le()?; + if len > max_len { + return Err(DecodeError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + let bytes = self.take(len as usize)?; + String::from_utf8(bytes.to_vec()).map_err(|_err| DecodeError::InvalidUtf8(field)) + } + + /// Read a little-endian `u16`. + /// + /// # Errors + /// [`DecodeError::Truncated`] if fewer than 2 bytes remain. + pub fn read_u16_le(&mut self) -> Result { + Ok(u16::from_le_bytes(self.read_array()?)) + } +} + +/// Append a little-endian `u16` to `out`. +pub fn write_u16_le(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u32` to `out`. +pub fn write_u32_le(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u64` to `out`. +pub fn write_u64_le(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a `u32`-length-prefixed byte string to `out`. +pub fn write_bytes_u32_prefixed(out: &mut Vec, bytes: &[u8]) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; callers are expected to keep byte strings \ + within u32::MAX. The decode side enforces the real, \ + non-panicking rejection via Reader::read_bytes_u32_prefixed." + )] + let len = bytes.len() as u32; + write_u32_le(out, len); + out.extend_from_slice(bytes); +} + +/// Append a `u16`-length-prefixed UTF-8 string to `out`. +pub fn write_string_u16_prefixed(out: &mut Vec, value: &str) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; see write_bytes_u32_prefixed." + )] + let len = value.len() as u16; + write_u16_le(out, len); + out.extend_from_slice(value.as_bytes()); +} + +#[cfg(test)] +mod tests { + use super::{ + DecodeError, Reader, write_bytes_u32_prefixed, write_string_u16_prefixed, write_u16_le, + write_u32_le, write_u64_le, + }; + + #[test] + fn read_u8_truncated_on_empty() { + let mut reader = Reader::new(&[]); + assert_eq!(reader.read_u8().unwrap_err(), DecodeError::Truncated { + needed: 1, + available: 0 + }); + } + + #[test] + fn read_u32_le_round_trip() { + let mut buf = Vec::new(); + write_u32_le(&mut buf, 0x1234_5678); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u32_le().unwrap(), 0x1234_5678); + } + + #[test] + fn read_u64_le_round_trip() { + let mut buf = Vec::new(); + write_u64_le(&mut buf, 0x0102_0304_0506_0708); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u64_le().unwrap(), 0x0102_0304_0506_0708); + } + + #[test] + fn read_u16_le_round_trip() { + let mut buf = Vec::new(); + write_u16_le(&mut buf, 0xABCD); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_u16_le().unwrap(), 0xABCD); + } + + #[test] + fn length_prefixed_bytes_round_trip() { + let mut buf = Vec::new(); + write_bytes_u32_prefixed(&mut buf, b"hello"); + let mut reader = Reader::new(&buf); + assert_eq!(reader.read_bytes_u32_prefixed("f", 100).unwrap(), b"hello"); + } + + #[test] + fn length_prefixed_bytes_rejects_over_max_before_truncation() { + let mut buf = Vec::new(); + write_u32_le(&mut buf, 1000); + let mut reader = Reader::new(&buf); + let err = reader.read_bytes_u32_prefixed("f", 10).unwrap_err(); + assert_eq!(err, DecodeError::LengthOutOfBounds { + field: "f", + declared: 1000, + max: 10, + }); + } + + #[test] + fn string_round_trip() { + let mut buf = Vec::new(); + write_string_u16_prefixed(&mut buf, "hello world"); + let mut reader = Reader::new(&buf); + assert_eq!( + reader.read_string_u16_prefixed("f", 100).unwrap(), + "hello world" + ); + } + + #[test] + fn string_rejects_invalid_utf8() { + let mut buf = Vec::new(); + write_u16_le(&mut buf, 2); + buf.extend_from_slice(&[0xFF, 0xFE]); // invalid UTF-8 + let mut reader = Reader::new(&buf); + let err = reader.read_string_u16_prefixed("f", 100).unwrap_err(); + assert_eq!(err, DecodeError::InvalidUtf8("f")); + } + + #[test] + fn array_read_exact_width() { + let mut reader = Reader::new(&[1, 2, 3, 4, 5]); + let arr: [u8; 3] = reader.read_array().unwrap(); + assert_eq!(arr, [1, 2, 3]); + assert_eq!(reader.remaining(), 2); + } +} diff --git a/crates/uffs-content-reader-protocol/src/lib.rs b/crates/uffs-content-reader-protocol/src/lib.rs new file mode 100644 index 000000000..628e8b4b7 --- /dev/null +++ b/crates/uffs-content-reader-protocol/src/lib.rs @@ -0,0 +1,583 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Private wire protocol between `uffs-content` (the unprivileged Content +//! Coordinator) and the privileged Snapshot Reader process. +//! +//! Addendum §2.1-§2.4: the Coordinator never receives the snapshot +//! handle, LCNs, extents, or raw MFT records. It sends a typed +//! [`ReadRequest`] naming a candidate and a logical byte range; the +//! Reader resolves the stream itself, validates the range against the +//! snapshot's own EOF/VDL, and returns bounded logical bytes or a typed +//! error — never accepting an arbitrary `(physical_offset, length)`. +//! +//! # Status +//! +//! Scaffold: types and wire codec only. No transport (named pipe) +//! implementation yet — that lives in `uffs-content` (client side) and +//! the Snapshot Reader binary (server side) once they exist. + +pub mod codec; + +use codec::{ + DecodeError, Reader, write_bytes_u32_prefixed, write_string_u16_prefixed, write_u32_le, + write_u64_le, +}; + +/// Named-pipe path the Snapshot Reader listens on. +/// +/// Deliberately separate from `uffs-broker-protocol::PIPE_NAME` (daemon +/// <-> Broker) and from the Broker's Snapshot Manager endpoint +/// (`uffs-broker-protocol::SNAPSHOT_PIPE_NAME`, once that lands) — +/// Coordinator<->Reader is a distinct channel with a distinct peer and +/// distinct trust check, per +/// `uffs-ingest-implementation-plan.md` §3.3. +pub const READER_PIPE_NAME: &str = r"\\.\pipe\uffs-content-reader"; + +/// Maximum byte length for `snapshot_device_identity`-style opaque +/// identifier fields in this protocol. +pub const MAX_IDENTIFIER_BYTES: u32 = 512; + +/// Maximum byte length for a free-text diagnostic message. +const MAX_MESSAGE_BYTES: u16 = 4096; + +/// A volume's identity, as carried in a [`ReadRequest`] (addendum §2.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VolumeIdentity { + /// NTFS volume serial number. + pub volume_serial: u64, + /// Opaque volume GUID bytes. + pub volume_guid: Vec, +} + +impl VolumeIdentity { + /// Append this identity's wire encoding to `out`. + fn encode(&self, out: &mut Vec) { + write_u64_le(out, self.volume_serial); + write_bytes_u32_prefixed(out, &self.volume_guid); + } + + /// Decode an identity from `reader`. + fn decode(reader: &mut Reader<'_>) -> Result { + let volume_serial = reader.read_u64_le()?; + let volume_guid = reader.read_bytes_u32_prefixed("volume_guid", MAX_IDENTIFIER_BYTES)?; + Ok(Self { + volume_serial, + volume_guid, + }) + } +} + +/// Which stream a [`ReadRequest`] targets. +/// +/// Only [`StreamKind::UnnamedData`] exists in v2 (design-doc §2.6: no ADS +/// in this version) — modeled as an enum so a future version can add +/// variants without breaking the field shape. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum StreamKind { + /// The unnamed/default `$DATA` stream. + UnnamedData = 0, +} + +impl StreamKind { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::UnnamedData), + other => Err(other), + } + } +} + +/// The read mode a [`ReadRequest`] asks the Reader to use. +/// +/// Addendum §2.3/§3.6 planner. Distinct from +/// `uffs_content_protocol::frame::ReadMode` (which reports what mode was +/// *actually* used, after the fact, and includes `Resident`/`MetadataOnly` +/// — concepts that don't apply to an outgoing request). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum RequestedReadMode { + /// Let the Reader choose the best available mode (including resident + /// inline reads where applicable). + Auto = 0, + /// Require a logical snapshot-namespace read. + Logical = 1, + /// Require the benchmark-gated raw accelerator (only valid once + /// UFI.3 approves it — see addendum §3). + RawAccelerator = 2, +} + +impl RequestedReadMode { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Auto), + 1 => Ok(Self::Logical), + 2 => Ok(Self::RawAccelerator), + other => Err(other), + } + } +} + +/// The read mode a [`ReadResponse::Bytes`] reports as actually used. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum ActualReadMode { + /// Read from the MFT-resident attribute value. + Resident = 0, + /// Logical open + read against the snapshot namespace. + Logical = 1, + /// Raw runlist/extent reconstruction. + RawAccelerator = 2, +} + +impl ActualReadMode { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Resident), + 1 => Ok(Self::Logical), + 2 => Ok(Self::RawAccelerator), + other => Err(other), + } + } +} + +/// Stable error codes for a [`ReadResponse::Error`]. +/// +/// A narrow subset of `uffs_content_protocol::error::ErrorCode` relevant +/// specifically to a single read — duplicated rather than shared, per +/// this crate's Layer-0-independence rationale (see `Cargo.toml`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +#[non_exhaustive] +pub enum ReaderErrorCode { + /// The requested snapshot lease is not known to the Reader. + LeaseInvalid = 0, + /// The requested snapshot lease has expired. + LeaseExpired = 1, + /// The candidate is not part of the finalized manifest the Reader + /// was given for this job. + CandidateNotInManifest = 2, + /// The opened object's identity does not match the manifest + /// (design-doc §5.2/§16 `IDENTITY_MISMATCH`). + IdentityMismatch = 3, + /// The requested stream was not found. + StreamNotFound = 4, + /// The requested logical range violates the EOF/VDL relationship. + VdlEofInvalid = 5, + /// The requested range falls outside validated bounds. + ExtentOutOfBounds = 6, + /// A transient I/O error occurred. + ReadIoTransient = 7, + /// A permanent I/O error occurred. + ReadIoPermanent = 8, + /// An internal Reader error not covered by another code. + InternalError = 9, +} + +impl ReaderErrorCode { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::LeaseInvalid), + 1 => Ok(Self::LeaseExpired), + 2 => Ok(Self::CandidateNotInManifest), + 3 => Ok(Self::IdentityMismatch), + 4 => Ok(Self::StreamNotFound), + 5 => Ok(Self::VdlEofInvalid), + 6 => Ok(Self::ExtentOutOfBounds), + 7 => Ok(Self::ReadIoTransient), + 8 => Ok(Self::ReadIoPermanent), + 9 => Ok(Self::InternalError), + other => Err(other), + } + } +} + +/// A read request from the Coordinator to the Reader (addendum §2.3). +/// +/// The Reader MUST treat every field here as untrusted input to be +/// revalidated against the snapshot itself — `logical_offset` + +/// `maximum_logical_length` is never trusted as an in-bounds range purely +/// because the Coordinator sent it (design-doc §15.4, this crate's +/// `README`-level contract in the implementation plan §3.2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadRequest { + /// Job this read belongs to. + pub job_id: [u8; 16], + /// Snapshot lease this read is scoped to. + pub snapshot_lease_id: u64, + /// Candidate being read. + pub candidate_id: u64, + /// Volume the candidate lives on. + pub volume_identity: VolumeIdentity, + /// Full NTFS file reference (never a bare MFT record index). + pub full_file_reference: u64, + /// Which stream to read. + pub stream_kind: StreamKind, + /// Logical byte offset to start reading at. + pub logical_offset: u64, + /// Maximum bytes to return for this request. + pub maximum_logical_length: u32, + /// Which read mode to use. + pub requested_mode: RequestedReadMode, + /// Caller-chosen nonce, echoed back for request/response correlation. + pub request_nonce: u64, +} + +impl ReadRequest { + /// Encode this request. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.job_id); + write_u64_le(&mut out, self.snapshot_lease_id); + write_u64_le(&mut out, self.candidate_id); + self.volume_identity.encode(&mut out); + write_u64_le(&mut out, self.full_file_reference); + out.push(self.stream_kind.encode()); + write_u64_le(&mut out, self.logical_offset); + write_u32_le(&mut out, self.maximum_logical_length); + out.push(self.requested_mode.encode()); + write_u64_le(&mut out, self.request_nonce); + out + } + + /// Decode a request from `reader`. + /// + /// # Errors + /// See [`DecodeError`]. + pub fn decode(reader: &mut Reader<'_>) -> Result { + let job_id: [u8; 16] = reader.read_array()?; + let snapshot_lease_id = reader.read_u64_le()?; + let candidate_id = reader.read_u64_le()?; + let volume_identity = VolumeIdentity::decode(reader)?; + let full_file_reference = reader.read_u64_le()?; + let stream_kind_byte = reader.read_u8()?; + let stream_kind = StreamKind::decode(stream_kind_byte).map_err(|byte| { + DecodeError::UnknownDiscriminant { + field: "stream_kind", + value: u64::from(byte), + } + })?; + let logical_offset = reader.read_u64_le()?; + let maximum_logical_length = reader.read_u32_le()?; + let requested_mode_byte = reader.read_u8()?; + let requested_mode = RequestedReadMode::decode(requested_mode_byte).map_err(|byte| { + DecodeError::UnknownDiscriminant { + field: "requested_mode", + value: u64::from(byte), + } + })?; + let request_nonce = reader.read_u64_le()?; + Ok(Self { + job_id, + snapshot_lease_id, + candidate_id, + volume_identity, + full_file_reference, + stream_kind, + logical_offset, + maximum_logical_length, + requested_mode, + request_nonce, + }) + } +} + +/// A read response from the Reader to the Coordinator (addendum §3.2). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReadResponse { + /// The request succeeded; `payload` is bounded by the request's + /// `maximum_logical_length`. + Bytes { + /// Logical offset these bytes start at (echoes the request). + logical_offset: u64, + /// Which mode the Reader actually used. + actual_mode: ActualReadMode, + /// The logical bytes read. + payload: Vec, + }, + /// The request failed. + Error { + /// Stable error code. + code: ReaderErrorCode, + /// Human-readable diagnostic message. + message: String, + }, +} + +/// Wire discriminant for [`ReadResponse`]'s two variants. +const RESPONSE_TAG_BYTES: u8 = 0; +/// Wire discriminant for [`ReadResponse`]'s two variants. +const RESPONSE_TAG_ERROR: u8 = 1; + +/// Bound on a single [`ReadResponse::Bytes`] payload. +/// +/// A conservative chunk ceiling; the Coordinator's own `max_chunk_bytes` +/// (see `uffs_content_protocol::frame::JobBegin`) governs the real +/// negotiated value. +pub const MAX_RESPONSE_PAYLOAD_BYTES: u32 = 64 * 1024 * 1024; + +impl ReadResponse { + /// Encode this response. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + match self { + Self::Bytes { + logical_offset, + actual_mode, + payload, + } => { + out.push(RESPONSE_TAG_BYTES); + write_u64_le(&mut out, *logical_offset); + out.push(actual_mode.encode()); + write_bytes_u32_prefixed(&mut out, payload); + } + Self::Error { code, message } => { + out.push(RESPONSE_TAG_ERROR); + out.push(code.encode()); + write_string_u16_prefixed(&mut out, message); + } + } + out + } + + /// Decode a response from `reader`. + /// + /// `max_payload_bytes` bounds the `Bytes` payload before allocation. + /// + /// # Errors + /// See [`DecodeError`]. + pub fn decode(reader: &mut Reader<'_>, max_payload_bytes: u32) -> Result { + let tag = reader.read_u8()?; + match tag { + RESPONSE_TAG_BYTES => { + let logical_offset = reader.read_u64_le()?; + let mode_byte = reader.read_u8()?; + let actual_mode = ActualReadMode::decode(mode_byte).map_err(|byte| { + DecodeError::UnknownDiscriminant { + field: "actual_mode", + value: u64::from(byte), + } + })?; + let payload = reader.read_bytes_u32_prefixed("payload", max_payload_bytes)?; + Ok(Self::Bytes { + logical_offset, + actual_mode, + payload, + }) + } + RESPONSE_TAG_ERROR => { + let code_byte = reader.read_u8()?; + let code = ReaderErrorCode::decode(code_byte).map_err(|byte| { + DecodeError::UnknownDiscriminant { + field: "code", + value: u64::from(byte), + } + })?; + let message = reader.read_string_u16_prefixed("message", MAX_MESSAGE_BYTES)?; + Ok(Self::Error { code, message }) + } + other => Err(DecodeError::UnknownDiscriminant { + field: "response_tag", + value: u64::from(other), + }), + } + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::{ + ActualReadMode, DecodeError, ReadRequest, ReadResponse, Reader, ReaderErrorCode, + RequestedReadMode, StreamKind, VolumeIdentity, + }; + + fn sample_request() -> ReadRequest { + ReadRequest { + job_id: [1_u8; 16], + snapshot_lease_id: 42, + candidate_id: 12345, + volume_identity: VolumeIdentity { + volume_serial: 0x0102_0304_0506_0708, + volume_guid: b"{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}".to_vec(), + }, + full_file_reference: 0xABCD_EF01_2345_6789, + stream_kind: StreamKind::UnnamedData, + logical_offset: 4096, + maximum_logical_length: 65536, + requested_mode: RequestedReadMode::Auto, + request_nonce: 999, + } + } + + #[test] + fn read_request_round_trips() { + let request = sample_request(); + let bytes = request.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ReadRequest::decode(&mut reader).unwrap(); + assert_eq!(decoded, request); + assert_eq!(reader.remaining(), 0); + } + + #[test] + fn read_response_bytes_round_trips() { + let response = ReadResponse::Bytes { + logical_offset: 4096, + actual_mode: ActualReadMode::Logical, + payload: vec![1, 2, 3, 4, 5], + }; + let bytes = response.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ReadResponse::decode(&mut reader, 1024).unwrap(); + assert_eq!(decoded, response); + } + + #[test] + fn read_response_error_round_trips() { + let response = ReadResponse::Error { + code: ReaderErrorCode::VdlEofInvalid, + message: "requested range past EOF".to_owned(), + }; + let bytes = response.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ReadResponse::decode(&mut reader, 1024).unwrap(); + assert_eq!(decoded, response); + } + + #[test] + fn read_response_bytes_rejects_payload_over_max_before_allocation() { + let response = ReadResponse::Bytes { + logical_offset: 0, + actual_mode: ActualReadMode::Resident, + payload: vec![0_u8; 100], + }; + let bytes = response.encode(); + let mut reader = Reader::new(&bytes); + let err = ReadResponse::decode(&mut reader, 10).unwrap_err(); + assert!(matches!(err, DecodeError::LengthOutOfBounds { .. })); + } + + #[test] + fn read_response_rejects_unknown_tag() { + let bytes = vec![0xFF]; + let mut reader = Reader::new(&bytes); + let err = ReadResponse::decode(&mut reader, 1024).unwrap_err(); + assert!(matches!(err, DecodeError::UnknownDiscriminant { + field: "response_tag", + .. + })); + } + + #[test] + fn requested_read_mode_round_trips_all_variants() { + for value in 0_u8..=2 { + let mode = RequestedReadMode::decode(value).unwrap(); + assert_eq!(mode.encode(), value); + } + assert_eq!(RequestedReadMode::decode(3), Err(3)); + } + + #[test] + fn actual_read_mode_round_trips_all_variants() { + for value in 0_u8..=2 { + let mode = ActualReadMode::decode(value).unwrap(); + assert_eq!(mode.encode(), value); + } + assert_eq!(ActualReadMode::decode(3), Err(3)); + } + + #[test] + fn reader_error_code_round_trips_all_variants() { + for value in 0_u8..=9 { + let code = ReaderErrorCode::decode(value).unwrap(); + assert_eq!(code.encode(), value); + } + assert_eq!(ReaderErrorCode::decode(10), Err(10)); + } + + #[test] + fn stream_kind_round_trips() { + assert_eq!(StreamKind::decode(0).unwrap(), StreamKind::UnnamedData); + assert_eq!(StreamKind::decode(1), Err(1)); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(200))] + + #[test] + fn read_request_round_trips_for_arbitrary_fields( + snapshot_lease_id: u64, + candidate_id: u64, + volume_serial: u64, + full_file_reference: u64, + logical_offset: u64, + maximum_logical_length: u32, + request_nonce: u64, + ) { + let request = ReadRequest { + job_id: [7_u8; 16], + snapshot_lease_id, + candidate_id, + volume_identity: VolumeIdentity { + volume_serial, + volume_guid: b"{guid}".to_vec(), + }, + full_file_reference, + stream_kind: StreamKind::UnnamedData, + logical_offset, + maximum_logical_length, + requested_mode: RequestedReadMode::Logical, + request_nonce, + }; + let bytes = request.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ReadRequest::decode(&mut reader).unwrap(); + prop_assert_eq!(decoded, request); + } + } +} From 56f43dc12aaff387c39e4403940d99a65374b2e9 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:24:16 -0700 Subject: [PATCH 10/98] feat(broker-protocol): Snapshot Manager wire protocol (UFI.1) Extends uffs-broker-protocol with the Broker's VSS-lifecycle API per addendum S4.3: CreateSnapshotLease(Result), DuplicateSnapshotHandle, RenewSnapshotLease, ReleaseSnapshotLease, QuerySnapshotLease, wrapped in tagged SnapshotManagerRequest/Response envelopes, over a new SNAPSHOT_PIPE_NAME distinct from the existing daemon<->Broker PIPE_NAME. DuplicateSnapshotHandle only carries the reader PID over the wire - the actual DuplicateHandle call and identity verification happen Broker-side via the existing check_client_identity pattern, never trusted from the wire (documented on the type). Split into snapshot_manager/{mod,codec,messages}.rs + a sibling tests.rs (same pattern as uffs-content-protocol/src/frame/) since one flat file would have exceeded the 800-LOC file-size gate. codec.rs duplicates the same small bounds-checked LE primitives as the two content-protocol crates rather than sharing them, for the same Layer-0-independence reason. Existing HandleRequest/HandleResponse (daemon<->Broker MFT handle protocol) are unchanged. 19 new tests (52 total in the crate), clean under lint-prod + lint-tests + file-size policy. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-broker-protocol/src/lib.rs | 2 + .../src/snapshot_manager/codec.rs | 207 +++++++++ .../src/snapshot_manager/messages.rs | 409 ++++++++++++++++++ .../src/snapshot_manager/mod.rs | 241 +++++++++++ .../src/snapshot_manager/tests.rs | 219 ++++++++++ 5 files changed, 1078 insertions(+) create mode 100644 crates/uffs-broker-protocol/src/snapshot_manager/codec.rs create mode 100644 crates/uffs-broker-protocol/src/snapshot_manager/messages.rs create mode 100644 crates/uffs-broker-protocol/src/snapshot_manager/mod.rs create mode 100644 crates/uffs-broker-protocol/src/snapshot_manager/tests.rs diff --git a/crates/uffs-broker-protocol/src/lib.rs b/crates/uffs-broker-protocol/src/lib.rs index 79e8aed51..6f01494e0 100644 --- a/crates/uffs-broker-protocol/src/lib.rs +++ b/crates/uffs-broker-protocol/src/lib.rs @@ -48,6 +48,8 @@ use thiserror::Error; +pub mod snapshot_manager; + /// Named-pipe path the broker listens on. /// /// Both the broker server (`uffs-broker::broker`) and the daemon client diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs b/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs new file mode 100644 index 000000000..3c7241357 --- /dev/null +++ b/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Minimal bounds-checked little-endian decode primitives for +//! [`super`] (`snapshot_manager`). + +use thiserror::Error; + +/// Errors decoding wire bytes. +/// +/// A deliberately small, independent duplicate of the same bounds-checked +/// LE decode shape used by `uffs-content-protocol` and +/// `uffs-content-reader-protocol` — this crate stays Layer-0-independent +/// of both (see those crates' `Cargo.toml` for the shared rationale). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum SnapshotProtocolError { + /// Fewer bytes remained than the field being read requires. + #[error("truncated input: needed {needed} bytes, only {available} remained")] + Truncated { + /// Bytes required to satisfy the read. + needed: usize, + /// Bytes actually remaining in the input. + available: usize, + }, + /// A length-prefixed field declared more bytes than the caller's + /// configured maximum allows, checked before allocation. + #[error("field '{field}' declared length {declared} exceeds maximum {max}")] + LengthOutOfBounds { + /// Name of the offending field, for diagnostics. + field: &'static str, + /// Length the wire bytes claimed. + declared: u64, + /// Maximum length the caller configured. + max: u64, + }, + /// A discriminant byte did not match any known enum/message variant. + #[error("unknown discriminant for '{field}': {value}")] + UnknownDiscriminant { + /// Name of the field being decoded, for diagnostics. + field: &'static str, + /// The unrecognized value. + value: u64, + }, + /// A string field was not valid UTF-8. + #[error("field '{0}' is not valid UTF-8")] + InvalidUtf8(&'static str), +} + +/// Bounds-checked little-endian cursor over a decode input buffer. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Reader<'a> { + /// Backing bytes being decoded. + buf: &'a [u8], + /// Read offset into `buf`; always `<= buf.len()`. + pos: usize, +} + +impl<'a> Reader<'a> { + /// Wrap `buf` for bounds-checked reading, starting at offset 0. + pub(crate) const fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + /// Bytes not yet consumed. + pub(crate) const fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + /// Consume and return exactly `len` bytes, or a + /// [`SnapshotProtocolError::Truncated`] if fewer remain. + fn take(&mut self, len: usize) -> Result<&'a [u8], SnapshotProtocolError> { + let available = self.remaining(); + if available < len { + return Err(SnapshotProtocolError::Truncated { + needed: len, + available, + }); + } + let start = self.pos; + let slice = self + .buf + .get(start..start + len) + .ok_or(SnapshotProtocolError::Truncated { + needed: len, + available, + })?; + self.pos += len; + Ok(slice) + } + + /// Read a single byte. + pub(crate) fn read_u8(&mut self) -> Result { + let bytes = self.take(1)?; + bytes + .first() + .copied() + .ok_or(SnapshotProtocolError::Truncated { + needed: 1, + available: 0, + }) + } + + /// Read exactly `N` raw bytes. + pub(crate) fn read_array(&mut self) -> Result<[u8; N], SnapshotProtocolError> { + let bytes = self.take(N)?; + let mut out = [0_u8; N]; + out.copy_from_slice(bytes); + Ok(out) + } + + /// Read a little-endian `u32`. + pub(crate) fn read_u32_le(&mut self) -> Result { + Ok(u32::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `u64`. + pub(crate) fn read_u64_le(&mut self) -> Result { + Ok(u64::from_le_bytes(self.read_array()?)) + } + + /// Read a little-endian `i64`. + pub(crate) fn read_i64_le(&mut self) -> Result { + Ok(self.read_u64_le()?.cast_signed()) + } + + /// Read a `u32`-length-prefixed byte string, rejecting (before any + /// allocation) a declared length exceeding `max_len` or the bytes + /// actually remaining. + pub(crate) fn read_bytes_u32_prefixed( + &mut self, + field: &'static str, + max_len: u32, + ) -> Result, SnapshotProtocolError> { + let len = self.read_u32_le()?; + if len > max_len { + return Err(SnapshotProtocolError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + let bytes = self.take(len as usize)?; + Ok(bytes.to_vec()) + } + + /// Read a `u16`-length-prefixed UTF-8 string. + pub(crate) fn read_string_u16_prefixed( + &mut self, + field: &'static str, + max_len: u16, + ) -> Result { + let len = u16::from_le_bytes(self.read_array()?); + if len > max_len { + return Err(SnapshotProtocolError::LengthOutOfBounds { + field, + declared: u64::from(len), + max: u64::from(max_len), + }); + } + let bytes = self.take(len as usize)?; + String::from_utf8(bytes.to_vec()).map_err(|_err| SnapshotProtocolError::InvalidUtf8(field)) + } +} + +/// Append a little-endian `u16` to `out`. +pub(crate) fn write_u16_le(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u32` to `out`. +pub(crate) fn write_u32_le(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `u64` to `out`. +pub(crate) fn write_u64_le(out: &mut Vec, value: u64) { + out.extend_from_slice(&value.to_le_bytes()); +} + +/// Append a little-endian `i64` to `out`. +pub(crate) fn write_i64_le(out: &mut Vec, value: i64) { + out.extend_from_slice(&value.cast_unsigned().to_le_bytes()); +} + +/// Append a `u32`-length-prefixed byte string to `out`. +pub(crate) fn write_bytes_u32_prefixed(out: &mut Vec, bytes: &[u8]) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; callers keep byte strings within u32::MAX. \ + The decode side enforces the real, non-panicking rejection." + )] + let len = bytes.len() as u32; + write_u32_le(out, len); + out.extend_from_slice(bytes); +} + +/// Append a `u16`-length-prefixed UTF-8 string to `out`. +pub(crate) fn write_string_u16_prefixed(out: &mut Vec, value: &str) { + #[expect( + clippy::cast_possible_truncation, + reason = "encode-side only; see write_bytes_u32_prefixed." + )] + let len = value.len() as u16; + write_u16_le(out, len); + out.extend_from_slice(value.as_bytes()); +} diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs b/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs new file mode 100644 index 000000000..da54fb172 --- /dev/null +++ b/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs @@ -0,0 +1,409 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Individual Snapshot Manager request/response message types +//! (addendum §4.3). See [`super`] for the tagged +//! [`super::SnapshotManagerRequest`]/[`super::SnapshotManagerResponse`] +//! envelopes that wrap these for transport. + +use super::codec::{ + Reader, SnapshotProtocolError, write_bytes_u32_prefixed, write_i64_le, + write_string_u16_prefixed, write_u32_le, write_u64_le, +}; +use super::{MAX_IDENTIFIER_BYTES, MAX_PATH_BYTES}; + +/// A source volume's identity (matches the shape carried elsewhere in +/// the UFFS content-ingest protocols; duplicated here for the same +/// Layer-0-independence reason). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VolumeIdentity { + /// NTFS volume serial number. + pub volume_serial: u64, + /// Opaque volume GUID bytes. + pub volume_guid: Vec, +} + +impl VolumeIdentity { + /// Append this identity's wire encoding to `out`. + fn encode(&self, out: &mut Vec) { + write_u64_le(out, self.volume_serial); + write_bytes_u32_prefixed(out, &self.volume_guid); + } + + /// Decode an identity from `reader`. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let volume_serial = reader.read_u64_le()?; + let volume_guid = reader.read_bytes_u32_prefixed("volume_guid", MAX_IDENTIFIER_BYTES)?; + Ok(Self { + volume_serial, + volume_guid, + }) + } +} + +/// `CreateSnapshotLease` request (addendum §4.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSnapshotLease { + /// Job this snapshot is being created for. + pub authenticated_job_id: [u8; 16], + /// Identity of the volume to snapshot. + pub source_volume_identity: VolumeIdentity, + /// Requested root, as lossless UTF-16LE code-unit bytes. + pub requested_root: Vec, + /// Maximum lease lifetime, in seconds. + pub maximum_lifetime_secs: u64, + /// Policy this job was authorized under. + pub policy_id: u32, +} + +impl CreateSnapshotLease { + /// Encode this request. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.authenticated_job_id); + self.source_volume_identity.encode(&mut out); + write_bytes_u32_prefixed(&mut out, &self.requested_root); + write_u64_le(&mut out, self.maximum_lifetime_secs); + write_u32_le(&mut out, self.policy_id); + out + } + + /// Decode this request. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let authenticated_job_id: [u8; 16] = reader.read_array()?; + let source_volume_identity = VolumeIdentity::decode(reader)?; + let requested_root = reader.read_bytes_u32_prefixed("requested_root", MAX_PATH_BYTES)?; + let maximum_lifetime_secs = reader.read_u64_le()?; + let policy_id = reader.read_u32_le()?; + Ok(Self { + authenticated_job_id, + source_volume_identity, + requested_root, + maximum_lifetime_secs, + policy_id, + }) + } +} + +/// `CreateSnapshotLeaseResult` response (addendum §4.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSnapshotLeaseResult { + /// Lease identifier the Coordinator uses in all subsequent calls. + pub snapshot_lease_id: u64, + /// Opaque VSS snapshot identifier. + pub snapshot_id: Vec, + /// Device path the snapshot is reachable at (e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`), as a UTF-8 + /// string — this device identity is safe to hand to the Coordinator; + /// it is not a handle or LCN/extent information. + pub snapshot_device_identity: String, + /// Snapshot creation time, Unix milliseconds. + pub snapshot_created_at_unix_ms: i64, + /// Current lease expiry time, Unix milliseconds. + pub expires_at_unix_ms: i64, +} + +impl CreateSnapshotLeaseResult { + /// Encode this result. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + write_bytes_u32_prefixed(&mut out, &self.snapshot_id); + write_string_u16_prefixed(&mut out, &self.snapshot_device_identity); + write_i64_le(&mut out, self.snapshot_created_at_unix_ms); + write_i64_le(&mut out, self.expires_at_unix_ms); + out + } + + /// Decode this result. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let snapshot_lease_id = reader.read_u64_le()?; + let snapshot_id = reader.read_bytes_u32_prefixed("snapshot_id", MAX_IDENTIFIER_BYTES)?; + let snapshot_device_identity = + reader.read_string_u16_prefixed("snapshot_device_identity", 2048)?; + let snapshot_created_at_unix_ms = reader.read_i64_le()?; + let expires_at_unix_ms = reader.read_i64_le()?; + Ok(Self { + snapshot_lease_id, + snapshot_id, + snapshot_device_identity, + snapshot_created_at_unix_ms, + expires_at_unix_ms, + }) + } +} + +/// `DuplicateSnapshotHandle` request (addendum §4.3). +/// +/// The actual `DuplicateHandle` call happens Broker-side, out of band; +/// this wire message only names which already-authenticated reader +/// process the Broker should duplicate the handle *into* — the Broker +/// verifies that process's identity itself (reusing the same +/// `check_client_identity` pattern already used for daemon +/// verification), it does not trust this PID as proof of anything. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DuplicateSnapshotHandle { + /// Lease the handle should be scoped to. + pub snapshot_lease_id: u64, + /// PID of the Snapshot Reader process to duplicate the handle into. + pub approved_reader_process_id: u32, +} + +impl DuplicateSnapshotHandle { + /// Encode this request. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + write_u32_le(&mut out, self.approved_reader_process_id); + out + } + + /// Decode this request. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let snapshot_lease_id = reader.read_u64_le()?; + let approved_reader_process_id = reader.read_u32_le()?; + Ok(Self { + snapshot_lease_id, + approved_reader_process_id, + }) + } +} + +/// `RenewSnapshotLease` request (addendum §4.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenewSnapshotLease { + /// Lease to renew. + pub snapshot_lease_id: u64, + /// Requested new expiry, Unix milliseconds. + pub requested_expiry_unix_ms: i64, +} + +impl RenewSnapshotLease { + /// Encode this request. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + write_i64_le(&mut out, self.requested_expiry_unix_ms); + out + } + + /// Decode this request. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let snapshot_lease_id = reader.read_u64_le()?; + let requested_expiry_unix_ms = reader.read_i64_le()?; + Ok(Self { + snapshot_lease_id, + requested_expiry_unix_ms, + }) + } +} + +/// `ReleaseSnapshotLease` request (addendum §4.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReleaseSnapshotLease { + /// Lease to release. + pub snapshot_lease_id: u64, +} + +impl ReleaseSnapshotLease { + /// Encode this request. + #[must_use] + pub fn encode(self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + out + } + + /// Decode this request. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self { + snapshot_lease_id: reader.read_u64_le()?, + }) + } +} + +/// `QuerySnapshotLease` request (addendum §4.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuerySnapshotLease { + /// Lease to query. + pub snapshot_lease_id: u64, +} + +impl QuerySnapshotLease { + /// Encode this request. + #[must_use] + pub fn encode(self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + out + } + + /// Decode this request. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + Ok(Self { + snapshot_lease_id: reader.read_u64_le()?, + }) + } +} + +/// Current state of a snapshot lease, reported by [`QuerySnapshotLease`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum SnapshotLeaseState { + /// Lease is active and the snapshot is retained. + Active = 0, + /// Lease expired without renewal. + Expired = 1, + /// Lease was explicitly released. + Released = 2, + /// The Broker has no record of this lease (unknown or already reaped). + Unknown = 3, +} + +impl SnapshotLeaseState { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::Active), + 1 => Ok(Self::Expired), + 2 => Ok(Self::Released), + 3 => Ok(Self::Unknown), + other => Err(other), + } + } +} + +/// `QuerySnapshotLease` response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotLeaseStatus { + /// Lease being reported on. + pub snapshot_lease_id: u64, + /// Current state. + pub state: SnapshotLeaseState, + /// Opaque VSS snapshot identifier (empty if + /// [`SnapshotLeaseState::Unknown`]). + pub snapshot_id: Vec, + /// Creation time, Unix milliseconds. + pub created_at_unix_ms: i64, + /// Expiry time, Unix milliseconds. + pub expires_at_unix_ms: i64, +} + +impl SnapshotLeaseStatus { + /// Encode this status. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.snapshot_lease_id); + out.push(self.state.encode()); + write_bytes_u32_prefixed(&mut out, &self.snapshot_id); + write_i64_le(&mut out, self.created_at_unix_ms); + write_i64_le(&mut out, self.expires_at_unix_ms); + out + } + + /// Decode this status. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub(crate) fn decode(reader: &mut Reader<'_>) -> Result { + let snapshot_lease_id = reader.read_u64_le()?; + let state_byte = reader.read_u8()?; + let state = SnapshotLeaseState::decode(state_byte).map_err(|byte| { + SnapshotProtocolError::UnknownDiscriminant { + field: "state", + value: u64::from(byte), + } + })?; + let snapshot_id = reader.read_bytes_u32_prefixed("snapshot_id", MAX_IDENTIFIER_BYTES)?; + let created_at_unix_ms = reader.read_i64_le()?; + let expires_at_unix_ms = reader.read_i64_le()?; + Ok(Self { + snapshot_lease_id, + state, + snapshot_id, + created_at_unix_ms, + expires_at_unix_ms, + }) + } +} + +/// Stable error codes for a [`SnapshotManagerResponse::Error`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +#[non_exhaustive] +pub enum SnapshotManagerErrorCode { + /// VSS snapshot creation failed. + SnapshotCreateFailed = 0, + /// The requested volume could not be validated. + VolumeValidationFailed = 1, + /// The caller's identity/authorization failed validation. + Unauthorized = 2, + /// The named lease is not known to the Broker. + LeaseNotFound = 3, + /// The named lease has already expired or been released. + LeaseNotActive = 4, + /// The approved reader process failed identity verification. + ReaderIdentityRejected = 5, + /// Copy-on-write storage pressure prevents further retention. + SnapshotStorageExhausted = 6, + /// An internal Broker error not covered by another code. + InternalError = 7, +} + +impl SnapshotManagerErrorCode { + /// Serialize to the wire byte. + #[must_use] + pub const fn encode(self) -> u8 { + self as u8 + } + + /// Parse the wire byte. + /// + /// # Errors + /// Returns the offending byte if unrecognized. + pub const fn decode(byte: u8) -> Result { + match byte { + 0 => Ok(Self::SnapshotCreateFailed), + 1 => Ok(Self::VolumeValidationFailed), + 2 => Ok(Self::Unauthorized), + 3 => Ok(Self::LeaseNotFound), + 4 => Ok(Self::LeaseNotActive), + 5 => Ok(Self::ReaderIdentityRejected), + 6 => Ok(Self::SnapshotStorageExhausted), + 7 => Ok(Self::InternalError), + other => Err(other), + } + } +} diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs b/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs new file mode 100644 index 000000000..8614ee7eb --- /dev/null +++ b/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Snapshot Manager wire protocol: the Broker's VSS-lifecycle API. +//! +//! Per the ingest-protocol addendum §4, the Broker (already the trusted +//! `LocalSystem` boundary for MFT handle vending) is extended with a +//! narrow Snapshot Manager subsystem that creates, leases, monitors, and +//! deletes VSS snapshots on behalf of `uffs-content` (the Content +//! Coordinator), and duplicates a read-only snapshot handle only to the +//! Snapshot Reader process — never to the Coordinator itself. +//! +//! This is deliberately **not** a general-purpose VSS administration API +//! (addendum §4.3): five narrow operations plus a status query. +//! +//! Uses a separate named pipe from [`crate::PIPE_NAME`] (the existing +//! Broker↔daemon MFT-handle channel) — Coordinator↔Broker is a distinct +//! channel with a distinct peer and distinct trust check, per +//! `uffs-ingest-implementation-plan.md` §4.1. + +mod codec; +mod messages; + +pub use codec::SnapshotProtocolError; +use codec::{Reader, write_i64_le, write_string_u16_prefixed}; +pub use messages::{ + CreateSnapshotLease, CreateSnapshotLeaseResult, DuplicateSnapshotHandle, QuerySnapshotLease, + ReleaseSnapshotLease, RenewSnapshotLease, SnapshotLeaseState, SnapshotLeaseStatus, + SnapshotManagerErrorCode, VolumeIdentity, +}; + +/// Named-pipe path the Broker's Snapshot Manager listens on. +/// +/// Distinct from [`crate::PIPE_NAME`] (daemon↔Broker MFT handle vending) +/// and from `uffs-content-reader-protocol::READER_PIPE_NAME` +/// (Coordinator↔Snapshot Reader). +pub const SNAPSHOT_PIPE_NAME: &str = r"\\.\pipe\uffs-broker-snapshot"; + +/// Maximum byte length for opaque identifier fields (volume GUIDs, +/// snapshot IDs, device identity strings) in this protocol. +pub const MAX_IDENTIFIER_BYTES: u32 = 1024; + +/// Maximum byte length for a lossless UTF-16LE `requested_root` path. +pub const MAX_PATH_BYTES: u32 = 32_767 * 2; + +/// Maximum byte length for a free-text diagnostic message. +const MAX_MESSAGE_BYTES: u16 = 4096; + +/// Tagged union of every Snapshot Manager request (addendum §4.3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapshotManagerRequest { + /// See [`CreateSnapshotLease`]. + Create(CreateSnapshotLease), + /// See [`DuplicateSnapshotHandle`]. + Duplicate(DuplicateSnapshotHandle), + /// See [`RenewSnapshotLease`]. + Renew(RenewSnapshotLease), + /// See [`ReleaseSnapshotLease`]. + Release(ReleaseSnapshotLease), + /// See [`QuerySnapshotLease`]. + Query(QuerySnapshotLease), +} + +/// Wire discriminant tags for [`SnapshotManagerRequest`]. +pub(crate) mod request_tag { + /// Tag for [`super::SnapshotManagerRequest::Create`]. + pub(crate) const CREATE: u8 = 0; + /// Tag for [`super::SnapshotManagerRequest::Duplicate`]. + pub(crate) const DUPLICATE: u8 = 1; + /// Tag for [`super::SnapshotManagerRequest::Renew`]. + pub(crate) const RENEW: u8 = 2; + /// Tag for [`super::SnapshotManagerRequest::Release`]. + pub(crate) const RELEASE: u8 = 3; + /// Tag for [`super::SnapshotManagerRequest::Query`]. + pub(crate) const QUERY: u8 = 4; +} + +impl SnapshotManagerRequest { + /// Encode this request. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + match self { + Self::Create(request) => { + out.push(request_tag::CREATE); + out.extend_from_slice(&request.encode()); + } + Self::Duplicate(request) => { + out.push(request_tag::DUPLICATE); + out.extend_from_slice(&request.encode()); + } + Self::Renew(request) => { + out.push(request_tag::RENEW); + out.extend_from_slice(&request.encode()); + } + Self::Release(request) => { + out.push(request_tag::RELEASE); + out.extend_from_slice(&request.encode()); + } + Self::Query(request) => { + out.push(request_tag::QUERY); + out.extend_from_slice(&request.encode()); + } + } + out + } + + /// Decode a request from raw bytes. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + let tag = reader.read_u8()?; + match tag { + request_tag::CREATE => Ok(Self::Create(CreateSnapshotLease::decode(&mut reader)?)), + request_tag::DUPLICATE => Ok(Self::Duplicate(DuplicateSnapshotHandle::decode( + &mut reader, + )?)), + request_tag::RENEW => Ok(Self::Renew(RenewSnapshotLease::decode(&mut reader)?)), + request_tag::RELEASE => Ok(Self::Release(ReleaseSnapshotLease::decode(&mut reader)?)), + request_tag::QUERY => Ok(Self::Query(QuerySnapshotLease::decode(&mut reader)?)), + other => Err(SnapshotProtocolError::UnknownDiscriminant { + field: "request_tag", + value: u64::from(other), + }), + } + } +} + +/// Tagged union of every Snapshot Manager response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SnapshotManagerResponse { + /// Response to [`SnapshotManagerRequest::Create`]. + Created(CreateSnapshotLeaseResult), + /// Response to [`SnapshotManagerRequest::Duplicate`]: the handle was + /// duplicated out of band; this just acknowledges success. + Duplicated, + /// Response to [`SnapshotManagerRequest::Renew`]. + Renewed { + /// The lease's new expiry, Unix milliseconds. + new_expires_at_unix_ms: i64, + }, + /// Response to [`SnapshotManagerRequest::Release`]. + Released, + /// Response to [`SnapshotManagerRequest::Query`]. + Status(SnapshotLeaseStatus), + /// Any request failed. + Error { + /// Stable error code. + code: SnapshotManagerErrorCode, + /// Human-readable diagnostic message. + message: String, + }, +} + +/// Wire discriminant tags for [`SnapshotManagerResponse`]. +pub(crate) mod response_tag { + /// Tag for [`super::SnapshotManagerResponse::Created`]. + pub(crate) const CREATED: u8 = 0; + /// Tag for [`super::SnapshotManagerResponse::Duplicated`]. + pub(crate) const DUPLICATED: u8 = 1; + /// Tag for [`super::SnapshotManagerResponse::Renewed`]. + pub(crate) const RENEWED: u8 = 2; + /// Tag for [`super::SnapshotManagerResponse::Released`]. + pub(crate) const RELEASED: u8 = 3; + /// Tag for [`super::SnapshotManagerResponse::Status`]. + pub(crate) const STATUS: u8 = 4; + /// Tag for [`super::SnapshotManagerResponse::Error`]. + pub(crate) const ERROR: u8 = 5; +} + +impl SnapshotManagerResponse { + /// Encode this response. + #[must_use] + pub fn encode(&self) -> Vec { + let mut out = Vec::new(); + match self { + Self::Created(result) => { + out.push(response_tag::CREATED); + out.extend_from_slice(&result.encode()); + } + Self::Duplicated => out.push(response_tag::DUPLICATED), + Self::Renewed { + new_expires_at_unix_ms, + } => { + out.push(response_tag::RENEWED); + write_i64_le(&mut out, *new_expires_at_unix_ms); + } + Self::Released => out.push(response_tag::RELEASED), + Self::Status(status) => { + out.push(response_tag::STATUS); + out.extend_from_slice(&status.encode()); + } + Self::Error { code, message } => { + out.push(response_tag::ERROR); + out.push(code.encode()); + write_string_u16_prefixed(&mut out, message); + } + } + out + } + + /// Decode a response from raw bytes. + /// + /// # Errors + /// See [`SnapshotProtocolError`]. + pub fn decode(bytes: &[u8]) -> Result { + let mut reader = Reader::new(bytes); + let tag = reader.read_u8()?; + match tag { + response_tag::CREATED => Ok(Self::Created(CreateSnapshotLeaseResult::decode( + &mut reader, + )?)), + response_tag::DUPLICATED => Ok(Self::Duplicated), + response_tag::RENEWED => Ok(Self::Renewed { + new_expires_at_unix_ms: reader.read_i64_le()?, + }), + response_tag::RELEASED => Ok(Self::Released), + response_tag::STATUS => Ok(Self::Status(SnapshotLeaseStatus::decode(&mut reader)?)), + response_tag::ERROR => { + let code_byte = reader.read_u8()?; + let code = SnapshotManagerErrorCode::decode(code_byte).map_err(|byte| { + SnapshotProtocolError::UnknownDiscriminant { + field: "code", + value: u64::from(byte), + } + })?; + let message = reader.read_string_u16_prefixed("message", MAX_MESSAGE_BYTES)?; + Ok(Self::Error { code, message }) + } + other => Err(SnapshotProtocolError::UnknownDiscriminant { + field: "response_tag", + value: u64::from(other), + }), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs b/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs new file mode 100644 index 000000000..21b95ea07 --- /dev/null +++ b/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Unit tests for [`super`] (`snapshot_manager`). + +use super::{ + CreateSnapshotLease, CreateSnapshotLeaseResult, DuplicateSnapshotHandle, QuerySnapshotLease, + ReleaseSnapshotLease, RenewSnapshotLease, SnapshotLeaseState, SnapshotLeaseStatus, + SnapshotManagerErrorCode, SnapshotManagerRequest, SnapshotManagerResponse, + SnapshotProtocolError, VolumeIdentity, +}; + +fn sample_create_request() -> CreateSnapshotLease { + CreateSnapshotLease { + authenticated_job_id: [1_u8; 16], + source_volume_identity: VolumeIdentity { + volume_serial: 0x0102_0304_0506_0708, + volume_guid: b"{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}".to_vec(), + }, + requested_root: b"C\0:\0\\\0d\0a\0t\0a\0".to_vec(), // toy UTF-16LE-ish bytes + maximum_lifetime_secs: 3600, + policy_id: 1, + } +} + +fn sample_create_result() -> CreateSnapshotLeaseResult { + CreateSnapshotLeaseResult { + snapshot_lease_id: 42, + snapshot_id: b"vss-snap-0001".to_vec(), + snapshot_device_identity: r"\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1".to_owned(), + snapshot_created_at_unix_ms: 1_752_000_000_000, + expires_at_unix_ms: 1_752_003_600_000, + } +} + +#[test] +fn create_snapshot_lease_round_trips() { + let request = sample_create_request(); + let wrapped = SnapshotManagerRequest::Create(request.clone()); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerRequest::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerRequest::Create(request)); +} + +#[test] +fn create_snapshot_lease_result_round_trips() { + let result = sample_create_result(); + let wrapped = SnapshotManagerResponse::Created(result.clone()); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerResponse::Created(result)); +} + +#[test] +fn duplicate_snapshot_handle_round_trips() { + let request = DuplicateSnapshotHandle { + snapshot_lease_id: 42, + approved_reader_process_id: 4321, + }; + let wrapped = SnapshotManagerRequest::Duplicate(request); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerRequest::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerRequest::Duplicate(request)); +} + +#[test] +fn duplicated_response_round_trips() { + let wrapped = SnapshotManagerResponse::Duplicated; + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerResponse::Duplicated); +} + +#[test] +fn renew_snapshot_lease_round_trips() { + let request = RenewSnapshotLease { + snapshot_lease_id: 42, + requested_expiry_unix_ms: 1_752_010_000_000, + }; + let wrapped = SnapshotManagerRequest::Renew(request); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerRequest::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerRequest::Renew(request)); +} + +#[test] +fn renewed_response_round_trips() { + let wrapped = SnapshotManagerResponse::Renewed { + new_expires_at_unix_ms: 1_752_010_000_000, + }; + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, wrapped); +} + +#[test] +fn release_snapshot_lease_round_trips() { + let request = ReleaseSnapshotLease { + snapshot_lease_id: 42, + }; + let wrapped = SnapshotManagerRequest::Release(request); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerRequest::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerRequest::Release(request)); +} + +#[test] +fn released_response_round_trips() { + let wrapped = SnapshotManagerResponse::Released; + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerResponse::Released); +} + +#[test] +fn query_snapshot_lease_round_trips() { + let request = QuerySnapshotLease { + snapshot_lease_id: 42, + }; + let wrapped = SnapshotManagerRequest::Query(request); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerRequest::decode(&bytes).unwrap(); + assert_eq!(decoded, SnapshotManagerRequest::Query(request)); +} + +#[test] +fn status_response_round_trips_for_every_state() { + for state in [ + SnapshotLeaseState::Active, + SnapshotLeaseState::Expired, + SnapshotLeaseState::Released, + SnapshotLeaseState::Unknown, + ] { + let status = SnapshotLeaseStatus { + snapshot_lease_id: 42, + state, + snapshot_id: b"vss-snap-0001".to_vec(), + created_at_unix_ms: 1_752_000_000_000, + expires_at_unix_ms: 1_752_003_600_000, + }; + let wrapped = SnapshotManagerResponse::Status(status.clone()); + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!( + decoded, + SnapshotManagerResponse::Status(status), + "failed for {state:?}" + ); + } +} + +#[test] +fn error_response_round_trips() { + let wrapped = SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::LeaseNotFound, + message: "no such lease".to_owned(), + }; + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, wrapped); +} + +#[test] +fn request_decode_rejects_unknown_tag() { + let bytes = vec![0xFF]; + let err = SnapshotManagerRequest::decode(&bytes).unwrap_err(); + assert!(matches!(err, SnapshotProtocolError::UnknownDiscriminant { + field: "request_tag", + .. + })); +} + +#[test] +fn response_decode_rejects_unknown_tag() { + let bytes = vec![0xFF]; + let err = SnapshotManagerResponse::decode(&bytes).unwrap_err(); + assert!(matches!(err, SnapshotProtocolError::UnknownDiscriminant { + field: "response_tag", + .. + })); +} + +#[test] +fn request_decode_rejects_truncated_input() { + let bytes = vec![0_u8]; // CREATE tag, but no body + let err = SnapshotManagerRequest::decode(&bytes).unwrap_err(); + assert!(matches!(err, SnapshotProtocolError::Truncated { .. })); +} + +#[test] +fn snapshot_lease_state_round_trips_all_variants() { + for value in 0_u8..=3 { + let state = SnapshotLeaseState::decode(value).unwrap(); + assert_eq!(state.encode(), value); + } + assert_eq!(SnapshotLeaseState::decode(4), Err(4)); +} + +#[test] +fn snapshot_manager_error_code_round_trips_all_variants() { + for value in 0_u8..=7 { + let code = SnapshotManagerErrorCode::decode(value).unwrap(); + assert_eq!(code.encode(), value); + } + assert_eq!(SnapshotManagerErrorCode::decode(8), Err(8)); +} + +#[test] +fn create_snapshot_lease_rejects_oversized_requested_root() { + let mut request = sample_create_request(); + request.requested_root = vec![0_u8; (super::MAX_PATH_BYTES + 2) as usize]; + let wrapped = SnapshotManagerRequest::Create(request); + let bytes = wrapped.encode(); + let err = SnapshotManagerRequest::decode(&bytes).unwrap_err(); + assert!(matches!( + err, + SnapshotProtocolError::LengthOutOfBounds { .. } + )); +} From 495033563fecaf81e11c44442d0aa50b6d5a9d6d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:36:30 -0700 Subject: [PATCH 11/98] feat(content): durable SQLite job/failure-bucket ledger (UFI.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/uffs-content/src/db/{mod,schema,queries}.rs implementing the addendum §6 job database: uffs-content-jobs.sqlite3, WAL + foreign_keys + synchronous=FULL pragmas, and the six-table schema (jobs, candidates, attempts, consumer_acks, snapshot_leases, job_events) from addendum §6.4. queries.rs adds a narrow typed API (create_job, insert_candidate, record_terminal_outcome, completeness_summary) - just enough to prove two things with real SQLite, not mocks: - the completeness invariant (design-doc §2.2/§21.7): candidate_count == succeeded + failed_retryable + failed_terminal + deferred_manual, with both a passing and a still-incomplete case; - crash-recovery durability (design-doc §19.2): write a job with a mix of terminal and still-pending candidates, drop the connection without a clean job-complete step, reopen from the same file, and confirm completed candidates stay completed. record_terminal_outcome takes a new TerminalCandidateState enum (the four terminal variants only, no Pending) rather than accepting the full CandidateState and asserting/panicking on a non-terminal value - passing Pending is now a compile error, so there is no runtime check to get wrong and no panic in production code. New workspace dependency: rusqlite (bundled feature, so the Windows cross-compile via cargo-xwin doesn't need a system sqlite3 import library). This has not yet been through cargo-vet - flagging for a follow-up audit pass before this branch ships. 10 new tests, clean under lint-prod + lint-tests + file-size policy. Workspace-wide `cargo check` confirmed no regressions from the new dep. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 98 ++++- Cargo.toml | 9 + crates/uffs-content/Cargo.toml | 6 + crates/uffs-content/src/db/mod.rs | 14 + crates/uffs-content/src/db/queries.rs | 529 ++++++++++++++++++++++++++ crates/uffs-content/src/db/schema.rs | 238 ++++++++++++ crates/uffs-content/src/lib.rs | 5 +- crates/uffs-content/src/main.rs | 5 + 8 files changed, 891 insertions(+), 13 deletions(-) create mode 100644 crates/uffs-content/src/db/mod.rs create mode 100644 crates/uffs-content/src/db/queries.rs create mode 100644 crates/uffs-content/src/db/schema.rs diff --git a/Cargo.lock b/Cargo.lock index 445a15267..ac9e0f122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,7 +146,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -157,7 +157,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -664,7 +664,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1084,7 +1084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1114,6 +1114,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + [[package]] name = "fallible-streaming-iterator" version = "0.1.9" @@ -1435,6 +1441,18 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1607,7 +1625,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -1880,6 +1898,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2028,7 +2057,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3447,6 +3476,31 @@ dependencies = [ "serde", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3463,7 +3517,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3839,7 +3893,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -3893,7 +3959,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4008,7 +4074,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4018,7 +4084,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4481,6 +4547,8 @@ dependencies = [ name = "uffs-content" version = "0.6.27" dependencies = [ + "rusqlite", + "tempfile", "uffs-content-protocol", "uffs-version", ] @@ -4899,6 +4967,12 @@ dependencies = [ "ryu", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -5116,7 +5190,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c88abd424..8937de38b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -370,6 +370,15 @@ security-framework = "3.7.0" # ───── Compression ───── zstd = { version = "0.13.3", features = ["zstdmt"] } +# ───── Embedded storage ───── +# `uffs-content`'s durable job/failure-bucket ledger (addendum S6.1: "a +# dedicated local SQLite database owned by the UFFS content subsystem"). +# `bundled` statically compiles SQLite from source via libsqlite3-sys — +# required so the Windows cross-compile (cargo xwin) doesn't need a +# system sqlite3 import library, matching how this workspace already +# avoids other system-library link dependencies. +rusqlite = { version = "0.40.1", features = ["bundled"] } + # ───── Testing & Benchmarking ───── criterion = "0.8.2" proptest = "1.11.0" diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index ab8b9c225..ac27b04ba 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -57,6 +57,12 @@ uffs-version.workspace = true # Shared wire-protocol types (manifest, frames, job/candidate states) — see # `crates/uffs-content-protocol/`. uffs-content-protocol.workspace = true +# Durable job/candidate/attempt/ACK/snapshot-lease ledger (addendum §6) — +# see `src/db/`. +rusqlite.workspace = true + +[dev-dependencies] +tempfile.workspace = true [lints] workspace = true diff --git a/crates/uffs-content/src/db/mod.rs b/crates/uffs-content/src/db/mod.rs new file mode 100644 index 000000000..2e5eda4a1 --- /dev/null +++ b/crates/uffs-content/src/db/mod.rs @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Durable job/failure-bucket ledger (addendum §6): +//! `uffs-content-jobs.sqlite3`. +//! +//! A dedicated, UFFS-owned SQLite WAL database — not an in-memory list, +//! not the mutable manifest, not shared with Docenta's database (addendum +//! §6.1/§6.2). [`schema`] owns the DDL and connection setup; [`queries`] +//! is the narrow typed API this milestone needs to prove the +//! completeness invariant and crash-recovery durability. + +pub mod queries; +pub mod schema; diff --git a/crates/uffs-content/src/db/queries.rs b/crates/uffs-content/src/db/queries.rs new file mode 100644 index 000000000..eb163f580 --- /dev/null +++ b/crates/uffs-content/src/db/queries.rs @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Typed read/write helpers over the schema in [`super::schema`]. +//! +//! This module is intentionally narrow — just enough surface to prove +//! the completeness invariant (design-doc §2.2/§21.7) and crash-recovery +//! durability against a real SQLite file, per +//! `uffs-ingest-implementation-plan.md` §7.3. The full job-workflow API +//! (manifest ingestion, retry scheduling, ...) is later work. + +use rusqlite::{Connection, OptionalExtension as _, params}; + +/// A candidate's persisted state. +/// +/// Mirrors [`uffs_content_protocol::state::CandidateOutcome`]'s four +/// terminal variants plus [`CandidateState::Pending`] for a candidate +/// that has not yet reached one — this crate doesn't depend on +/// `uffs-content-protocol` for this small enum because the DB layer's +/// state model includes the pre-terminal case that protocol crate has no +/// reason to represent (it only ever appears in already-terminal wire +/// frames). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CandidateState { + /// Not yet resolved to a terminal outcome. + Pending, + /// Content was read, streamed, and verified successfully. + Succeeded, + /// Transient failure; may be retried in a later job attempt. + FailedRetryable, + /// Permanent failure. + FailedTerminal, + /// Explicitly deferred to manual/later handling. + DeferredManual, +} + +impl CandidateState { + /// The `TEXT` value stored in `candidates.state` / + /// `attempts.terminal_outcome`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "Pending", + Self::Succeeded => "Succeeded", + Self::FailedRetryable => "FailedRetryable", + Self::FailedTerminal => "FailedTerminal", + Self::DeferredManual => "DeferredManual", + } + } + + /// Parse the `TEXT` value stored in `candidates.state` / + /// `attempts.terminal_outcome`. + fn from_str(value: &str) -> Option { + match value { + "Pending" => Some(Self::Pending), + "Succeeded" => Some(Self::Succeeded), + "FailedRetryable" => Some(Self::FailedRetryable), + "FailedTerminal" => Some(Self::FailedTerminal), + "DeferredManual" => Some(Self::DeferredManual), + _ => None, + } + } +} + +/// One of the four terminal outcomes (design-doc §2.2/§9.2). +/// +/// Deliberately a separate type from [`CandidateState`], which also has +/// [`CandidateState::Pending`]. [`record_terminal_outcome`] takes this +/// type instead of `CandidateState` so passing a non-terminal state is a +/// compile error, not a runtime check — there is no `Pending` variant to +/// even construct here, so no panic/assert is needed to reject it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TerminalCandidateState { + /// Content was read, streamed, and verified successfully. + Succeeded, + /// Transient failure; may be retried in a later job attempt. + FailedRetryable, + /// Permanent failure. + FailedTerminal, + /// Explicitly deferred to manual/later handling. + DeferredManual, +} + +impl TerminalCandidateState { + /// The `TEXT` value stored in `candidates.state` / + /// `attempts.terminal_outcome`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "Succeeded", + Self::FailedRetryable => "FailedRetryable", + Self::FailedTerminal => "FailedTerminal", + Self::DeferredManual => "DeferredManual", + } + } +} + +impl From for CandidateState { + fn from(value: TerminalCandidateState) -> Self { + match value { + TerminalCandidateState::Succeeded => Self::Succeeded, + TerminalCandidateState::FailedRetryable => Self::FailedRetryable, + TerminalCandidateState::FailedTerminal => Self::FailedTerminal, + TerminalCandidateState::DeferredManual => Self::DeferredManual, + } + } +} + +/// Minimal fields needed to create a `jobs` row for these tests/helpers. +#[derive(Debug, Clone)] +pub struct NewJob { + /// Unique job identifier. + pub job_id: String, + /// Source identifier. + pub source_id: String, + /// Volume identity (opaque string form). + pub volume_identity: String, + /// Requested root path. + pub root: String, + /// Digest of the UFFS query. + pub query_digest: String, + /// Authorization mode (see + /// `uffs_content_protocol::manifest::AuthorizationMode`). + pub authorization_mode: i64, + /// Total candidates once the manifest is finalized. + pub candidate_count: i64, + /// Creation time, Unix milliseconds. + pub created_at: i64, + /// Producer build identifier. + pub producer_build: String, + /// Wire protocol version. + pub protocol_version: i64, +} + +/// Insert a new job row in state `"Created"`. +/// +/// # Errors +/// Returns any [`rusqlite::Error`] from the insert. +pub fn create_job(conn: &Connection, job: &NewJob) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO jobs (job_id, source_id, volume_identity, root, query_digest, \ + authorization_mode, state, candidate_count, created_at, producer_build, \ + protocol_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'Created', ?7, ?8, ?9, ?10)", + params![ + job.job_id, + job.source_id, + job.volume_identity, + job.root, + job.query_digest, + job.authorization_mode, + job.candidate_count, + job.created_at, + job.producer_build, + job.protocol_version, + ], + )?; + Ok(()) +} + +/// Insert a candidate row in state [`CandidateState::Pending`]. +/// +/// # Errors +/// Returns any [`rusqlite::Error`] from the insert. +pub fn insert_candidate( + conn: &Connection, + job_id: &str, + candidate_id: i64, + full_file_reference: i64, + path_bytes: &[u8], + logical_size: i64, +) -> rusqlite::Result<()> { + conn.execute( + "INSERT INTO candidates (job_id, candidate_id, full_file_reference, path_bytes, \ + path_encoding, logical_size, mtime, candidate_flags, state) \ + VALUES (?1, ?2, ?3, ?4, 0, ?5, 0, 0, ?6)", + params![ + job_id, + candidate_id, + full_file_reference, + path_bytes, + logical_size, + CandidateState::Pending.as_str(), + ], + )?; + Ok(()) +} + +/// Record a terminal outcome for one candidate: appends an `attempts` +/// row (history is never overwritten — addendum §6.5) and updates +/// `candidates.state` to match. +/// +/// Uses a transaction so the two writes are atomic — a crash between +/// them must never leave `candidates.state` inconsistent with the +/// `attempts` history. Takes [`TerminalCandidateState`] rather than +/// [`CandidateState`] so a non-terminal outcome is a compile error, not +/// a runtime check. +/// +/// # Errors +/// +/// Returns any [`rusqlite::Error`] from the transaction. +pub fn record_terminal_outcome( + conn: &mut Connection, + job_id: &str, + candidate_id: i64, + attempt_number: i64, + outcome: TerminalCandidateState, +) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO attempts (job_id, candidate_id, attempt_number, terminal_outcome) \ + VALUES (?1, ?2, ?3, ?4)", + params![job_id, candidate_id, attempt_number, outcome.as_str()], + )?; + tx.execute( + "UPDATE candidates SET state = ?1 WHERE job_id = ?2 AND candidate_id = ?3", + params![outcome.as_str(), job_id, candidate_id], + )?; + tx.commit() +} + +/// Per-job completeness summary (design-doc §2.2/§21.7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompletenessSummary { + /// `jobs.candidate_count` — the manifest's declared total. + pub candidate_count: i64, + /// Candidates currently in [`CandidateState::Pending`]. + pub pending: i64, + /// Candidates currently [`TerminalCandidateState::Succeeded`]. + pub succeeded: i64, + /// Candidates currently [`TerminalCandidateState::FailedRetryable`]. + pub failed_retryable: i64, + /// Candidates currently [`TerminalCandidateState::FailedTerminal`]. + pub failed_terminal: i64, + /// Candidates currently [`TerminalCandidateState::DeferredManual`]. + pub deferred_manual: i64, +} + +impl CompletenessSummary { + /// Whether every candidate has reached a terminal state and the + /// terminal buckets sum to `candidate_count` (design-doc §2.2's + /// completeness invariant). + #[must_use] + pub const fn is_complete(&self) -> bool { + self.pending == 0 + && self.candidate_count + == self.succeeded + + self.failed_retryable + + self.failed_terminal + + self.deferred_manual + } +} + +/// Compute the completeness summary for `job_id`. +/// +/// # Errors +/// +/// Returns any [`rusqlite::Error`] from the query, or a +/// [`rusqlite::Error::QueryReturnedNoRows`]-shaped error surfaced via +/// [`OptionalExtension`] if the job does not exist. +pub fn completeness_summary( + conn: &Connection, + job_id: &str, +) -> rusqlite::Result { + let candidate_count: i64 = conn + .query_row( + "SELECT candidate_count FROM jobs WHERE job_id = ?1", + params![job_id], + |row| row.get(0), + ) + .optional()? + .unwrap_or(0); + + let mut pending = 0_i64; + let mut succeeded = 0_i64; + let mut failed_retryable = 0_i64; + let mut failed_terminal = 0_i64; + let mut deferred_manual = 0_i64; + + let mut stmt = + conn.prepare("SELECT state, COUNT(*) FROM candidates WHERE job_id = ?1 GROUP BY state")?; + let rows = stmt.query_map(params![job_id], |row| { + let state: String = row.get(0)?; + let count: i64 = row.get(1)?; + Ok((state, count)) + })?; + for row in rows { + let (state_str, count) = row?; + match CandidateState::from_str(&state_str) { + Some(CandidateState::Pending) => pending = count, + Some(CandidateState::Succeeded) => succeeded = count, + Some(CandidateState::FailedRetryable) => failed_retryable = count, + Some(CandidateState::FailedTerminal) => failed_terminal = count, + Some(CandidateState::DeferredManual) => deferred_manual = count, + // An unrecognized state string is a corrupt/foreign-written + // row, not a case this summary should silently absorb into + // any bucket — skip it rather than guessing. + None => {} + } + } + + Ok(CompletenessSummary { + candidate_count, + pending, + succeeded, + failed_retryable, + failed_terminal, + deferred_manual, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + NewJob, TerminalCandidateState, completeness_summary, create_job, insert_candidate, + record_terminal_outcome, + }; + use crate::db::schema::open; + + fn sample_job(job_id: &str, candidate_count: i64) -> NewJob { + NewJob { + job_id: job_id.to_owned(), + source_id: "src-1".to_owned(), + volume_identity: "vol-1".to_owned(), + root: r"C:\data".to_owned(), + query_digest: "digest-1".to_owned(), + authorization_mode: 0, + candidate_count, + created_at: 1_752_000_000_000, + producer_build: "test-build".to_owned(), + protocol_version: 2, + } + } + + #[test] + fn completeness_invariant_holds_once_every_candidate_is_terminal() { + let mut conn = open(None).unwrap(); + let job_id = "job-complete"; + create_job(&conn, &sample_job(job_id, 4)).unwrap(); + for candidate_id in 0..4_i64 { + insert_candidate( + &conn, + job_id, + candidate_id, + 1000 + candidate_id, + b"path", + 4096, + ) + .unwrap(); + } + + // Not yet complete: every candidate is still Pending. + let initial_summary = completeness_summary(&conn, job_id).unwrap(); + assert!(!initial_summary.is_complete()); + assert_eq!(initial_summary.pending, 4); + + // Drive each candidate to a (different) terminal outcome. + record_terminal_outcome(&mut conn, job_id, 0, 1, TerminalCandidateState::Succeeded) + .unwrap(); + record_terminal_outcome( + &mut conn, + job_id, + 1, + 1, + TerminalCandidateState::FailedRetryable, + ) + .unwrap(); + record_terminal_outcome( + &mut conn, + job_id, + 2, + 1, + TerminalCandidateState::FailedTerminal, + ) + .unwrap(); + record_terminal_outcome( + &mut conn, + job_id, + 3, + 1, + TerminalCandidateState::DeferredManual, + ) + .unwrap(); + + let summary = completeness_summary(&conn, job_id).unwrap(); + assert!(summary.is_complete(), "{summary:?}"); + assert_eq!(summary.pending, 0); + assert_eq!(summary.succeeded, 1); + assert_eq!(summary.failed_retryable, 1); + assert_eq!(summary.failed_terminal, 1); + assert_eq!(summary.deferred_manual, 1); + assert_eq!( + summary.candidate_count, + summary.succeeded + + summary.failed_retryable + + summary.failed_terminal + + summary.deferred_manual + ); + } + + #[test] + fn completeness_invariant_detects_incomplete_job() { + let mut conn = open(None).unwrap(); + let job_id = "job-incomplete"; + create_job(&conn, &sample_job(job_id, 3)).unwrap(); + for candidate_id in 0..3_i64 { + insert_candidate( + &conn, + job_id, + candidate_id, + 2000 + candidate_id, + b"path", + 4096, + ) + .unwrap(); + } + record_terminal_outcome(&mut conn, job_id, 0, 1, TerminalCandidateState::Succeeded) + .unwrap(); + record_terminal_outcome(&mut conn, job_id, 1, 1, TerminalCandidateState::Succeeded) + .unwrap(); + // candidate 2 never resolved. + + let summary = completeness_summary(&conn, job_id).unwrap(); + assert!(!summary.is_complete()); + assert_eq!(summary.pending, 1); + assert_eq!(summary.succeeded, 2); + } + + // There is deliberately no "rejects Pending" test: + // `record_terminal_outcome` takes `TerminalCandidateState`, which has + // no `Pending` variant, so passing a non-terminal state is a compile + // error rather than a runtime condition to test. + + #[test] + fn attempts_history_is_never_overwritten_across_retries() { + // A candidate fails transiently, then succeeds on a later attempt + // (a new snapshot per design-doc §8.5) — both attempts rows must + // remain, per addendum §6.5 ("retries never overwrite prior + // attempts"). + let mut conn = open(None).unwrap(); + let job_id = "job-retry"; + create_job(&conn, &sample_job(job_id, 1)).unwrap(); + insert_candidate(&conn, job_id, 0, 4000, b"path", 4096).unwrap(); + record_terminal_outcome( + &mut conn, + job_id, + 0, + 1, + TerminalCandidateState::FailedRetryable, + ) + .unwrap(); + record_terminal_outcome(&mut conn, job_id, 0, 2, TerminalCandidateState::Succeeded) + .unwrap(); + + let attempt_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM attempts WHERE job_id = ?1 AND candidate_id = 0", + [job_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(attempt_count, 2, "both attempts must be retained"); + + let final_state: String = conn + .query_row( + "SELECT state FROM candidates WHERE job_id = ?1 AND candidate_id = 0", + [job_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + final_state, "Succeeded", + "candidates.state reflects only the latest attempt" + ); + } + + #[test] + fn crash_recovery_completed_candidates_survive_reconnect() { + // Simulates a producer crash: write a job with a mix of terminal + // and still-pending candidates, drop the connection without a + // clean job-complete step, reopen from the same file, and assert + // completed candidates stay completed (design-doc §19.2: "never + // assumes a file was accepted without durable ACK state"). + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("crash-recovery.sqlite3"); + let job_id = "job-crash"; + + let mut crashed_conn = open(Some(&path)).unwrap(); + create_job(&crashed_conn, &sample_job(job_id, 3)).unwrap(); + for candidate_id in 0..3_i64 { + insert_candidate( + &crashed_conn, + job_id, + candidate_id, + 5000 + candidate_id, + b"path", + 4096, + ) + .unwrap(); + } + record_terminal_outcome( + &mut crashed_conn, + job_id, + 0, + 1, + TerminalCandidateState::Succeeded, + ) + .unwrap(); + record_terminal_outcome( + &mut crashed_conn, + job_id, + 1, + 1, + TerminalCandidateState::FailedTerminal, + ) + .unwrap(); + // candidate 2 left Pending, simulating an in-flight read when the + // process died. Drop the connection explicitly rather than the + // above writes being rolled back — each was its own committed + // transaction, so this models a crash, not an abandoned one. + drop(crashed_conn); + + let conn = open(Some(&path)).unwrap(); + let summary = completeness_summary(&conn, job_id).unwrap(); + assert_eq!(summary.succeeded, 1); + assert_eq!(summary.failed_terminal, 1); + assert_eq!(summary.pending, 1); + assert!(!summary.is_complete()); + } +} diff --git a/crates/uffs-content/src/db/schema.rs b/crates/uffs-content/src/db/schema.rs new file mode 100644 index 000000000..d59593deb --- /dev/null +++ b/crates/uffs-content/src/db/schema.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Durable job/candidate/attempt/ACK/snapshot-lease schema (addendum §6.4). +//! +//! This is the single source of truth for "did every finalized manifest +//! candidate reach exactly one terminal outcome" — the completeness +//! invariant design-doc §2.2/§21.7 requires. It is deliberately not an +//! in-memory structure: addendum §6.1 requires it survive a producer +//! crash independently of Docenta. + +use rusqlite::Connection; + +/// Schema DDL. `CREATE TABLE IF NOT EXISTS` makes re-applying idempotent +/// — there is no migration framework yet (design-doc plan §7.1: "even a +/// single-file `CREATE TABLE` statement run idempotently is fine for the +/// first milestone; don't build a full migration framework prematurely"). +const SCHEMA_SQL: &str = " +CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL, + volume_identity TEXT NOT NULL, + root TEXT NOT NULL, + query_digest TEXT NOT NULL, + authorization_mode INTEGER NOT NULL, + state TEXT NOT NULL, + snapshot_lease_id INTEGER, + snapshot_id TEXT, + manifest_locator TEXT, + manifest_digest TEXT, + candidate_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + started_at INTEGER, + completed_at INTEGER, + expires_at INTEGER, + producer_build TEXT NOT NULL, + protocol_version INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS candidates ( + job_id TEXT NOT NULL, + candidate_id INTEGER NOT NULL, + full_file_reference INTEGER NOT NULL, + path_bytes BLOB NOT NULL, + path_encoding INTEGER NOT NULL, + logical_size INTEGER NOT NULL, + mtime INTEGER NOT NULL, + candidate_flags INTEGER NOT NULL, + state TEXT NOT NULL, + content_object_id INTEGER, + PRIMARY KEY (job_id, candidate_id), + FOREIGN KEY (job_id) REFERENCES jobs(job_id) +); + +CREATE TABLE IF NOT EXISTS attempts ( + attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + candidate_id INTEGER NOT NULL, + attempt_number INTEGER NOT NULL, + lease_owner TEXT, + lease_generation INTEGER, + lease_expires_at INTEGER, + planned_mode TEXT, + actual_mode TEXT, + started_at INTEGER, + finished_at INTEGER, + bytes_emitted INTEGER, + content_digest TEXT, + terminal_outcome TEXT, + failure_stage TEXT, + error_code TEXT, + os_error_code INTEGER, + retry_class TEXT, + message TEXT, + FOREIGN KEY (job_id, candidate_id) REFERENCES candidates(job_id, candidate_id) +); + +CREATE TABLE IF NOT EXISTS consumer_acks ( + job_id TEXT NOT NULL, + candidate_id INTEGER NOT NULL, + content_digest TEXT NOT NULL, + consumer_instance_id TEXT, + ack_state TEXT NOT NULL, + acked_at INTEGER NOT NULL, + PRIMARY KEY (job_id, candidate_id, content_digest) +); + +CREATE TABLE IF NOT EXISTS snapshot_leases ( + snapshot_lease_id INTEGER PRIMARY KEY, + job_id TEXT NOT NULL, + snapshot_id TEXT, + broker_state TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + released_at INTEGER, + last_error TEXT +); + +CREATE TABLE IF NOT EXISTS job_events ( + event_id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + occurred_at INTEGER NOT NULL, + event_type TEXT NOT NULL, + detail TEXT +); +"; + +/// Apply the schema to `conn`. Safe to call on every startup — every +/// statement is `CREATE TABLE IF NOT EXISTS`. +/// +/// # Errors +/// +/// Returns any [`rusqlite::Error`] from executing the DDL batch. +pub fn apply(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch(SCHEMA_SQL) +} + +/// Open a connection at `path` (or an in-memory database when `path` is +/// `None`, for tests) with the durability pragmas addendum §6.3 +/// requires, and apply the schema. +/// +/// # Errors +/// +/// Returns any [`rusqlite::Error`] from opening the connection, setting +/// pragmas, or applying the schema. +pub fn open(db_path: Option<&std::path::Path>) -> rusqlite::Result { + let conn = match db_path { + Some(existing_path) => Connection::open(existing_path)?, + None => Connection::open_in_memory()?, + }; + // WAL is a no-op (and briefly errors) on `:memory:` connections in + // some SQLite builds; tolerate that specifically so in-memory test + // connections don't need a different pragma set than real files. + let _: String = conn + .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0)) + .or_else(|_err| { + conn.pragma_update_and_check(None, "journal_mode", "MEMORY", |row| row.get(0)) + })?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(core::time::Duration::from_secs(5))?; + // Durable-before-ack per addendum §6.3: manifest finalization, + // candidate terminal outcomes, consumer ACKs, retry decisions, + // snapshot-lease changes, and job terminal state. This connection is + // used for exactly those writes; a future high-frequency progress + // counter should get its own relaxed-synchronous connection rather + // than loosening this one. + conn.pragma_update(None, "synchronous", "FULL")?; + apply(&conn)?; + Ok(conn) +} + +#[cfg(test)] +mod tests { + use super::open; + + #[test] + fn schema_applies_to_fresh_in_memory_database() { + let conn = open(None).unwrap(); + // Excludes SQLite's own internal `sqlite_%` tables (e.g. + // `sqlite_sequence`, auto-created because `attempts`/`job_events` + // use `AUTOINCREMENT`) — this counts only our own schema's tables. + let table_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(table_count, 6, "expected all six tables from addendum §6.4"); + } + + #[test] + fn schema_reapplication_is_idempotent() { + let conn = open(None).unwrap(); + super::apply(&conn).unwrap(); + super::apply(&conn).unwrap(); + } + + #[test] + fn every_expected_table_exists() { + let conn = open(None).unwrap(); + for table in [ + "jobs", + "candidates", + "attempts", + "consumer_acks", + "snapshot_leases", + "job_events", + ] { + let exists: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1", + [table], + |row| row.get(0), + ) + .unwrap(); + assert!(exists, "table '{table}' must exist"); + } + } + + #[test] + fn foreign_keys_pragma_is_enabled() { + let conn = open(None).unwrap(); + let enabled: i64 = conn + .pragma_query_value(None, "foreign_keys", |row| row.get(0)) + .unwrap(); + assert_eq!(enabled, 1); + } + + #[test] + fn opening_a_real_file_persists_across_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("uffs-content-jobs.sqlite3"); + + let first_conn = open(Some(&path)).unwrap(); + first_conn + .execute( + "INSERT INTO jobs (job_id, source_id, volume_identity, root, query_digest, \ + authorization_mode, state, candidate_count, created_at, producer_build, \ + protocol_version) VALUES ('job-1', 'src-1', 'vol-1', 'C:\\', 'digest', 0, \ + 'Created', 0, 0, 'test-build', 2)", + [], + ) + .unwrap(); + drop(first_conn); // explicit: reopen below must see this via the file, not the live handle + + let conn = open(Some(&path)).unwrap(); + let job_id: String = conn + .query_row( + "SELECT job_id FROM jobs WHERE job_id = 'job-1'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(job_id, "job-1"); + } +} diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 5a640fb7e..81047c5f4 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -17,7 +17,10 @@ //! //! # Status //! -//! Scaffold only — no job intake, VSS, MFT, or streaming logic yet. +//! Job intake, VSS, MFT, and streaming logic are not implemented yet. +//! [`db`] (the durable job/failure-bucket ledger, addendum §6) is real. + +pub mod db; // Not yet wired into this library's logic — reserved for the manifest / // frame types this crate will produce and consume once job intake lands. diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index cd0fd41be..6e7e5bf6e 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -21,6 +21,11 @@ // Reserved for the wire types the bin will emit once job intake is wired // up; not yet used from this thin entry point. +// Used by `uffs_content::db`, not by this thin entry point directly. +use rusqlite as _; +// Dev-dependency used by `uffs_content::db`'s tests, not by this bin. +#[cfg(test)] +use tempfile as _; use uffs_content_protocol as _; #[expect( From eee0317d4b437fc19551bb64a61205d36b092f0a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:07:47 -0700 Subject: [PATCH 12/98] refactor(content): replace SQLite job database with ephemeral run state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the durable rusqlite-backed job/candidate/attempt ledger in favor of a restart-from-zero model: an immutable manifest, an append-only JSONL failure log, in-memory counters, and an atomically finalized run summary. A run either completes (a valid final summary exists) or it didn't, and is retried from a fresh VSS snapshot — no mid-job transactions, leases, or crash reconciliation to get right. Docenta's own content-hash deduplication makes re-streaming after a crash a no-op on the consumer side, so restarting from zero costs nothing. rusqlite is no longer a dependency anywhere in the workspace. --- Cargo.lock | 75 +-- Cargo.toml | 9 - crates/uffs-content/Cargo.toml | 9 +- crates/uffs-content/src/db/mod.rs | 14 - crates/uffs-content/src/db/queries.rs | 529 --------------------- crates/uffs-content/src/db/schema.rs | 238 --------- crates/uffs-content/src/lib.rs | 5 +- crates/uffs-content/src/main.rs | 8 +- crates/uffs-content/src/run/failure_log.rs | 199 ++++++++ crates/uffs-content/src/run/mod.rs | 34 ++ crates/uffs-content/src/run/summary.rs | 236 +++++++++ crates/uffs-content/src/run/tests.rs | 179 +++++++ 12 files changed, 664 insertions(+), 871 deletions(-) delete mode 100644 crates/uffs-content/src/db/mod.rs delete mode 100644 crates/uffs-content/src/db/queries.rs delete mode 100644 crates/uffs-content/src/db/schema.rs create mode 100644 crates/uffs-content/src/run/failure_log.rs create mode 100644 crates/uffs-content/src/run/mod.rs create mode 100644 crates/uffs-content/src/run/summary.rs create mode 100644 crates/uffs-content/src/run/tests.rs diff --git a/Cargo.lock b/Cargo.lock index ac9e0f122..84184a05c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,12 +1114,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - [[package]] name = "fallible-streaming-iterator" version = "0.1.9" @@ -1441,18 +1435,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "hashlink" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" -dependencies = [ - "hashbrown 0.17.1", -] [[package]] name = "heck" @@ -1898,17 +1880,6 @@ dependencies = [ "libc", ] -[[package]] -name = "libsqlite3-sys" -version = "0.38.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3476,31 +3447,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rusqlite" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - [[package]] name = "rustc-hash" version = "2.1.3" @@ -3896,18 +3842,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "sqlparser" version = "0.60.0" @@ -4547,7 +4481,8 @@ dependencies = [ name = "uffs-content" version = "0.6.27" dependencies = [ - "rusqlite", + "serde", + "serde_json", "tempfile", "uffs-content-protocol", "uffs-version", @@ -4967,12 +4902,6 @@ dependencies = [ "ryu", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 8937de38b..c88abd424 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -370,15 +370,6 @@ security-framework = "3.7.0" # ───── Compression ───── zstd = { version = "0.13.3", features = ["zstdmt"] } -# ───── Embedded storage ───── -# `uffs-content`'s durable job/failure-bucket ledger (addendum S6.1: "a -# dedicated local SQLite database owned by the UFFS content subsystem"). -# `bundled` statically compiles SQLite from source via libsqlite3-sys — -# required so the Windows cross-compile (cargo xwin) doesn't need a -# system sqlite3 import library, matching how this workspace already -# avoids other system-library link dependencies. -rusqlite = { version = "0.40.1", features = ["bundled"] } - # ───── Testing & Benchmarking ───── criterion = "0.8.2" proptest = "1.11.0" diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index ac27b04ba..9b2af5b25 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -57,9 +57,12 @@ uffs-version.workspace = true # Shared wire-protocol types (manifest, frames, job/candidate states) — see # `crates/uffs-content-protocol/`. uffs-content-protocol.workspace = true -# Durable job/candidate/attempt/ACK/snapshot-lease ledger (addendum §6) — -# see `src/db/`. -rusqlite.workspace = true +# Failure-log records + the finalized run summary — see `src/run/`. Run +# state is intentionally ephemeral (no transactional per-candidate job +# database — see `src/run/mod.rs` for the full rationale), so this is +# the only serialization this crate needs. +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/uffs-content/src/db/mod.rs b/crates/uffs-content/src/db/mod.rs deleted file mode 100644 index 2e5eda4a1..000000000 --- a/crates/uffs-content/src/db/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2025-2026 SKY, LLC. - -//! Durable job/failure-bucket ledger (addendum §6): -//! `uffs-content-jobs.sqlite3`. -//! -//! A dedicated, UFFS-owned SQLite WAL database — not an in-memory list, -//! not the mutable manifest, not shared with Docenta's database (addendum -//! §6.1/§6.2). [`schema`] owns the DDL and connection setup; [`queries`] -//! is the narrow typed API this milestone needs to prove the -//! completeness invariant and crash-recovery durability. - -pub mod queries; -pub mod schema; diff --git a/crates/uffs-content/src/db/queries.rs b/crates/uffs-content/src/db/queries.rs deleted file mode 100644 index eb163f580..000000000 --- a/crates/uffs-content/src/db/queries.rs +++ /dev/null @@ -1,529 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2025-2026 SKY, LLC. - -//! Typed read/write helpers over the schema in [`super::schema`]. -//! -//! This module is intentionally narrow — just enough surface to prove -//! the completeness invariant (design-doc §2.2/§21.7) and crash-recovery -//! durability against a real SQLite file, per -//! `uffs-ingest-implementation-plan.md` §7.3. The full job-workflow API -//! (manifest ingestion, retry scheduling, ...) is later work. - -use rusqlite::{Connection, OptionalExtension as _, params}; - -/// A candidate's persisted state. -/// -/// Mirrors [`uffs_content_protocol::state::CandidateOutcome`]'s four -/// terminal variants plus [`CandidateState::Pending`] for a candidate -/// that has not yet reached one — this crate doesn't depend on -/// `uffs-content-protocol` for this small enum because the DB layer's -/// state model includes the pre-terminal case that protocol crate has no -/// reason to represent (it only ever appears in already-terminal wire -/// frames). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CandidateState { - /// Not yet resolved to a terminal outcome. - Pending, - /// Content was read, streamed, and verified successfully. - Succeeded, - /// Transient failure; may be retried in a later job attempt. - FailedRetryable, - /// Permanent failure. - FailedTerminal, - /// Explicitly deferred to manual/later handling. - DeferredManual, -} - -impl CandidateState { - /// The `TEXT` value stored in `candidates.state` / - /// `attempts.terminal_outcome`. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Pending => "Pending", - Self::Succeeded => "Succeeded", - Self::FailedRetryable => "FailedRetryable", - Self::FailedTerminal => "FailedTerminal", - Self::DeferredManual => "DeferredManual", - } - } - - /// Parse the `TEXT` value stored in `candidates.state` / - /// `attempts.terminal_outcome`. - fn from_str(value: &str) -> Option { - match value { - "Pending" => Some(Self::Pending), - "Succeeded" => Some(Self::Succeeded), - "FailedRetryable" => Some(Self::FailedRetryable), - "FailedTerminal" => Some(Self::FailedTerminal), - "DeferredManual" => Some(Self::DeferredManual), - _ => None, - } - } -} - -/// One of the four terminal outcomes (design-doc §2.2/§9.2). -/// -/// Deliberately a separate type from [`CandidateState`], which also has -/// [`CandidateState::Pending`]. [`record_terminal_outcome`] takes this -/// type instead of `CandidateState` so passing a non-terminal state is a -/// compile error, not a runtime check — there is no `Pending` variant to -/// even construct here, so no panic/assert is needed to reject it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TerminalCandidateState { - /// Content was read, streamed, and verified successfully. - Succeeded, - /// Transient failure; may be retried in a later job attempt. - FailedRetryable, - /// Permanent failure. - FailedTerminal, - /// Explicitly deferred to manual/later handling. - DeferredManual, -} - -impl TerminalCandidateState { - /// The `TEXT` value stored in `candidates.state` / - /// `attempts.terminal_outcome`. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Succeeded => "Succeeded", - Self::FailedRetryable => "FailedRetryable", - Self::FailedTerminal => "FailedTerminal", - Self::DeferredManual => "DeferredManual", - } - } -} - -impl From for CandidateState { - fn from(value: TerminalCandidateState) -> Self { - match value { - TerminalCandidateState::Succeeded => Self::Succeeded, - TerminalCandidateState::FailedRetryable => Self::FailedRetryable, - TerminalCandidateState::FailedTerminal => Self::FailedTerminal, - TerminalCandidateState::DeferredManual => Self::DeferredManual, - } - } -} - -/// Minimal fields needed to create a `jobs` row for these tests/helpers. -#[derive(Debug, Clone)] -pub struct NewJob { - /// Unique job identifier. - pub job_id: String, - /// Source identifier. - pub source_id: String, - /// Volume identity (opaque string form). - pub volume_identity: String, - /// Requested root path. - pub root: String, - /// Digest of the UFFS query. - pub query_digest: String, - /// Authorization mode (see - /// `uffs_content_protocol::manifest::AuthorizationMode`). - pub authorization_mode: i64, - /// Total candidates once the manifest is finalized. - pub candidate_count: i64, - /// Creation time, Unix milliseconds. - pub created_at: i64, - /// Producer build identifier. - pub producer_build: String, - /// Wire protocol version. - pub protocol_version: i64, -} - -/// Insert a new job row in state `"Created"`. -/// -/// # Errors -/// Returns any [`rusqlite::Error`] from the insert. -pub fn create_job(conn: &Connection, job: &NewJob) -> rusqlite::Result<()> { - conn.execute( - "INSERT INTO jobs (job_id, source_id, volume_identity, root, query_digest, \ - authorization_mode, state, candidate_count, created_at, producer_build, \ - protocol_version) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'Created', ?7, ?8, ?9, ?10)", - params![ - job.job_id, - job.source_id, - job.volume_identity, - job.root, - job.query_digest, - job.authorization_mode, - job.candidate_count, - job.created_at, - job.producer_build, - job.protocol_version, - ], - )?; - Ok(()) -} - -/// Insert a candidate row in state [`CandidateState::Pending`]. -/// -/// # Errors -/// Returns any [`rusqlite::Error`] from the insert. -pub fn insert_candidate( - conn: &Connection, - job_id: &str, - candidate_id: i64, - full_file_reference: i64, - path_bytes: &[u8], - logical_size: i64, -) -> rusqlite::Result<()> { - conn.execute( - "INSERT INTO candidates (job_id, candidate_id, full_file_reference, path_bytes, \ - path_encoding, logical_size, mtime, candidate_flags, state) \ - VALUES (?1, ?2, ?3, ?4, 0, ?5, 0, 0, ?6)", - params![ - job_id, - candidate_id, - full_file_reference, - path_bytes, - logical_size, - CandidateState::Pending.as_str(), - ], - )?; - Ok(()) -} - -/// Record a terminal outcome for one candidate: appends an `attempts` -/// row (history is never overwritten — addendum §6.5) and updates -/// `candidates.state` to match. -/// -/// Uses a transaction so the two writes are atomic — a crash between -/// them must never leave `candidates.state` inconsistent with the -/// `attempts` history. Takes [`TerminalCandidateState`] rather than -/// [`CandidateState`] so a non-terminal outcome is a compile error, not -/// a runtime check. -/// -/// # Errors -/// -/// Returns any [`rusqlite::Error`] from the transaction. -pub fn record_terminal_outcome( - conn: &mut Connection, - job_id: &str, - candidate_id: i64, - attempt_number: i64, - outcome: TerminalCandidateState, -) -> rusqlite::Result<()> { - let tx = conn.transaction()?; - tx.execute( - "INSERT INTO attempts (job_id, candidate_id, attempt_number, terminal_outcome) \ - VALUES (?1, ?2, ?3, ?4)", - params![job_id, candidate_id, attempt_number, outcome.as_str()], - )?; - tx.execute( - "UPDATE candidates SET state = ?1 WHERE job_id = ?2 AND candidate_id = ?3", - params![outcome.as_str(), job_id, candidate_id], - )?; - tx.commit() -} - -/// Per-job completeness summary (design-doc §2.2/§21.7). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct CompletenessSummary { - /// `jobs.candidate_count` — the manifest's declared total. - pub candidate_count: i64, - /// Candidates currently in [`CandidateState::Pending`]. - pub pending: i64, - /// Candidates currently [`TerminalCandidateState::Succeeded`]. - pub succeeded: i64, - /// Candidates currently [`TerminalCandidateState::FailedRetryable`]. - pub failed_retryable: i64, - /// Candidates currently [`TerminalCandidateState::FailedTerminal`]. - pub failed_terminal: i64, - /// Candidates currently [`TerminalCandidateState::DeferredManual`]. - pub deferred_manual: i64, -} - -impl CompletenessSummary { - /// Whether every candidate has reached a terminal state and the - /// terminal buckets sum to `candidate_count` (design-doc §2.2's - /// completeness invariant). - #[must_use] - pub const fn is_complete(&self) -> bool { - self.pending == 0 - && self.candidate_count - == self.succeeded - + self.failed_retryable - + self.failed_terminal - + self.deferred_manual - } -} - -/// Compute the completeness summary for `job_id`. -/// -/// # Errors -/// -/// Returns any [`rusqlite::Error`] from the query, or a -/// [`rusqlite::Error::QueryReturnedNoRows`]-shaped error surfaced via -/// [`OptionalExtension`] if the job does not exist. -pub fn completeness_summary( - conn: &Connection, - job_id: &str, -) -> rusqlite::Result { - let candidate_count: i64 = conn - .query_row( - "SELECT candidate_count FROM jobs WHERE job_id = ?1", - params![job_id], - |row| row.get(0), - ) - .optional()? - .unwrap_or(0); - - let mut pending = 0_i64; - let mut succeeded = 0_i64; - let mut failed_retryable = 0_i64; - let mut failed_terminal = 0_i64; - let mut deferred_manual = 0_i64; - - let mut stmt = - conn.prepare("SELECT state, COUNT(*) FROM candidates WHERE job_id = ?1 GROUP BY state")?; - let rows = stmt.query_map(params![job_id], |row| { - let state: String = row.get(0)?; - let count: i64 = row.get(1)?; - Ok((state, count)) - })?; - for row in rows { - let (state_str, count) = row?; - match CandidateState::from_str(&state_str) { - Some(CandidateState::Pending) => pending = count, - Some(CandidateState::Succeeded) => succeeded = count, - Some(CandidateState::FailedRetryable) => failed_retryable = count, - Some(CandidateState::FailedTerminal) => failed_terminal = count, - Some(CandidateState::DeferredManual) => deferred_manual = count, - // An unrecognized state string is a corrupt/foreign-written - // row, not a case this summary should silently absorb into - // any bucket — skip it rather than guessing. - None => {} - } - } - - Ok(CompletenessSummary { - candidate_count, - pending, - succeeded, - failed_retryable, - failed_terminal, - deferred_manual, - }) -} - -#[cfg(test)] -mod tests { - use super::{ - NewJob, TerminalCandidateState, completeness_summary, create_job, insert_candidate, - record_terminal_outcome, - }; - use crate::db::schema::open; - - fn sample_job(job_id: &str, candidate_count: i64) -> NewJob { - NewJob { - job_id: job_id.to_owned(), - source_id: "src-1".to_owned(), - volume_identity: "vol-1".to_owned(), - root: r"C:\data".to_owned(), - query_digest: "digest-1".to_owned(), - authorization_mode: 0, - candidate_count, - created_at: 1_752_000_000_000, - producer_build: "test-build".to_owned(), - protocol_version: 2, - } - } - - #[test] - fn completeness_invariant_holds_once_every_candidate_is_terminal() { - let mut conn = open(None).unwrap(); - let job_id = "job-complete"; - create_job(&conn, &sample_job(job_id, 4)).unwrap(); - for candidate_id in 0..4_i64 { - insert_candidate( - &conn, - job_id, - candidate_id, - 1000 + candidate_id, - b"path", - 4096, - ) - .unwrap(); - } - - // Not yet complete: every candidate is still Pending. - let initial_summary = completeness_summary(&conn, job_id).unwrap(); - assert!(!initial_summary.is_complete()); - assert_eq!(initial_summary.pending, 4); - - // Drive each candidate to a (different) terminal outcome. - record_terminal_outcome(&mut conn, job_id, 0, 1, TerminalCandidateState::Succeeded) - .unwrap(); - record_terminal_outcome( - &mut conn, - job_id, - 1, - 1, - TerminalCandidateState::FailedRetryable, - ) - .unwrap(); - record_terminal_outcome( - &mut conn, - job_id, - 2, - 1, - TerminalCandidateState::FailedTerminal, - ) - .unwrap(); - record_terminal_outcome( - &mut conn, - job_id, - 3, - 1, - TerminalCandidateState::DeferredManual, - ) - .unwrap(); - - let summary = completeness_summary(&conn, job_id).unwrap(); - assert!(summary.is_complete(), "{summary:?}"); - assert_eq!(summary.pending, 0); - assert_eq!(summary.succeeded, 1); - assert_eq!(summary.failed_retryable, 1); - assert_eq!(summary.failed_terminal, 1); - assert_eq!(summary.deferred_manual, 1); - assert_eq!( - summary.candidate_count, - summary.succeeded - + summary.failed_retryable - + summary.failed_terminal - + summary.deferred_manual - ); - } - - #[test] - fn completeness_invariant_detects_incomplete_job() { - let mut conn = open(None).unwrap(); - let job_id = "job-incomplete"; - create_job(&conn, &sample_job(job_id, 3)).unwrap(); - for candidate_id in 0..3_i64 { - insert_candidate( - &conn, - job_id, - candidate_id, - 2000 + candidate_id, - b"path", - 4096, - ) - .unwrap(); - } - record_terminal_outcome(&mut conn, job_id, 0, 1, TerminalCandidateState::Succeeded) - .unwrap(); - record_terminal_outcome(&mut conn, job_id, 1, 1, TerminalCandidateState::Succeeded) - .unwrap(); - // candidate 2 never resolved. - - let summary = completeness_summary(&conn, job_id).unwrap(); - assert!(!summary.is_complete()); - assert_eq!(summary.pending, 1); - assert_eq!(summary.succeeded, 2); - } - - // There is deliberately no "rejects Pending" test: - // `record_terminal_outcome` takes `TerminalCandidateState`, which has - // no `Pending` variant, so passing a non-terminal state is a compile - // error rather than a runtime condition to test. - - #[test] - fn attempts_history_is_never_overwritten_across_retries() { - // A candidate fails transiently, then succeeds on a later attempt - // (a new snapshot per design-doc §8.5) — both attempts rows must - // remain, per addendum §6.5 ("retries never overwrite prior - // attempts"). - let mut conn = open(None).unwrap(); - let job_id = "job-retry"; - create_job(&conn, &sample_job(job_id, 1)).unwrap(); - insert_candidate(&conn, job_id, 0, 4000, b"path", 4096).unwrap(); - record_terminal_outcome( - &mut conn, - job_id, - 0, - 1, - TerminalCandidateState::FailedRetryable, - ) - .unwrap(); - record_terminal_outcome(&mut conn, job_id, 0, 2, TerminalCandidateState::Succeeded) - .unwrap(); - - let attempt_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM attempts WHERE job_id = ?1 AND candidate_id = 0", - [job_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(attempt_count, 2, "both attempts must be retained"); - - let final_state: String = conn - .query_row( - "SELECT state FROM candidates WHERE job_id = ?1 AND candidate_id = 0", - [job_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!( - final_state, "Succeeded", - "candidates.state reflects only the latest attempt" - ); - } - - #[test] - fn crash_recovery_completed_candidates_survive_reconnect() { - // Simulates a producer crash: write a job with a mix of terminal - // and still-pending candidates, drop the connection without a - // clean job-complete step, reopen from the same file, and assert - // completed candidates stay completed (design-doc §19.2: "never - // assumes a file was accepted without durable ACK state"). - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("crash-recovery.sqlite3"); - let job_id = "job-crash"; - - let mut crashed_conn = open(Some(&path)).unwrap(); - create_job(&crashed_conn, &sample_job(job_id, 3)).unwrap(); - for candidate_id in 0..3_i64 { - insert_candidate( - &crashed_conn, - job_id, - candidate_id, - 5000 + candidate_id, - b"path", - 4096, - ) - .unwrap(); - } - record_terminal_outcome( - &mut crashed_conn, - job_id, - 0, - 1, - TerminalCandidateState::Succeeded, - ) - .unwrap(); - record_terminal_outcome( - &mut crashed_conn, - job_id, - 1, - 1, - TerminalCandidateState::FailedTerminal, - ) - .unwrap(); - // candidate 2 left Pending, simulating an in-flight read when the - // process died. Drop the connection explicitly rather than the - // above writes being rolled back — each was its own committed - // transaction, so this models a crash, not an abandoned one. - drop(crashed_conn); - - let conn = open(Some(&path)).unwrap(); - let summary = completeness_summary(&conn, job_id).unwrap(); - assert_eq!(summary.succeeded, 1); - assert_eq!(summary.failed_terminal, 1); - assert_eq!(summary.pending, 1); - assert!(!summary.is_complete()); - } -} diff --git a/crates/uffs-content/src/db/schema.rs b/crates/uffs-content/src/db/schema.rs deleted file mode 100644 index d59593deb..000000000 --- a/crates/uffs-content/src/db/schema.rs +++ /dev/null @@ -1,238 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2025-2026 SKY, LLC. - -//! Durable job/candidate/attempt/ACK/snapshot-lease schema (addendum §6.4). -//! -//! This is the single source of truth for "did every finalized manifest -//! candidate reach exactly one terminal outcome" — the completeness -//! invariant design-doc §2.2/§21.7 requires. It is deliberately not an -//! in-memory structure: addendum §6.1 requires it survive a producer -//! crash independently of Docenta. - -use rusqlite::Connection; - -/// Schema DDL. `CREATE TABLE IF NOT EXISTS` makes re-applying idempotent -/// — there is no migration framework yet (design-doc plan §7.1: "even a -/// single-file `CREATE TABLE` statement run idempotently is fine for the -/// first milestone; don't build a full migration framework prematurely"). -const SCHEMA_SQL: &str = " -CREATE TABLE IF NOT EXISTS jobs ( - job_id TEXT PRIMARY KEY, - source_id TEXT NOT NULL, - volume_identity TEXT NOT NULL, - root TEXT NOT NULL, - query_digest TEXT NOT NULL, - authorization_mode INTEGER NOT NULL, - state TEXT NOT NULL, - snapshot_lease_id INTEGER, - snapshot_id TEXT, - manifest_locator TEXT, - manifest_digest TEXT, - candidate_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - started_at INTEGER, - completed_at INTEGER, - expires_at INTEGER, - producer_build TEXT NOT NULL, - protocol_version INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS candidates ( - job_id TEXT NOT NULL, - candidate_id INTEGER NOT NULL, - full_file_reference INTEGER NOT NULL, - path_bytes BLOB NOT NULL, - path_encoding INTEGER NOT NULL, - logical_size INTEGER NOT NULL, - mtime INTEGER NOT NULL, - candidate_flags INTEGER NOT NULL, - state TEXT NOT NULL, - content_object_id INTEGER, - PRIMARY KEY (job_id, candidate_id), - FOREIGN KEY (job_id) REFERENCES jobs(job_id) -); - -CREATE TABLE IF NOT EXISTS attempts ( - attempt_id INTEGER PRIMARY KEY AUTOINCREMENT, - job_id TEXT NOT NULL, - candidate_id INTEGER NOT NULL, - attempt_number INTEGER NOT NULL, - lease_owner TEXT, - lease_generation INTEGER, - lease_expires_at INTEGER, - planned_mode TEXT, - actual_mode TEXT, - started_at INTEGER, - finished_at INTEGER, - bytes_emitted INTEGER, - content_digest TEXT, - terminal_outcome TEXT, - failure_stage TEXT, - error_code TEXT, - os_error_code INTEGER, - retry_class TEXT, - message TEXT, - FOREIGN KEY (job_id, candidate_id) REFERENCES candidates(job_id, candidate_id) -); - -CREATE TABLE IF NOT EXISTS consumer_acks ( - job_id TEXT NOT NULL, - candidate_id INTEGER NOT NULL, - content_digest TEXT NOT NULL, - consumer_instance_id TEXT, - ack_state TEXT NOT NULL, - acked_at INTEGER NOT NULL, - PRIMARY KEY (job_id, candidate_id, content_digest) -); - -CREATE TABLE IF NOT EXISTS snapshot_leases ( - snapshot_lease_id INTEGER PRIMARY KEY, - job_id TEXT NOT NULL, - snapshot_id TEXT, - broker_state TEXT, - created_at INTEGER NOT NULL, - expires_at INTEGER, - released_at INTEGER, - last_error TEXT -); - -CREATE TABLE IF NOT EXISTS job_events ( - event_id INTEGER PRIMARY KEY AUTOINCREMENT, - job_id TEXT NOT NULL, - occurred_at INTEGER NOT NULL, - event_type TEXT NOT NULL, - detail TEXT -); -"; - -/// Apply the schema to `conn`. Safe to call on every startup — every -/// statement is `CREATE TABLE IF NOT EXISTS`. -/// -/// # Errors -/// -/// Returns any [`rusqlite::Error`] from executing the DDL batch. -pub fn apply(conn: &Connection) -> rusqlite::Result<()> { - conn.execute_batch(SCHEMA_SQL) -} - -/// Open a connection at `path` (or an in-memory database when `path` is -/// `None`, for tests) with the durability pragmas addendum §6.3 -/// requires, and apply the schema. -/// -/// # Errors -/// -/// Returns any [`rusqlite::Error`] from opening the connection, setting -/// pragmas, or applying the schema. -pub fn open(db_path: Option<&std::path::Path>) -> rusqlite::Result { - let conn = match db_path { - Some(existing_path) => Connection::open(existing_path)?, - None => Connection::open_in_memory()?, - }; - // WAL is a no-op (and briefly errors) on `:memory:` connections in - // some SQLite builds; tolerate that specifically so in-memory test - // connections don't need a different pragma set than real files. - let _: String = conn - .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0)) - .or_else(|_err| { - conn.pragma_update_and_check(None, "journal_mode", "MEMORY", |row| row.get(0)) - })?; - conn.pragma_update(None, "foreign_keys", "ON")?; - conn.busy_timeout(core::time::Duration::from_secs(5))?; - // Durable-before-ack per addendum §6.3: manifest finalization, - // candidate terminal outcomes, consumer ACKs, retry decisions, - // snapshot-lease changes, and job terminal state. This connection is - // used for exactly those writes; a future high-frequency progress - // counter should get its own relaxed-synchronous connection rather - // than loosening this one. - conn.pragma_update(None, "synchronous", "FULL")?; - apply(&conn)?; - Ok(conn) -} - -#[cfg(test)] -mod tests { - use super::open; - - #[test] - fn schema_applies_to_fresh_in_memory_database() { - let conn = open(None).unwrap(); - // Excludes SQLite's own internal `sqlite_%` tables (e.g. - // `sqlite_sequence`, auto-created because `attempts`/`job_events` - // use `AUTOINCREMENT`) — this counts only our own schema's tables. - let table_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master \ - WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(table_count, 6, "expected all six tables from addendum §6.4"); - } - - #[test] - fn schema_reapplication_is_idempotent() { - let conn = open(None).unwrap(); - super::apply(&conn).unwrap(); - super::apply(&conn).unwrap(); - } - - #[test] - fn every_expected_table_exists() { - let conn = open(None).unwrap(); - for table in [ - "jobs", - "candidates", - "attempts", - "consumer_acks", - "snapshot_leases", - "job_events", - ] { - let exists: bool = conn - .query_row( - "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1", - [table], - |row| row.get(0), - ) - .unwrap(); - assert!(exists, "table '{table}' must exist"); - } - } - - #[test] - fn foreign_keys_pragma_is_enabled() { - let conn = open(None).unwrap(); - let enabled: i64 = conn - .pragma_query_value(None, "foreign_keys", |row| row.get(0)) - .unwrap(); - assert_eq!(enabled, 1); - } - - #[test] - fn opening_a_real_file_persists_across_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("uffs-content-jobs.sqlite3"); - - let first_conn = open(Some(&path)).unwrap(); - first_conn - .execute( - "INSERT INTO jobs (job_id, source_id, volume_identity, root, query_digest, \ - authorization_mode, state, candidate_count, created_at, producer_build, \ - protocol_version) VALUES ('job-1', 'src-1', 'vol-1', 'C:\\', 'digest', 0, \ - 'Created', 0, 0, 'test-build', 2)", - [], - ) - .unwrap(); - drop(first_conn); // explicit: reopen below must see this via the file, not the live handle - - let conn = open(Some(&path)).unwrap(); - let job_id: String = conn - .query_row( - "SELECT job_id FROM jobs WHERE job_id = 'job-1'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(job_id, "job-1"); - } -} diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 81047c5f4..8e810f152 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -18,9 +18,10 @@ //! # Status //! //! Job intake, VSS, MFT, and streaming logic are not implemented yet. -//! [`db`] (the durable job/failure-bucket ledger, addendum §6) is real. +//! [`run`] (the ephemeral per-run manifest/failure-log/summary model) is +//! real. -pub mod db; +pub mod run; // Not yet wired into this library's logic — reserved for the manifest / // frame types this crate will produce and consume once job intake lands. diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 6e7e5bf6e..12117744f 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -21,9 +21,11 @@ // Reserved for the wire types the bin will emit once job intake is wired // up; not yet used from this thin entry point. -// Used by `uffs_content::db`, not by this thin entry point directly. -use rusqlite as _; -// Dev-dependency used by `uffs_content::db`'s tests, not by this bin. +// Dev-dependency used by `uffs_content::run`'s tests, not by this bin. +// Used by `uffs_content::run` (failure log + summary serialization), not +// by this thin entry point directly. +use serde as _; +use serde_json as _; #[cfg(test)] use tempfile as _; use uffs_content_protocol as _; diff --git a/crates/uffs-content/src/run/failure_log.rs b/crates/uffs-content/src/run/failure_log.rs new file mode 100644 index 000000000..04440cf72 --- /dev/null +++ b/crates/uffs-content/src/run/failure_log.rs @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Append-only JSONL failure log: one record per non-success candidate. + +use std::fs::OpenOptions; +use std::io::{self, Write as _}; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use uffs_content_protocol::error::ErrorCode; +use uffs_content_protocol::frame::{FailedOutcome, FailureStage, RetryClass}; + +/// Discriminant for a [`FailureRecord`]'s outcome. +/// +/// Mirrors the non-success half of +/// [`uffs_content_protocol::state::CandidateOutcome`] (excludes +/// `Succeeded` — a successful candidate is never written to this log, +/// only to the manifest and the content stream itself). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailureOutcomeKind { + /// May be retried in a later job attempt against a new snapshot. + FailedRetryable, + /// Will not succeed on retry. + FailedTerminal, + /// Deferred to manual or later handling. + DeferredManual, +} + +impl From for FailureOutcomeKind { + fn from(outcome: FailedOutcome) -> Self { + match outcome { + FailedOutcome::Retryable => Self::FailedRetryable, + FailedOutcome::Terminal => Self::FailedTerminal, + } + } +} + +/// One non-success candidate outcome, as persisted to the run's failure +/// log. +/// +/// Serialized one JSON object per line (JSONL), appended as candidates +/// resolve — see [`FailureLogWriter`]. A candidate present in the +/// manifest but absent from both this log and the successful-content +/// stream simply hasn't resolved yet; a reader must not infer success +/// from mere absence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FailureRecord { + /// Candidate this record terminates. + pub candidate_id: u64, + /// Which of the three non-success outcomes this is. + pub outcome: FailureOutcomeKind, + /// Which pipeline stage the failure occurred at. Absent for + /// `DeferredManual` (a deferral isn't a failure at a stage). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub failure_stage: Option, + /// Stable machine-readable error/reason code + /// ([`ErrorCode::as_str`]). + pub error_code: String, + /// Underlying OS error code, if applicable. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub os_error_code: Option, + /// How this failure may be retried. Absent for `DeferredManual`. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub retry_class: Option, + /// Bytes emitted before the failure (0 for a deferral, which never + /// starts streaming a body). + pub bytes_emitted_before_failure: u64, + /// Human-readable diagnostic message. + pub message: String, +} + +impl FailureRecord { + /// Build a record for a `FAILED_RETRYABLE`/`FAILED_TERMINAL` outcome. + #[must_use] + #[expect( + clippy::too_many_arguments, + reason = "single call site, flat args mirroring FILE_FAILED's own field list" + )] + pub fn failed>( + candidate_id: u64, + outcome: FailedOutcome, + failure_stage: FailureStage, + error_code: ErrorCode, + os_error_code: Option, + retry_class: RetryClass, + bytes_emitted_before_failure: u64, + message: S, + ) -> Self { + Self { + candidate_id, + outcome: outcome.into(), + failure_stage: Some(failure_stage_label(failure_stage).to_owned()), + error_code: error_code.as_str().to_owned(), + os_error_code, + retry_class: Some(retry_class_label(retry_class).to_owned()), + bytes_emitted_before_failure, + message: message.into(), + } + } + + /// Build a record for a `DEFERRED_MANUAL` outcome. + #[must_use] + pub fn deferred>( + candidate_id: u64, + reason_code: ErrorCode, + message: S, + ) -> Self { + Self { + candidate_id, + outcome: FailureOutcomeKind::DeferredManual, + failure_stage: None, + error_code: reason_code.as_str().to_owned(), + os_error_code: None, + retry_class: None, + bytes_emitted_before_failure: 0, + message: message.into(), + } + } + + /// Serialize this record as one JSON line (no trailing newline). + /// + /// # Errors + /// Returns an error if JSON serialization fails. + pub fn to_json_line(&self) -> serde_json::Result { + serde_json::to_string(self) + } +} + +/// Stable `snake_case` label for a [`FailureStage`], for JSON — kept local +/// to this log rather than added to the wire-protocol type, since the +/// binary wire codec (design-doc §5.4) and this auxiliary JSON log are +/// deliberately separate concerns. +const fn failure_stage_label(stage: FailureStage) -> &'static str { + match stage { + FailureStage::SnapshotCreate => "snapshot_create", + FailureStage::SnapshotOpen => "snapshot_open", + FailureStage::Enumeration => "enumeration", + FailureStage::Identity => "identity", + FailureStage::StreamResolution => "stream_resolution", + FailureStage::RunlistValidation => "runlist_validation", + FailureStage::Read => "read", + FailureStage::Reconstruction => "reconstruction", + FailureStage::Hash => "hash", + FailureStage::Transport => "transport", + FailureStage::ConsumerAck => "consumer_ack", + FailureStage::Internal => "internal", + } +} + +/// Stable `snake_case` label for a [`RetryClass`], for JSON — see +/// [`failure_stage_label`] for why this lives here instead of on the +/// wire-protocol type. +const fn retry_class_label(class: RetryClass) -> &'static str { + match class { + RetryClass::RetrySameJob => "retry_same_job", + RetryClass::RetryNewSnapshot => "retry_new_snapshot", + RetryClass::RetryAfterResourceChange => "retry_after_resource_change", + RetryClass::RetryWithManualHandler => "retry_with_manual_handler", + RetryClass::RetryWithCredentialOrKey => "retry_with_credential_or_key", + RetryClass::DoNotRetry => "do_not_retry", + } +} + +/// Append-only writer for a run's failure log. +/// +/// Opens (or creates) the file in append mode and flushes after every +/// record, so a reader tailing the file mid-run always sees complete +/// lines — there is no cross-call buffering to lose on a crash. +#[derive(Debug)] +pub struct FailureLogWriter { + /// The open file handle, in append mode. + file: std::fs::File, +} + +impl FailureLogWriter { + /// Open (creating if absent) the failure log at `path` for + /// appending. + /// + /// # Errors + /// Propagates the underlying [`io::Error`] from opening the file. + pub fn open(path: &Path) -> io::Result { + let file = OpenOptions::new().create(true).append(true).open(path)?; + Ok(Self { file }) + } + + /// Append one record as a JSON line. + /// + /// # Errors + /// Returns an error if serialization or the write/flush fails. + pub fn append(&mut self, record: &FailureRecord) -> io::Result<()> { + let line = record + .to_json_line() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + writeln!(self.file, "{line}")?; + self.file.flush() + } +} diff --git a/crates/uffs-content/src/run/mod.rs b/crates/uffs-content/src/run/mod.rs new file mode 100644 index 000000000..db833adf4 --- /dev/null +++ b/crates/uffs-content/src/run/mod.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Ephemeral per-run bookkeeping. +//! +//! A run's state is intentionally **not** a transactional per-candidate +//! job database. Three artifacts on disk fully describe a run: +//! +//! 1. The immutable candidate manifest ([`uffs_content_protocol::manifest`]) — +//! written once, before streaming starts, and never modified. +//! 2. An append-only JSONL failure log (`run-.failures.jsonl`) — one +//! [`FailureRecord`] line per non-success candidate, appended as candidates +//! resolve. See [`FailureLogWriter`]. +//! 3. A [`RunSummary`], atomically finalized only once every candidate has +//! reached a terminal outcome (`run-.summary.json`). See +//! [`RunCounters::finalize`] and [`RunSummary::finalize_to_disk`]. +//! +//! There is no durable per-candidate ledger, no lease/attempt history, +//! and no crash-recovery reconciliation: if the process or machine dies +//! before the summary is finalized, the run is simply incomplete — the +//! existence of a valid final summary file *is* the job-completion +//! marker. Re-running from a fresh VSS snapshot is the recovery path, +//! not resuming mid-job: Docenta's own content-hash deduplication makes +//! re-streaming already-ingested content on a rerun a no-op on the +//! consumer side, so restarting from zero wastes no meaningful work. + +mod failure_log; +mod summary; + +pub use failure_log::{FailureLogWriter, FailureOutcomeKind, FailureRecord}; +pub use summary::{RunCounters, RunSummary, SummaryFinalizeError}; + +#[cfg(test)] +mod tests; diff --git a/crates/uffs-content/src/run/summary.rs b/crates/uffs-content/src/run/summary.rs new file mode 100644 index 000000000..39bb71552 --- /dev/null +++ b/crates/uffs-content/src/run/summary.rs @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! In-memory run counters + the atomically finalized run summary. + +use std::fs::{self, File}; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// In-memory tally of candidate outcomes as a run streams. +/// +/// Never persisted mid-run — see the [`crate::run`] module docs for why +/// there is no durable per-candidate ledger. Only +/// [`RunCounters::finalize`] turns this into a [`RunSummary`], and only +/// once every candidate is accounted for. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RunCounters { + /// Total candidates in the finalized manifest this run is streaming. + pub candidate_count: u64, + /// Candidates that succeeded. + pub succeeded_count: u64, + /// Candidates that failed retryably. + pub failed_retryable_count: u64, + /// Candidates that failed terminally. + pub failed_terminal_count: u64, + /// Candidates deferred to manual handling. + pub deferred_manual_count: u64, + /// Total logical bytes across all successful candidates. + pub logical_bytes_succeeded: u64, +} + +impl RunCounters { + /// Start a fresh counter set for a run whose manifest has + /// `candidate_count` candidates. + #[must_use] + pub const fn new(candidate_count: u64) -> Self { + Self { + candidate_count, + succeeded_count: 0, + failed_retryable_count: 0, + failed_terminal_count: 0, + deferred_manual_count: 0, + logical_bytes_succeeded: 0, + } + } + + /// Record one succeeded candidate. + pub const fn record_succeeded(&mut self, logical_size: u64) { + self.succeeded_count += 1; + self.logical_bytes_succeeded += logical_size; + } + + /// Record one failed-retryable candidate. + pub const fn record_failed_retryable(&mut self) { + self.failed_retryable_count += 1; + } + + /// Record one failed-terminal candidate. + pub const fn record_failed_terminal(&mut self) { + self.failed_terminal_count += 1; + } + + /// Record one deferred-manual candidate. + pub const fn record_deferred_manual(&mut self) { + self.deferred_manual_count += 1; + } + + /// Total candidates that have reached *any* terminal outcome so far. + #[must_use] + pub const fn resolved_count(&self) -> u64 { + self.succeeded_count + + self.failed_retryable_count + + self.failed_terminal_count + + self.deferred_manual_count + } + + /// Whether every candidate in the manifest has a terminal outcome. + #[must_use] + pub const fn is_complete(&self) -> bool { + self.resolved_count() == self.candidate_count + } + + /// Turn these counters into a [`RunSummary`], failing if any + /// candidate has not yet reached a terminal outcome. + /// + /// # Errors + /// Returns [`SummaryFinalizeError::Incomplete`] if + /// [`RunCounters::is_complete`] is false. + pub fn finalize( + self, + run_id: String, + started_at_unix_ms: i64, + finished_at_unix_ms: i64, + ) -> Result { + if !self.is_complete() { + return Err(SummaryFinalizeError::Incomplete { + candidate_count: self.candidate_count, + resolved_count: self.resolved_count(), + }); + } + Ok(RunSummary { + run_id, + started_at_unix_ms, + finished_at_unix_ms, + candidate_count: self.candidate_count, + succeeded_count: self.succeeded_count, + failed_retryable_count: self.failed_retryable_count, + failed_terminal_count: self.failed_terminal_count, + deferred_manual_count: self.deferred_manual_count, + logical_bytes_succeeded: self.logical_bytes_succeeded, + }) + } +} + +/// Why [`RunCounters::finalize`] refused to produce a [`RunSummary`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SummaryFinalizeError { + /// Not every candidate has reached a terminal outcome yet. + Incomplete { + /// Candidates in the manifest. + candidate_count: u64, + /// Candidates that have resolved so far. + resolved_count: u64, + }, +} + +impl core::fmt::Display for SummaryFinalizeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Incomplete { + candidate_count, + resolved_count, + } => write!( + f, + "cannot finalize run summary: {resolved_count} of {candidate_count} \ + candidates have a terminal outcome" + ), + } + } +} + +impl core::error::Error for SummaryFinalizeError {} + +/// The finalized, immutable record of one completed run. +/// +/// The existence of a valid file at this summary's final path (no +/// `.partial` suffix) is the job-completion marker: if a run or the +/// machine crashes before [`RunSummary::finalize_to_disk`] renames the +/// `.partial` file into place, the run is incomplete, full stop. There is +/// no partial-completion state to reconcile — rerun the job from a new +/// VSS snapshot (see the [`crate::run`] module docs). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunSummary { + /// Identifier for this run (matches the manifest's `job_id`, rendered + /// as a string for JSON). + pub run_id: String, + /// When the run started, Unix milliseconds. + pub started_at_unix_ms: i64, + /// When the run finished (all candidates resolved), Unix + /// milliseconds. + pub finished_at_unix_ms: i64, + /// Total candidates in the finalized manifest. + pub candidate_count: u64, + /// Candidates that succeeded. + pub succeeded_count: u64, + /// Candidates that failed retryably. + pub failed_retryable_count: u64, + /// Candidates that failed terminally. + pub failed_terminal_count: u64, + /// Candidates deferred to manual handling. + pub deferred_manual_count: u64, + /// Total logical bytes across all successful candidates. + pub logical_bytes_succeeded: u64, +} + +impl RunSummary { + /// Atomically finalize this summary to `final_path`. + /// + /// Writes to a sibling `.partial` file first, `fsync`s it, then + /// renames it into place — the rename is the atomic step a reader + /// can rely on to never observe a half-written summary. `final_path` + /// must not already exist (a run's summary is written exactly once). + /// + /// # Errors + /// Returns an [`io::ErrorKind::AlreadyExists`] error if `final_path` + /// already exists, and otherwise propagates the underlying + /// [`io::Error`] from any filesystem step. + pub fn finalize_to_disk(&self, final_path: &Path) -> io::Result<()> { + if final_path.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("run summary already finalized at {}", final_path.display()), + )); + } + let partial_path = partial_path_for(final_path); + let json = serde_json::to_vec_pretty(self) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let mut partial_file = File::create(&partial_path)?; + partial_file.write_all(&json)?; + partial_file.sync_all()?; + drop(partial_file); + fs::rename(&partial_path, final_path)?; + Ok(()) + } + + /// Read a previously finalized summary from `final_path`. + /// + /// Returns `Ok(None)` if no file exists there yet (i.e. the run has + /// not completed) rather than an error — "not finalized" is an + /// expected, common state, not a failure. + /// + /// # Errors + /// Propagates I/O errors other than "not found", and wraps a JSON + /// parse failure (for a file that exists but is not a valid summary) + /// as an [`io::Error`]. + pub fn load_if_finalized(final_path: &Path) -> io::Result> { + match fs::read(final_path) { + Ok(bytes) => { + let summary = serde_json::from_slice(&bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + Ok(Some(summary)) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + } + } +} + +/// The `.partial` sibling path used during atomic finalization. +fn partial_path_for(final_path: &Path) -> PathBuf { + let mut partial = final_path.as_os_str().to_owned(); + partial.push(".partial"); + PathBuf::from(partial) +} diff --git a/crates/uffs-content/src/run/tests.rs b/crates/uffs-content/src/run/tests.rs new file mode 100644 index 000000000..521ca87b1 --- /dev/null +++ b/crates/uffs-content/src/run/tests.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Tests for the ephemeral run-state model (manifest + failure log + +//! atomically finalized summary). + +use std::fs; + +use uffs_content_protocol::error::ErrorCode; +use uffs_content_protocol::frame::{FailedOutcome, FailureStage, RetryClass}; + +use super::{ + FailureLogWriter, FailureOutcomeKind, FailureRecord, RunCounters, SummaryFinalizeError, +}; + +#[test] +fn finalize_rejects_incomplete_run() { + let mut counters = RunCounters::new(3); + counters.record_succeeded(100); + counters.record_failed_terminal(); + // Only 2 of 3 candidates resolved. + assert!(!counters.is_complete()); + + let err = counters + .finalize("run-1".to_owned(), 1_000, 2_000) + .expect_err("must not finalize with unresolved candidates"); + assert_eq!(err, SummaryFinalizeError::Incomplete { + candidate_count: 3, + resolved_count: 2, + }); +} + +#[test] +fn finalize_succeeds_once_every_candidate_is_resolved() { + let mut counters = RunCounters::new(4); + counters.record_succeeded(100); + counters.record_succeeded(200); + counters.record_failed_retryable(); + counters.record_deferred_manual(); + assert!(counters.is_complete()); + + let summary = counters + .finalize("run-2".to_owned(), 1_000, 5_000) + .expect("all candidates resolved, finalize must succeed"); + assert_eq!(summary.candidate_count, 4); + assert_eq!(summary.succeeded_count, 2); + assert_eq!(summary.failed_retryable_count, 1); + assert_eq!(summary.failed_terminal_count, 0); + assert_eq!(summary.deferred_manual_count, 1); + assert_eq!(summary.logical_bytes_succeeded, 300); +} + +#[test] +fn atomic_finalize_writes_final_file_and_removes_partial() { + let dir = tempfile::tempdir().expect("create temp dir"); + let final_path = dir.path().join("run-3.summary.json"); + let partial_path = dir.path().join("run-3.summary.json.partial"); + + let mut counters = RunCounters::new(1); + counters.record_succeeded(42); + let summary = counters + .finalize("run-3".to_owned(), 10, 20) + .expect("complete run finalizes"); + + summary + .finalize_to_disk(&final_path) + .expect("finalize_to_disk must succeed"); + + assert!(final_path.exists(), "final summary file must exist"); + assert!( + !partial_path.exists(), + "partial file must be gone after rename" + ); + + let loaded = super::RunSummary::load_if_finalized(&final_path) + .expect("load must succeed") + .expect("summary must be present"); + assert_eq!(loaded, summary); +} + +#[test] +fn finalize_to_disk_refuses_to_overwrite_existing_summary() { + let dir = tempfile::tempdir().expect("create temp dir"); + let final_path = dir.path().join("run-4.summary.json"); + + let mut counters = RunCounters::new(1); + counters.record_succeeded(1); + let summary = counters + .finalize("run-4".to_owned(), 0, 1) + .expect("complete run finalizes"); + summary + .finalize_to_disk(&final_path) + .expect("first finalize must succeed"); + + let err = summary + .finalize_to_disk(&final_path) + .expect_err("second finalize to the same path must fail"); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); +} + +#[test] +fn unfinalized_run_reports_no_summary_rather_than_fabricating_one() { + let dir = tempfile::tempdir().expect("create temp dir"); + let final_path = dir.path().join("run-5.summary.json"); + + let loaded = + super::RunSummary::load_if_finalized(&final_path).expect("missing file is not an error"); + assert!( + loaded.is_none(), + "no summary file present means the run is incomplete, not a default/empty summary" + ); +} + +#[test] +fn failure_log_appends_and_round_trips_jsonl() { + let dir = tempfile::tempdir().expect("create temp dir"); + let log_path = dir.path().join("run-6.failures.jsonl"); + + let failed = FailureRecord::failed( + 7, + FailedOutcome::Retryable, + FailureStage::Read, + ErrorCode::ReadIoTransient, + Some(5), + RetryClass::RetryNewSnapshot, + 1_024, + "transient read error", + ); + let deferred = FailureRecord::deferred(9, ErrorCode::CompressedManual, "NTFS-compressed"); + + let mut writer = FailureLogWriter::open(&log_path).expect("open failure log"); + writer.append(&failed).expect("append failed record"); + writer.append(&deferred).expect("append deferred record"); + drop(writer); + + let contents = fs::read_to_string(&log_path).expect("read failure log"); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 2, "one JSON object per appended record"); + + let line_failed = lines.first().expect("first line present"); + let line_deferred = lines.get(1).expect("second line present"); + + let decoded_failed: FailureRecord = + serde_json::from_str(line_failed).expect("decode first line"); + assert_eq!(decoded_failed, failed); + assert_eq!(decoded_failed.outcome, FailureOutcomeKind::FailedRetryable); + + let decoded_deferred: FailureRecord = + serde_json::from_str(line_deferred).expect("decode second line"); + assert_eq!(decoded_deferred, deferred); + assert_eq!(decoded_deferred.outcome, FailureOutcomeKind::DeferredManual); + assert!(decoded_deferred.failure_stage.is_none()); + assert!(decoded_deferred.retry_class.is_none()); +} + +#[test] +fn failure_log_writer_appends_across_reopens() { + let dir = tempfile::tempdir().expect("create temp dir"); + let log_path = dir.path().join("run-7.failures.jsonl"); + + let mut first_writer = FailureLogWriter::open(&log_path).expect("open failure log"); + first_writer + .append(&FailureRecord::deferred(1, ErrorCode::SparseManual, "a")) + .expect("append first"); + drop(first_writer); + + let mut second_writer = FailureLogWriter::open(&log_path).expect("reopen failure log"); + second_writer + .append(&FailureRecord::deferred(2, ErrorCode::SparseManual, "b")) + .expect("append second after reopen"); + drop(second_writer); + + let contents = fs::read_to_string(&log_path).expect("read failure log"); + assert_eq!( + contents.lines().count(), + 2, + "reopening must append, not truncate" + ); +} From a388258412c911733b489ea76665fd223d98c28c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:38:42 -0700 Subject: [PATCH 13/98] =?UTF-8?q?feat(content):=20job=20workflow=20+=20fas?= =?UTF-8?q?t=20dir-walk=20parity=20harness=20(UFI.0/=C2=A79.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the real Coordinator workflow (job intake, candidate enumeration, manifest construction, protocol framing) against swappable CandidateSource/ContentSource backends, per the implementation plan's §9.5 "fast" test strategy. DirWalkCandidateSource/FsContentSource are real, correct, std::fs-backed stand-ins for the VSS-snapshot and privileged-Reader-backed implementations that land in UFI.1/UFI.2. Adds the end-to-end dir-walk parity harness: a deterministic fixture tree (size classes, non-BMP filename, hard links), an independent plain_walk oracle sharing no code with the pipeline, a minimal protocol-decoding test consumer, and a parity test that decodes real wire bytes and recomputes content digests independently rather than trusting the producer's self-reported ones. --- Cargo.lock | 2 + crates/uffs-content/Cargo.toml | 7 + .../uffs-content/src/job/candidate_source.rs | 116 +++++++ crates/uffs-content/src/job/content_source.rs | 62 ++++ crates/uffs-content/src/job/intake.rs | 23 ++ .../uffs-content/src/job/manifest_builder.rs | 96 ++++++ crates/uffs-content/src/job/mod.rs | 23 ++ crates/uffs-content/src/job/tests.rs | 177 ++++++++++ crates/uffs-content/src/job/workflow.rs | 313 ++++++++++++++++++ crates/uffs-content/src/lib.rs | 28 +- crates/uffs-content/src/main.rs | 24 +- .../tests/e2e_dir_walk_parity_fake_reader.rs | 201 +++++++++++ .../tests/support/fixture_tree.rs | 108 ++++++ crates/uffs-content/tests/support/mod.rs | 9 + .../uffs-content/tests/support/plain_walk.rs | 58 ++++ .../tests/support/test_consumer.rs | 150 +++++++++ 16 files changed, 1380 insertions(+), 17 deletions(-) create mode 100644 crates/uffs-content/src/job/candidate_source.rs create mode 100644 crates/uffs-content/src/job/content_source.rs create mode 100644 crates/uffs-content/src/job/intake.rs create mode 100644 crates/uffs-content/src/job/manifest_builder.rs create mode 100644 crates/uffs-content/src/job/mod.rs create mode 100644 crates/uffs-content/src/job/tests.rs create mode 100644 crates/uffs-content/src/job/workflow.rs create mode 100644 crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs create mode 100644 crates/uffs-content/tests/support/fixture_tree.rs create mode 100644 crates/uffs-content/tests/support/mod.rs create mode 100644 crates/uffs-content/tests/support/plain_walk.rs create mode 100644 crates/uffs-content/tests/support/test_consumer.rs diff --git a/Cargo.lock b/Cargo.lock index 84184a05c..0eabe9f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4481,11 +4481,13 @@ dependencies = [ name = "uffs-content" version = "0.6.27" dependencies = [ + "blake3", "serde", "serde_json", "tempfile", "uffs-content-protocol", "uffs-version", + "uuid", ] [[package]] diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 9b2af5b25..2c0d1c14c 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -63,9 +63,16 @@ uffs-content-protocol.workspace = true # the only serialization this crate needs. serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +# Job/run identifiers (`ManifestHeader::job_id`, etc.) — see `src/job/`. +uuid.workspace = true [dev-dependencies] tempfile.workspace = true +# Independent oracle digest for the E2E dir-walk parity harness +# (`tests/support/plain_walk.rs`) — deliberately calls the `blake3` crate +# directly rather than going through `uffs-content-protocol::codec::digest`, +# so a bug in that wrapper can't hide from the parity test. +blake3.workspace = true [lints] workspace = true diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs new file mode 100644 index 000000000..ad3c9f2c1 --- /dev/null +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Candidate enumeration: turns a job's root directory into the flat list +//! of files that will become manifest candidates. + +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +/// One enumerated candidate, before it's assigned a `candidate_id` and +/// turned into a `CandidateRecord` (see [`super::manifest_builder`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CandidateEntry { + /// Path relative to the job's root. + pub relative_path: PathBuf, + /// Absolute path a [`super::content_source::ContentSource`] can open. + pub absolute_path: PathBuf, + /// Logical file size in bytes. + pub logical_size: u64, + /// Modification time, Unix milliseconds. + pub mtime_unix_ms: i64, + /// Filesystem-assigned unique identity for this file. In production + /// this is the NTFS file reference; this crate's cross-platform + /// source uses the OS's native per-volume file identifier, which is + /// stable across hard links the same way an NTFS file reference is. + pub file_reference: u64, +} + +/// Produces the candidate list for a job. +/// +/// The production implementation (not yet built — UFI.1/UFI.2) evaluates +/// the job's UFFS query against an `MftIndex` built from a VSS snapshot. +/// [`DirWalkCandidateSource`] is a real, correct, but non-privileged +/// stand-in used until that lands: it walks the live filesystem directly, +/// which is exactly right for testing the Coordinator's own logic (this +/// is `uffs-ingest-implementation-plan.md` §9.5's "fast" harness) but is +/// not how a shipped job runs against NTFS. +pub trait CandidateSource { + /// Enumerate every regular file under `root`. + /// + /// # Errors + /// Propagates the underlying [`io::Error`] from directory traversal. + fn enumerate(&self, root: &Path) -> io::Result>; +} + +/// Enumerates candidates by walking the live filesystem with `std::fs`. +#[derive(Debug, Clone, Copy, Default)] +pub struct DirWalkCandidateSource; + +impl CandidateSource for DirWalkCandidateSource { + fn enumerate(&self, root: &Path) -> io::Result> { + let mut entries = Vec::new(); + walk(root, root, &mut entries)?; + Ok(entries) + } +} + +/// Recursively walks `dir` (rooted at `root`), appending one +/// [`CandidateEntry`] per regular file found, in deterministic +/// (path-sorted) order. +fn walk(root: &Path, dir: &Path, out: &mut Vec) -> io::Result<()> { + let mut dir_entries: Vec = fs::read_dir(dir)?.collect::>()?; + dir_entries.sort_by_key(fs::DirEntry::path); + + for entry in dir_entries { + let path = entry.path(); + let metadata = entry.metadata()?; + if metadata.is_dir() { + walk(root, &path, out)?; + } else if metadata.is_file() { + let relative_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + out.push(CandidateEntry { + relative_path, + absolute_path: path, + logical_size: metadata.len(), + mtime_unix_ms: mtime_unix_ms(&metadata), + file_reference: file_identity(&metadata), + }); + } + } + Ok(()) +} + +/// Extracts a file's modification time as Unix milliseconds, defaulting +/// to `0` if the platform can't report one or it predates the epoch. +fn mtime_unix_ms(metadata: &fs::Metadata) -> i64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +/// The file's inode number — stable across hard links to the same file. +#[cfg(unix)] +fn file_identity(metadata: &fs::Metadata) -> u64 { + use std::os::unix::fs::MetadataExt as _; + metadata.ino() +} + +/// The file's NTFS file index — stable across hard links to the same +/// file, the Windows analogue of a Unix inode number. +#[cfg(windows)] +fn file_identity(metadata: &fs::Metadata) -> u64 { + use std::os::windows::fs::MetadataExt as _; + metadata.file_index().unwrap_or(0) +} + +/// No native per-volume file identity is available on this platform; +/// hard-link detection simply won't apply here. +#[cfg(not(any(unix, windows)))] +const fn file_identity(_metadata: &fs::Metadata) -> u64 { + 0 +} diff --git a/crates/uffs-content/src/job/content_source.rs b/crates/uffs-content/src/job/content_source.rs new file mode 100644 index 000000000..f3b94b7c9 --- /dev/null +++ b/crates/uffs-content/src/job/content_source.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Content reading: turns a candidate + byte range into logical bytes. + +use std::fs::File; +use std::io::{self, Read as _, Seek as _, SeekFrom}; + +use super::candidate_source::CandidateEntry; + +/// Reads a bounded range of a candidate's logical content. +/// +/// The production implementation (not yet built — UFI.2) is +/// `uffs-content`'s IPC client to `uffs-content-reader`, which resolves +/// and reads against a VSS snapshot device, never the live volume. +/// [`FsContentSource`] is a real, correct, but unprivileged stand-in: it +/// reads the live file directly with `std::fs`. See +/// [`super::candidate_source::CandidateSource`] for why that's the right +/// trade-off for this crate's own fast, cross-platform test harness. +pub trait ContentSource { + /// Read up to `max_len` bytes starting at `offset` from `candidate`. + /// + /// Returns fewer than `max_len` bytes only at EOF (matching a normal + /// [`std::io::Read::read`] short-read contract at end of file); an + /// empty result means `offset` was at or past EOF. + /// + /// # Errors + /// Propagates the underlying [`io::Error`] from opening/seeking/ + /// reading the file. + fn read_at(&self, candidate: &CandidateEntry, offset: u64, max_len: u32) + -> io::Result>; +} + +/// Reads content directly from the live filesystem. +#[derive(Debug, Clone, Copy, Default)] +pub struct FsContentSource; + +impl ContentSource for FsContentSource { + fn read_at( + &self, + candidate: &CandidateEntry, + offset: u64, + max_len: u32, + ) -> io::Result> { + let mut file = File::open(&candidate.absolute_path)?; + file.seek(SeekFrom::Start(offset))?; + + let capacity = usize::try_from(max_len).unwrap_or(usize::MAX); + let mut buffer = vec![0_u8; capacity]; + let mut total_read = 0_usize; + while total_read < buffer.len() { + let remaining = buffer.get_mut(total_read..).unwrap_or(&mut []); + let read = file.read(remaining)?; + if read == 0 { + break; + } + total_read += read; + } + buffer.truncate(total_read); + Ok(buffer) + } +} diff --git a/crates/uffs-content/src/job/intake.rs b/crates/uffs-content/src/job/intake.rs new file mode 100644 index 000000000..fe69d1926 --- /dev/null +++ b/crates/uffs-content/src/job/intake.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Job intake: the structured request that starts a content-ingest run. + +use std::path::PathBuf; + +/// A request to ingest content under `root`. +/// +/// This is the local job-submission format — ordinary JSON, unlike the +/// Docenta-facing frame protocol, which uses the explicit binary codec +/// (addendum §5.4). Query filtering (extension/date/size) is not wired up +/// yet: every job currently matches every regular file under `root` +/// (equivalent to a `"*"` query) — see [`super::candidate_source`]. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct JobRequest { + /// Identifier for the source this job's candidates came from. + /// `ManifestHeader::source_id` is derived deterministically from this + /// string (see [`super::workflow::run_job`]). + pub source_id: String, + /// Root directory to enumerate candidates under. + pub root: PathBuf, +} diff --git a/crates/uffs-content/src/job/manifest_builder.rs b/crates/uffs-content/src/job/manifest_builder.rs new file mode 100644 index 000000000..b17fc56fd --- /dev/null +++ b/crates/uffs-content/src/job/manifest_builder.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Builds a finalized candidate manifest from an enumerated candidate +//! list (design-doc §4.1 step 8: checksummed and finalized before any +//! candidate is processed). + +use uffs_content_protocol::codec::Digest; +use uffs_content_protocol::manifest::{ + AuthorizationMode, CandidateFlags, CandidateRecord, ManifestError, ManifestHeader, + ManifestTrailer, +}; +use uffs_content_protocol::path_encoding::WindowsPath; + +use super::candidate_source::CandidateEntry; + +/// A finalized manifest: encoded bytes plus the metadata a job needs to +/// build its `JOB_BEGIN`/`JOB_END` frames. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BuiltManifest { + /// Header + record section + trailer, exactly as they appear on disk. + pub bytes: Vec, + /// BLAKE3 digest over the header + record section — the trailer's + /// `manifest_digest`, and what `JOB_BEGIN.manifest_digest` repeats. + pub manifest_digest: Digest, + /// `candidate_id` assigned to each input entry, in the same order as + /// the `entries` slice given to [`build_manifest`]. + pub candidate_ids: Vec, +} + +/// Assigns sequential `candidate_id`s and builds a finalized manifest for +/// `entries`. +/// +/// # Errors +/// Propagates [`ManifestError`] from encoding any header/record (only +/// possible for implausibly large fields — see +/// [`CandidateRecord::encode`]). +pub fn build_manifest( + job_id: [u8; 16], + source_id: [u8; 16], + query_digest: Digest, + entries: &[CandidateEntry], +) -> Result { + let mut record_bytes = Vec::new(); + let mut candidate_ids = Vec::with_capacity(entries.len()); + for (index, entry) in entries.iter().enumerate() { + let candidate_id = index_to_candidate_id(index); + candidate_ids.push(candidate_id); + let record = CandidateRecord { + candidate_id, + file_reference: entry.file_reference, + logical_size: entry.logical_size, + valid_data_length: entry.logical_size, + mtime_unix_ms: entry.mtime_unix_ms, + candidate_flags: CandidateFlags::empty(), + path: WindowsPath::from_str_lossless(&entry.relative_path.to_string_lossy()), + }; + record_bytes.extend_from_slice(&record.encode()?); + } + + let candidate_count = u64::try_from(entries.len()).unwrap_or(u64::MAX); + let header = ManifestHeader { + format_version: 2, + job_id, + source_id, + volume_serial: 0, + volume_guid: Vec::new(), + snapshot_id: Vec::new(), + snapshot_created_unix_ms: 0, + query_digest, + authorization_mode: AuthorizationMode::AdminExport, + candidate_count, + record_section_length: u64::try_from(record_bytes.len()).unwrap_or(u64::MAX), + }; + + let mut bytes = header.encode()?; + bytes.extend_from_slice(&record_bytes); + let manifest_digest = ManifestTrailer::compute_digest(&bytes); + let trailer = ManifestTrailer { + candidate_count_repeat: candidate_count, + manifest_digest, + }; + bytes.extend_from_slice(&trailer.encode()); + + Ok(BuiltManifest { + bytes, + manifest_digest, + candidate_ids, + }) +} + +/// `candidate_id` assignment policy: sequential, 1-based (`0` is left +/// unused as a future not-a-candidate sentinel might want it). +fn index_to_candidate_id(index: usize) -> u64 { + u64::try_from(index).unwrap_or(u64::MAX).saturating_add(1) +} diff --git a/crates/uffs-content/src/job/mod.rs b/crates/uffs-content/src/job/mod.rs new file mode 100644 index 000000000..90f2b8486 --- /dev/null +++ b/crates/uffs-content/src/job/mod.rs @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Job intake and execution: the real Coordinator workflow described in +//! `docs/dev/architecture/uffs-ingest-implementation-plan.md` §6. +//! +//! Built against swappable [`candidate_source::CandidateSource`] / +//! [`content_source::ContentSource`] backends so it can be exercised +//! today — via this crate's own +//! [`candidate_source::DirWalkCandidateSource`] / +//! [`content_source::FsContentSource`] — ahead of the real +//! Broker/Reader-backed implementations landing (UFI.1/UFI.2). This is +//! also what powers the plan's §9.5 "fast" end-to-end dir-walk parity +//! harness (`crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs`). + +pub mod candidate_source; +pub mod content_source; +pub mod intake; +pub mod manifest_builder; +pub mod workflow; + +#[cfg(test)] +mod tests; diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs new file mode 100644 index 000000000..c27a1240d --- /dev/null +++ b/crates/uffs-content/src/job/tests.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Unit tests for job intake, candidate/content sources, manifest +//! building, and the end-to-end workflow. The full directory-walk +//! parity check against an independent oracle lives in +//! `crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs` — these +//! tests instead cover this module's own internals in isolation. + +use std::fs; + +use uffs_content_protocol::codec::Reader; +use uffs_content_protocol::frame::{FrameEnvelope, FrameType}; +use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader, ManifestTrailer}; + +use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; +use super::content_source::{ContentSource as _, FsContentSource}; +use super::intake::JobRequest; +use super::manifest_builder::build_manifest; +use super::workflow::run_job; + +#[test] +fn dir_walk_candidate_source_enumerates_files_not_directories() { + let dir = tempfile::tempdir().expect("create temp dir"); + fs::create_dir_all(dir.path().join("nested")).expect("create nested dir"); + fs::write(dir.path().join("a.txt"), b"a").expect("write a.txt"); + fs::write(dir.path().join("nested/b.txt"), b"bb").expect("write nested/b.txt"); + + let entries = DirWalkCandidateSource + .enumerate(dir.path()) + .expect("enumerate must succeed"); + + let mut relative_paths: Vec<_> = entries + .iter() + .map(|entry| entry.relative_path.clone()) + .collect(); + relative_paths.sort(); + assert_eq!(relative_paths, vec![ + std::path::PathBuf::from("a.txt"), + std::path::PathBuf::from("nested/b.txt"), + ]); +} + +#[test] +fn dir_walk_candidate_source_gives_hard_links_the_same_file_reference() { + let dir = tempfile::tempdir().expect("create temp dir"); + let original = dir.path().join("original.txt"); + let linked = dir.path().join("linked.txt"); + fs::write(&original, b"shared content").expect("write original"); + fs::hard_link(&original, &linked).expect("create hard link"); + + let entries = DirWalkCandidateSource + .enumerate(dir.path()) + .expect("enumerate must succeed"); + assert_eq!(entries.len(), 2); + + let mut file_references: Vec = entries.iter().map(|entry| entry.file_reference).collect(); + file_references.sort_unstable(); + let [first, second] = file_references.as_slice() else { + panic!("expected exactly two entries"); + }; + assert_eq!( + first, second, + "two directory entries for the same inode must share file_reference" + ); +} + +#[test] +fn fs_content_source_reads_bounded_ranges_and_reports_eof() { + let dir = tempfile::tempdir().expect("create temp dir"); + let path = dir.path().join("data.bin"); + fs::write(&path, b"0123456789").expect("write data.bin"); + + let entries = DirWalkCandidateSource + .enumerate(dir.path()) + .expect("enumerate must succeed"); + let entry = entries.first().expect("one entry expected"); + + let first_half = FsContentSource + .read_at(entry, 0, 5) + .expect("read first half"); + assert_eq!(first_half, b"01234"); + + let second_half = FsContentSource + .read_at(entry, 5, 5) + .expect("read second half"); + assert_eq!(second_half, b"56789"); + + let past_eof = FsContentSource + .read_at(entry, 10, 5) + .expect("read past EOF must not error"); + assert!(past_eof.is_empty(), "read at EOF must return no bytes"); +} + +#[test] +fn build_manifest_round_trips_through_the_wire_codec() { + let dir = tempfile::tempdir().expect("create temp dir"); + fs::write(dir.path().join("one.txt"), b"one").expect("write one.txt"); + fs::write(dir.path().join("two.txt"), b"two!!").expect("write two.txt"); + let entries = DirWalkCandidateSource + .enumerate(dir.path()) + .expect("enumerate must succeed"); + + let built = build_manifest([1_u8; 16], [2_u8; 16], [3_u8; 32], &entries) + .expect("build_manifest must succeed"); + assert_eq!(built.candidate_ids.len(), entries.len()); + + let mut reader = Reader::new(&built.bytes); + let header = ManifestHeader::decode(&mut reader).expect("decode header"); + assert_eq!(header.candidate_count, entries.len() as u64); + + let mut decoded_records = Vec::new(); + for _ in 0..header.candidate_count { + decoded_records.push(CandidateRecord::decode(&mut reader).expect("decode record")); + } + let trailer = ManifestTrailer::decode(&mut reader).expect("decode trailer"); + assert_eq!(reader.remaining(), 0, "trailer must be the last thing"); + assert_eq!(trailer.manifest_digest, built.manifest_digest); + + let mut decoded_ids: Vec = decoded_records + .iter() + .map(|record| record.candidate_id) + .collect(); + decoded_ids.sort_unstable(); + let mut expected_ids = built.candidate_ids.clone(); + expected_ids.sort_unstable(); + assert_eq!(decoded_ids, expected_ids); +} + +#[test] +fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { + let source_dir = tempfile::tempdir().expect("create source temp dir"); + fs::write(source_dir.path().join("hello.txt"), b"hello world").expect("write hello.txt"); + fs::create_dir_all(source_dir.path().join("sub")).expect("create sub dir"); + fs::write(source_dir.path().join("sub/empty.txt"), b"").expect("write empty.txt"); + + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let request = JobRequest { + source_id: "test-source".to_owned(), + root: source_dir.path().to_path_buf(), + }; + + let outcome = run_job( + &request, + &DirWalkCandidateSource, + &FsContentSource, + run_dir.path(), + ) + .expect("run_job must succeed"); + + assert_eq!(outcome.run_summary.candidate_count, 2); + assert_eq!(outcome.run_summary.succeeded_count, 2); + assert_eq!(outcome.run_summary.failed_retryable_count, 0); + assert_eq!(outcome.run_summary.failed_terminal_count, 0); + assert_eq!(outcome.run_summary.deferred_manual_count, 0); + assert_eq!(outcome.run_summary.logical_bytes_succeeded, 11); + + // Decode every emitted frame and assert the expected type sequence: + // JOB_BEGIN, then (FILE_BEGIN, [CONTENT_CHUNK]*, FILE_END) per + // candidate, then JOB_END. + let mut decoded_types = Vec::new(); + for frame_bytes in &outcome.frames { + let mut reader = Reader::new(frame_bytes); + let (envelope, _payload) = + FrameEnvelope::decode(&mut reader, u64::MAX).expect("decode frame envelope"); + assert_eq!(envelope.job_id, outcome.job_id); + decoded_types.push(envelope.frame_type); + } + + assert_eq!(decoded_types.first(), Some(&FrameType::JobBegin)); + assert_eq!(decoded_types.last(), Some(&FrameType::JobEnd)); + let file_end_count = decoded_types + .iter() + .filter(|frame_type| **frame_type == FrameType::FileEnd) + .count(); + assert_eq!(file_end_count, 2, "both candidates must reach FILE_END"); +} diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs new file mode 100644 index 000000000..2961461e1 --- /dev/null +++ b/crates/uffs-content/src/job/workflow.rs @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Drives one job end to end: enumerate candidates, finalize the +//! manifest, stream framed content, and finalize the run summary. +//! +//! This is the real Coordinator workflow — only the +//! [`CandidateSource`]/[`ContentSource`] it's given are swappable; see +//! those traits' docs for what "swappable" means today (a real vs. fake +//! backing). + +use std::io; +use std::path::Path; + +use uffs_content_protocol::codec::{Digest, digest}; +use uffs_content_protocol::error::ErrorCode; +use uffs_content_protocol::frame::{ + ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, + FileEnd, FileFailed, FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, + ReadMode, RetryClass, +}; +use uffs_content_protocol::manifest::AuthorizationMode; +use uffs_content_protocol::path_encoding::WindowsPath; + +use super::candidate_source::{CandidateEntry, CandidateSource}; +use super::content_source::ContentSource; +use super::intake::JobRequest; +use super::manifest_builder::build_manifest; +use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; + +/// One `CONTENT_CHUNK`'s maximum payload size for a job run. +/// +/// Deliberately small so even modest fixture files exercise multiple +/// chunks — production tuning of this value is a UFI.2 scheduler +/// concern, not something this workflow needs to get "right" yet. +pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 64 * 1024; + +/// Everything one completed job produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobOutcome { + /// Job identifier assigned to this run. + pub job_id: [u8; 16], + /// The finalized manifest's encoded bytes. + pub manifest_bytes: Vec, + /// Every frame this job emitted, in emission order, each already + /// wrapped in its `FrameEnvelope` — exactly the bytes a consumer + /// would receive over the wire. + pub frames: Vec>, + /// The finalized run summary. + pub run_summary: RunSummary, +} + +/// Run one job: enumerate `request.root` via `candidate_source`, finalize +/// a manifest, stream every candidate's content via `content_source`, and +/// finalize the run's summary/failure log under `run_dir`. +/// +/// # Errors +/// Returns an [`io::Error`] for any filesystem failure enumerating +/// candidates, writing the failure log, or finalizing the summary. A +/// per-candidate content-read failure is *not* an error return — it's +/// recorded as a `FAILED_RETRYABLE` outcome for that candidate instead +/// (a [`FileFailed`] frame plus a [`FailureRecord`]). +pub fn run_job( + request: &JobRequest, + candidate_source: &dyn CandidateSource, + content_source: &dyn ContentSource, + run_dir: &Path, +) -> io::Result { + let job_id = *uuid::Uuid::new_v4().as_bytes(); + let source_id = source_id_bytes(&request.source_id); + // No query filtering is wired up yet (see `JobRequest` docs) — every + // job is equivalent to a `"*"` query, so its digest is fixed. + let query_digest = digest(b"*"); + + let entries = candidate_source.enumerate(&request.root)?; + let candidate_count = len_as_u64(entries.len()); + + let built = build_manifest(job_id, source_id, query_digest, &entries) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + + let mut frames = Vec::new(); + let mut frame_sequence: u64 = 0; + let mut push_frame = |frame_type: FrameType, payload: &[u8]| { + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id, + frame_sequence, + }; + frame_sequence += 1; + frames.push(envelope.encode(payload)); + }; + + let job_begin = JobBegin { + job_id, + source_id, + snapshot_id: Vec::new(), + snapshot_created_at: 0, + manifest_digest: built.manifest_digest, + candidate_count, + authorization_mode: AuthorizationMode::AdminExport, + ordering: FrameOrdering::None, + content_semantics: ContentSemantics::UnnamedLogicalStream, + digest_algorithm: DigestAlgorithm::Blake3, + max_chunk_bytes: DEFAULT_MAX_CHUNK_BYTES, + max_content_delivery_bytes: None, + }; + push_frame(FrameType::JobBegin, &job_begin.encode()); + + let mut counters = RunCounters::new(candidate_count); + let run_id = uuid::Uuid::from_bytes(job_id).to_string(); + let failures_path = run_dir.join(format!("run-{run_id}.failures.jsonl")); + let mut failure_log = FailureLogWriter::open(&failures_path)?; + + for (entry, &candidate_id) in entries.iter().zip(&built.candidate_ids) { + let candidate_frames = stream_one_candidate( + entry, + candidate_id, + content_source, + DEFAULT_MAX_CHUNK_BYTES, + &mut counters, + &mut failure_log, + )?; + for (frame_type, payload) in &candidate_frames { + push_frame(*frame_type, payload); + } + } + drop(failure_log); + + let job_status = if counters.failed_retryable_count == 0 + && counters.failed_terminal_count == 0 + && counters.deferred_manual_count == 0 + { + JobStatus::Completed + } else { + JobStatus::CompletedWithFailures + }; + + let failure_log_bytes = std::fs::read(&failures_path).unwrap_or_default(); + let failure_bucket_id = failures_path + .file_name() + .map(|name| name.to_string_lossy().into_owned().into_bytes()) + .unwrap_or_default(); + let job_end = JobEnd { + candidate_count, + succeeded_count: counters.succeeded_count, + failed_retryable_count: counters.failed_retryable_count, + failed_terminal_count: counters.failed_terminal_count, + deferred_manual_count: counters.deferred_manual_count, + // No FILE_ACK loop is modeled by this fake-reader harness yet + // (UFI.2 scheduler work) — every success is treated as + // immediately acknowledged. + acknowledged_success_count: counters.succeeded_count, + logical_bytes_succeeded: counters.logical_bytes_succeeded, + failure_bucket_id, + manifest_digest: built.manifest_digest, + outcome_ledger_digest: digest(&failure_log_bytes), + job_status, + }; + let job_end_bytes = job_end + .encode() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + push_frame(FrameType::JobEnd, &job_end_bytes); + + let now_ms = unix_ms_now(); + let summary_path = run_dir.join(format!("run-{run_id}.summary.json")); + let run_summary = counters + .finalize(run_id, now_ms, now_ms) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + run_summary.finalize_to_disk(&summary_path)?; + + Ok(JobOutcome { + job_id, + manifest_bytes: built.bytes, + frames, + run_summary, + }) +} + +/// Streams one candidate's content and returns the `(FrameType, payload)` +/// pairs it produced (`FILE_BEGIN`, zero or more `CONTENT_CHUNK`s, then +/// exactly one of `FILE_END`/`FILE_FAILED`), updating `counters` and +/// appending to `failure_log` for a non-success outcome. +fn stream_one_candidate( + entry: &CandidateEntry, + candidate_id: u64, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, + counters: &mut RunCounters, + failure_log: &mut FailureLogWriter, +) -> io::Result)>> { + let mut out = Vec::new(); + let path = WindowsPath::from_str_lossless(&entry.relative_path.to_string_lossy()); + + let file_begin = FileBegin { + candidate_id, + file_reference: entry.file_reference, + path, + logical_size: entry.logical_size, + mtime: entry.mtime_unix_ms, + read_mode: ReadMode::LogicalSnapshot, + attempt_number: 1, + content_object_id: None, + }; + out.push((FrameType::FileBegin, file_begin.encode())); + + let mut buffered = Vec::with_capacity(usize::try_from(entry.logical_size).unwrap_or(0)); + let mut offset = 0_u64; + let mut chunk_sequence = 0_u64; + let mut read_error = None; + + while offset < entry.logical_size { + match content_source.read_at(entry, offset, max_chunk_bytes) { + Ok(bytes) if bytes.is_empty() => break, + Ok(bytes) => { + let read_len = len_as_u64(bytes.len()); + buffered.extend_from_slice(&bytes); + let chunk = ContentChunk { + candidate_id, + chunk_sequence, + logical_offset: offset, + logical_length: read_len, + payload: bytes, + }; + out.push((FrameType::ContentChunk, chunk.encode())); + offset += read_len; + chunk_sequence += 1; + } + Err(err) => { + read_error = Some(err); + break; + } + } + } + + match read_error { + None => { + let content_digest = digest(&buffered); + let file_end = FileEnd { + candidate_id, + total_logical_bytes: len_as_u64(buffered.len()), + content_digest: Some(content_digest), + read_mode: ReadMode::LogicalSnapshot, + chunk_count: chunk_sequence, + elapsed_ms: 0, + warning_flags: 0, + }; + out.push((FrameType::FileEnd, file_end.encode())); + counters.record_succeeded(len_as_u64(buffered.len())); + } + Some(err) => { + let os_error_code = err.raw_os_error().map(i64::from); + let message = err.to_string(); + let file_failed = FileFailed { + candidate_id, + outcome: FailedOutcome::Retryable, + failure_stage: FailureStage::Read, + error_code: ErrorCode::ReadIoTransient, + os_error_code, + retry_class: RetryClass::RetryNewSnapshot, + bytes_emitted_before_failure: len_as_u64(buffered.len()), + message: message.clone(), + }; + out.push((FrameType::FileFailed, file_failed.encode())); + counters.record_failed_retryable(); + failure_log.append(&FailureRecord::failed( + candidate_id, + FailedOutcome::Retryable, + FailureStage::Read, + ErrorCode::ReadIoTransient, + os_error_code, + RetryClass::RetryNewSnapshot, + len_as_u64(buffered.len()), + message, + ))?; + } + } + + Ok(out) +} + +/// Deterministically derives a manifest `source_id` from an arbitrary +/// caller-supplied string, truncating a BLAKE3 digest to 16 bytes (this +/// avoids requiring the `uuid` crate's `v5` feature workspace-wide for +/// what both docs and every existing user only ever treat as an opaque +/// 16-byte identifier). +fn source_id_bytes(source_id: &str) -> [u8; 16] { + let full: Digest = digest(source_id.as_bytes()); + let mut out = [0_u8; 16]; + if let Some(prefix) = full.get(..16) { + out.copy_from_slice(prefix); + } + out +} + +/// Converts a byte length to `u64`, saturating instead of panicking (this +/// crate never handles files anywhere near `u64::MAX` bytes long, so +/// saturation is unobservable in practice and keeps every call site +/// infallible). +fn len_as_u64(len: usize) -> u64 { + u64::try_from(len).unwrap_or(u64::MAX) +} + +/// Current wall-clock time, Unix milliseconds, saturating to `0` if the +/// clock is somehow set before the epoch. +fn unix_ms_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 8e810f152..56c93a358 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -17,22 +17,32 @@ //! //! # Status //! -//! Job intake, VSS, MFT, and streaming logic are not implemented yet. -//! [`run`] (the ephemeral per-run manifest/failure-log/summary model) is -//! real. +//! [`run`] (the ephemeral per-run manifest/failure-log/summary model) and +//! [`job`] (job intake, candidate enumeration, manifest construction, and +//! protocol framing) are real — but [`job`]'s [`job::candidate_source`] +//! and [`job::content_source`] backends are currently the cross-platform +//! `std::fs`-based stand-ins described in +//! `uffs-ingest-implementation-plan.md` §9.5, not the real VSS-snapshot +//! and privileged-Reader-backed ones (UFI.1/UFI.2). [`is_implemented`] +//! tracks the latter, not this crate's own workflow logic. +pub mod job; pub mod run; -// Not yet wired into this library's logic — reserved for the manifest / -// frame types this crate will produce and consume once job intake lands. // `uffs_version::handle_version!` is invoked from `main.rs` only. -use uffs_content_protocol as _; +// Dev-dependency used by `tests/support/plain_walk.rs` (the independent +// oracle for the E2E dir-walk parity harness), not by this crate's own +// unit tests. +#[cfg(test)] +use blake3 as _; use uffs_version as _; -/// Placeholder for the not-yet-implemented job entry point. +/// Whether the production, VSS-snapshot-backed pipeline is wired up. /// -/// Returns `false` until job intake (job-spec parsing, VSS snapshot -/// creation, candidate evaluation, and streaming) is implemented. +/// Returns `false` until [`job::candidate_source`] and +/// [`job::content_source`] have real Broker/Reader-backed implementations +/// (UFI.1/UFI.2) — the workflow itself ([`job::workflow::run_job`]) is +/// already real, just not yet running against NTFS. #[must_use] pub const fn is_implemented() -> bool { false diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 12117744f..3b68966c4 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -6,12 +6,15 @@ //! //! # Status //! -//! Scaffold only. Job intake (structured JSON job spec), VSS snapshot -//! orchestration, candidate evaluation, and framed content streaming are -//! not yet implemented. See Docenta's `uffs-ingest-protocol-v2-vss.md` for -//! the target contract (the authoritative spec this tool is built -//! against) and `docs/dev/architecture/` (local-only) for the surrounding -//! design review. +//! Job intake, manifest construction, and protocol framing are +//! implemented (`uffs_content::job`), but only against the cross-platform +//! `std::fs`-based candidate/content sources, not real VSS snapshots yet. +//! This bin is still a thin `--version`-only entry point — it does not +//! yet parse a job spec off the command line and dispatch it. See +//! Docenta's `uffs-ingest-protocol-v2-vss.md` for the target contract +//! (the authoritative spec this tool is built against) and +//! `docs/dev/architecture/` (local-only) for the surrounding design +//! review. //! //! # Usage (planned) //! @@ -20,8 +23,10 @@ //! ``` // Reserved for the wire types the bin will emit once job intake is wired -// up; not yet used from this thin entry point. -// Dev-dependency used by `uffs_content::run`'s tests, not by this bin. +// up as a real CLI entry point; not yet used from this thin bin. +// Dev-dependencies used by `uffs_content`'s tests, not by this bin. +#[cfg(test)] +use blake3 as _; // Used by `uffs_content::run` (failure log + summary serialization), not // by this thin entry point directly. use serde as _; @@ -29,6 +34,9 @@ use serde_json as _; #[cfg(test)] use tempfile as _; use uffs_content_protocol as _; +// Used by `uffs_content::job::workflow`, not by this thin entry point +// directly. +use uuid as _; #[expect( clippy::print_stderr, diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs new file mode 100644 index 000000000..e72c846bd --- /dev/null +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Fast, cross-platform end-to-end validation: does `uffs-content`'s +//! fake (`std::fs`-backed) pipeline produce exactly the same file set and +//! content as a plain, independent directory walk? +//! +//! This is `uffs-ingest-implementation-plan.md` §9.5's "fast" harness — +//! it substitutes `std::fs`-backed candidate/content sources +//! (`uffs_content::job::candidate_source::DirWalkCandidateSource`, +//! `uffs_content::job::content_source::FsContentSource`) for the real +//! VSS-snapshot/privileged-Reader machinery (not yet built — UFI.1/UFI.2), +//! so it runs everywhere, on every PR, with no elevation and no Windows +//! dependency. It still exercises the real Coordinator: candidate +//! enumeration, manifest construction and wire encoding, protocol +//! framing, and the ephemeral run-state bookkeeping — only the "how do +//! we get the candidate list and bytes" step is faked. +//! +//! The real-VSS variant (§9.4, Windows-only, `#[ignore]`) is not built +//! yet — it needs `uffs-content-reader` and a real Broker Snapshot +//! Manager, neither of which exist yet. + +// `#[cfg(test)]` so clippy's test-code relaxations (`allow-expect-in-tests` +// et al. in `clippy.toml`) apply inside `support/`'s helper files too — +// those relaxations key off the enclosing item chain carrying +// `#[cfg(test)]`, which a plain `mod support;` here would not provide. +#[cfg(test)] +mod support; + +// This crate's own dependencies (shared across the lib, bin, and every +// integration test binary), not used directly from this particular test. +use serde as _; +use serde_json as _; +use uffs_version as _; +use uuid as _; + +#[cfg(test)] +mod tests { + use uffs_content::job::candidate_source::DirWalkCandidateSource; + use uffs_content::job::content_source::FsContentSource; + use uffs_content::job::intake::JobRequest; + use uffs_content::job::workflow::{JobOutcome, run_job}; + + use crate::support; + use crate::support::fixture_tree::FixtureFile; + use crate::support::plain_walk::PlainWalkEntry; + use crate::support::test_consumer::ConsumedJob; + + #[test] + fn ingest_output_matches_plain_directory_walk() { + let source_dir = tempfile::tempdir().expect("create source temp dir"); + let fixture_files = support::fixture_tree::build(source_dir.path()); + assert!( + fixture_files + .iter() + .any(|file| file.relative_path.to_string_lossy().contains("hardlink")), + "fixture must include a hard-linked file" + ); + + // 1. Independent oracle — shares no code with the pipeline under test. + let mut expected = support::plain_walk::plain_walk(source_dir.path()); + expected.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + assert_fixture_matches_oracle(&fixture_files, &expected); + + // 2. Run the real pipeline against the fake (std::fs) sources. + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let request = JobRequest { + source_id: "fixture-source".to_owned(), + root: source_dir.path().to_path_buf(), + }; + let outcome = run_job( + &request, + &DirWalkCandidateSource, + &FsContentSource, + run_dir.path(), + ) + .expect("run_job must succeed"); + + // 3. Structural assertions (design-doc §21.7) before content is even compared. + assert_structural_invariants(&outcome, expected.len()); + + // 4. Decode the actual wire bytes as a real consumer would — this is what + // catches a framing bug a structure-passthrough shortcut would miss + // entirely. + let consumed = support::test_consumer::consume(&outcome.manifest_bytes, &outcome.frames); + assert_eq!( + consumed.candidate_count, outcome.run_summary.candidate_count, + "manifest header's candidate_count must match the run summary's" + ); + assert!(consumed.failed_retryable.is_empty()); + assert!(consumed.failed_terminal.is_empty()); + assert!(consumed.deferred_manual.is_empty()); + + assert_content_matches_oracle(&consumed, &expected); + + // 5. Every FILE_END digest must equal recomputing BLAKE3 over the actual + // emitted bytes the consumer buffered — not just the producer's + // self-reported digest — catching a "digest computed over the wrong bytes" + // bug that a self-reported-digest-only check would miss entirely. + assert_digests_recompute(&consumed); + } + + /// Sanity-checks the fixture generator itself before trusting it as + /// the oracle's input: every file it says it wrote must appear in + /// the oracle's own independent walk with matching size/content. + fn assert_fixture_matches_oracle(fixture_files: &[FixtureFile], expected: &[PlainWalkEntry]) { + for file in fixture_files { + let found = expected + .iter() + .find(|entry| entry.relative_path == file.relative_path) + .unwrap_or_else(|| { + panic!( + "fixture file {:?} must appear in the oracle walk", + file.relative_path + ) + }); + assert_eq!( + found.size, + u64::try_from(file.content.len()).unwrap_or(u64::MAX) + ); + assert_eq!( + *found.digest.as_bytes(), + *blake3::hash(&file.content).as_bytes() + ); + } + } + + /// Checks the completeness invariant (design-doc §21.7) and that + /// plain, ordinary fixture files never fail or defer. + fn assert_structural_invariants(outcome: &JobOutcome, expected_count: usize) { + assert_eq!( + outcome.run_summary.candidate_count, + outcome.run_summary.succeeded_count + + outcome.run_summary.failed_retryable_count + + outcome.run_summary.failed_terminal_count + + outcome.run_summary.deferred_manual_count + ); + assert_eq!( + outcome.run_summary.failed_retryable_count, 0, + "plain ordinary fixture files must not fail" + ); + assert_eq!( + outcome.run_summary.failed_terminal_count, 0, + "plain ordinary fixture files must not fail" + ); + assert_eq!( + outcome.run_summary.deferred_manual_count, 0, + "plain ordinary fixture files must not defer" + ); + assert_eq!( + outcome.run_summary.candidate_count, + u64::try_from(expected_count).unwrap_or(u64::MAX) + ); + } + + /// The actual "matches a plain dir walk" check: every succeeded + /// candidate's `(path, size, digest)` must exactly match the oracle. + fn assert_content_matches_oracle(consumed: &ConsumedJob, expected: &[PlainWalkEntry]) { + let mut actual: Vec<_> = consumed + .succeeded + .iter() + .map(|file| { + ( + file.relative_path.clone(), + file.total_logical_bytes, + file.reported_digest, + ) + }) + .collect(); + actual.sort(); + let expected_tuples: Vec<_> = expected + .iter() + .map(|entry| { + ( + entry.relative_path.clone(), + entry.size, + *entry.digest.as_bytes(), + ) + }) + .collect(); + assert_eq!( + actual, expected_tuples, + "ingest output must exactly match a plain directory walk" + ); + } + + /// Recomputes BLAKE3 over the consumer-buffered bytes for every + /// succeeded file, independently of the producer's self-reported + /// digest. + fn assert_digests_recompute(consumed: &ConsumedJob) { + for file in &consumed.succeeded { + let recomputed = blake3::hash(&file.buffered_content); + assert_eq!( + *recomputed.as_bytes(), + file.reported_digest, + "producer's self-reported digest must match independent recomputation for {:?}", + file.relative_path + ); + } + } +} diff --git a/crates/uffs-content/tests/support/fixture_tree.rs b/crates/uffs-content/tests/support/fixture_tree.rs new file mode 100644 index 000000000..9bd913b4f --- /dev/null +++ b/crates/uffs-content/tests/support/fixture_tree.rs @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Builds a deterministic test directory tree exercising the size +//! classes, encoding edge cases, and hard-link semantics called for by +//! `uffs-ingest-implementation-plan.md` §9.2. +//! +//! Deliberately smaller than the plan's full real-VSS test sizes (a real +//! 64 MiB+ file): this harness runs on every PR, so its "large" bucket is +//! scaled down to something that still forces multi-chunk streaming +//! without slowing every CI run. The real-VSS test (§9.4, Windows-only, +//! `#[ignore]`) is where the full production size classes belong. + +use std::path::{Path, PathBuf}; + +/// One file this harness deliberately created. +#[derive(Debug, Clone)] +pub(crate) struct FixtureFile { + /// Path relative to the fixture tree's root. + pub relative_path: PathBuf, + /// Exact bytes written. + pub content: Vec, +} + +/// Builds the fixture tree under `root` (must already exist), returning +/// every file it created. +pub(crate) fn build(root: &Path) -> Vec { + let mut files = vec![ + write_file(root, Path::new("zero_byte.dat"), &[]), + write_file( + root, + Path::new("resident_small.txt"), + &deterministic_content("resident_small.txt", 200), + ), + write_file( + root, + Path::new("small_16k.bin"), + &deterministic_content("small_16k.bin", 16 * 1024), + ), + write_file( + root, + Path::new("medium_256k.bin"), + &deterministic_content("medium_256k.bin", 256 * 1024), + ), + write_file( + root, + Path::new("large_2m.bin"), + &deterministic_content("large_2m.bin", 2 * 1024 * 1024), + ), + ]; + + let nested_relative: PathBuf = ["a", "b", "c", "d", "nested.txt"].iter().collect(); + files.push(write_file( + root, + &nested_relative, + &deterministic_content("nested.txt", 512), + )); + + // Non-ASCII / non-BMP name (the emoji requires a UTF-16 surrogate + // pair) — exercises the lossless Windows path encoding. + let unicode_relative = PathBuf::from("日本語_😀.txt"); + files.push(write_file( + root, + &unicode_relative, + &deterministic_content("unicode-name-seed", 128), + )); + + // Hard link: two directory entries, one underlying file. + let original_relative = PathBuf::from("hardlink_original.dat"); + let original_content = deterministic_content("hardlink_original.dat", 4096); + files.push(write_file(root, &original_relative, &original_content)); + let linked_relative = PathBuf::from("hardlink_copy.dat"); + std::fs::hard_link(root.join(&original_relative), root.join(&linked_relative)) + .expect("hard_link must succeed"); + files.push(FixtureFile { + relative_path: linked_relative, + content: original_content, + }); + + files +} + +/// Writes `content` at `root.join(relative)`, creating parent directories +/// as needed, and returns the corresponding [`FixtureFile`]. +fn write_file(root: &Path, relative: &Path, content: &[u8]) -> FixtureFile { + let absolute = root.join(relative); + if let Some(parent) = absolute.parent() { + std::fs::create_dir_all(parent).expect("create_dir_all must succeed"); + } + std::fs::write(&absolute, content).expect("write must succeed"); + FixtureFile { + relative_path: relative.to_path_buf(), + content: content.to_vec(), + } +} + +/// Deterministic, non-uniform content: expands a BLAKE3 hash of `seed` +/// via its extendable output, so content is reproducible across runs but +/// not trivially compressible/all-zeros — catching a digest bug that +/// only manifests on repetitive content. +fn deterministic_content(seed: &str, length: usize) -> Vec { + let mut output = vec![0_u8; length]; + let mut hasher = blake3::Hasher::new(); + hasher.update(seed.as_bytes()); + let mut xof_reader = hasher.finalize_xof(); + xof_reader.fill(&mut output); + output +} diff --git a/crates/uffs-content/tests/support/mod.rs b/crates/uffs-content/tests/support/mod.rs new file mode 100644 index 000000000..f0eb04ecc --- /dev/null +++ b/crates/uffs-content/tests/support/mod.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Shared support for the end-to-end dir-walk parity harness +//! (`uffs-ingest-implementation-plan.md` §9). + +pub(crate) mod fixture_tree; +pub(crate) mod plain_walk; +pub(crate) mod test_consumer; diff --git a/crates/uffs-content/tests/support/plain_walk.rs b/crates/uffs-content/tests/support/plain_walk.rs new file mode 100644 index 000000000..d257a76be --- /dev/null +++ b/crates/uffs-content/tests/support/plain_walk.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Independent reference directory walk — the end-to-end parity test's +//! oracle. +//! +//! Deliberately does not call into `uffs-content`, `uffs-content-protocol`, +//! `uffs-core`, or `uffs-mft`: the entire point is an implementation that +//! shares no code with the pipeline under test, so a shared bug can't +//! hide from the comparison. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// One file as seen by a plain recursive directory walk. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlainWalkEntry { + /// Path relative to the walked root. + pub relative_path: PathBuf, + /// File size in bytes. + pub size: u64, + /// BLAKE3 digest of the file's content, computed directly (not + /// through `uffs-content-protocol::codec::digest`). + pub digest: blake3::Hash, +} + +/// Recursively walks `root`, hashing every regular file's content with +/// `blake3` directly. +#[must_use] +pub(crate) fn plain_walk(root: &Path) -> Vec { + let mut out = Vec::new(); + walk(root, root, &mut out); + out +} + +fn walk(root: &Path, dir: &Path, out: &mut Vec) { + let mut entries: Vec = fs::read_dir(dir) + .expect("read_dir must succeed") + .collect::>() + .expect("collecting read_dir entries must succeed"); + entries.sort_by_key(fs::DirEntry::path); + + for entry in entries { + let path = entry.path(); + let metadata = entry.metadata().expect("metadata must succeed"); + if metadata.is_dir() { + walk(root, &path, out); + } else if metadata.is_file() { + let content = fs::read(&path).expect("read must succeed"); + let relative_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + out.push(PlainWalkEntry { + relative_path, + size: metadata.len(), + digest: blake3::hash(&content), + }); + } + } +} diff --git a/crates/uffs-content/tests/support/test_consumer.rs b/crates/uffs-content/tests/support/test_consumer.rs new file mode 100644 index 000000000..dd3c64182 --- /dev/null +++ b/crates/uffs-content/tests/support/test_consumer.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Minimal stand-in for a downstream consumer (e.g. Docenta): decodes the +//! public wire protocol — the manifest and every frame — using only +//! `uffs-content-protocol`'s decoders, exactly as a real consumer would. +//! This is what lets the parity test catch a framing bug that a +//! structure-passthrough shortcut would miss (design-doc §21's intent). + +use std::collections::HashMap; +use std::path::PathBuf; + +use uffs_content_protocol::codec::{Digest, Reader}; +use uffs_content_protocol::frame::{ + ContentChunk, FailedOutcome, FileBegin, FileDeferred, FileEnd, FileFailed, FrameEnvelope, + FrameType, +}; +use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader, ManifestTrailer}; + +/// One candidate's fully consumed success outcome. +#[derive(Debug, Clone)] +pub(crate) struct ConsumedSuccess { + /// Path from the manifest's candidate record. + pub relative_path: PathBuf, + /// `FILE_END.total_logical_bytes`. + pub total_logical_bytes: u64, + /// The producer's self-reported `FILE_END.content_digest`. + pub reported_digest: Digest, + /// Every byte this consumer actually received via `CONTENT_CHUNK` + /// frames for this candidate, in the order received. + pub buffered_content: Vec, +} + +/// Everything decoded from one job's manifest + frame stream. +#[derive(Debug, Clone, Default)] +pub(crate) struct ConsumedJob { + /// `ManifestHeader::candidate_count`. + pub candidate_count: u64, + /// Candidates that reached `FILE_END`. + pub succeeded: Vec, + /// Candidate IDs that reached `FILE_FAILED` with a retryable outcome. + pub failed_retryable: Vec, + /// Candidate IDs that reached `FILE_FAILED` with a terminal outcome. + pub failed_terminal: Vec, + /// Candidate IDs that reached `FILE_DEFERRED`. + pub deferred_manual: Vec, +} + +/// Decode `manifest_bytes` + `frames` (each already a complete, +/// envelope-wrapped frame, as emitted by +/// `uffs_content::job::workflow::run_job`) into a [`ConsumedJob`]. +#[must_use] +pub(crate) fn consume(manifest_bytes: &[u8], frames: &[Vec]) -> ConsumedJob { + let mut manifest_reader = Reader::new(manifest_bytes); + let header = ManifestHeader::decode(&mut manifest_reader).expect("decode manifest header"); + + let mut paths_by_candidate_id: HashMap = HashMap::new(); + for _ in 0..header.candidate_count { + let record = + CandidateRecord::decode(&mut manifest_reader).expect("decode candidate record"); + paths_by_candidate_id.insert( + record.candidate_id, + PathBuf::from(record.path.display_lossy()), + ); + } + ManifestTrailer::decode(&mut manifest_reader).expect("decode manifest trailer"); + + let mut job = ConsumedJob { + candidate_count: header.candidate_count, + ..ConsumedJob::default() + }; + let mut buffers: HashMap> = HashMap::new(); + + for frame_bytes in frames { + let mut frame_reader = Reader::new(frame_bytes); + let (envelope, payload) = + FrameEnvelope::decode(&mut frame_reader, u64::MAX).expect("decode frame envelope"); + let mut payload_reader = Reader::new(&payload); + apply_frame( + envelope.frame_type, + &mut payload_reader, + &paths_by_candidate_id, + &mut buffers, + &mut job, + ); + } + + job +} + +/// Applies one decoded frame to the in-progress [`ConsumedJob`]/buffer +/// state. Split out of [`consume`] purely to keep that function's body +/// short — not a reusable abstraction on its own. +fn apply_frame( + frame_type: FrameType, + payload_reader: &mut Reader<'_>, + paths_by_candidate_id: &HashMap, + buffers: &mut HashMap>, + job: &mut ConsumedJob, +) { + match frame_type { + FrameType::FileBegin => { + let file_begin = FileBegin::decode(payload_reader).expect("decode FILE_BEGIN"); + buffers.insert(file_begin.candidate_id, Vec::new()); + } + FrameType::ContentChunk => { + let chunk = + ContentChunk::decode(payload_reader, u32::MAX).expect("decode CONTENT_CHUNK"); + buffers + .entry(chunk.candidate_id) + .or_default() + .extend_from_slice(&chunk.payload); + } + FrameType::FileEnd => { + let file_end = FileEnd::decode(payload_reader).expect("decode FILE_END"); + let buffered_content = buffers.remove(&file_end.candidate_id).unwrap_or_default(); + let relative_path = paths_by_candidate_id + .get(&file_end.candidate_id) + .cloned() + .unwrap_or_default(); + let reported_digest = file_end + .content_digest + .expect("a succeeded file must report a digest in this harness (no delivery ceiling is set)"); + job.succeeded.push(ConsumedSuccess { + relative_path, + total_logical_bytes: file_end.total_logical_bytes, + reported_digest, + buffered_content, + }); + } + FrameType::FileFailed => { + let file_failed = FileFailed::decode(payload_reader).expect("decode FILE_FAILED"); + match file_failed.outcome { + FailedOutcome::Retryable => job.failed_retryable.push(file_failed.candidate_id), + FailedOutcome::Terminal => job.failed_terminal.push(file_failed.candidate_id), + } + } + FrameType::FileDeferred => { + let file_deferred = FileDeferred::decode(payload_reader).expect("decode FILE_DEFERRED"); + job.deferred_manual.push(file_deferred.candidate_id); + } + FrameType::JobBegin + | FrameType::FileAck + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobEnd + | FrameType::JobCancel + | FrameType::WindowUpdate => {} + } +} From 22bcb6b1510c99202db24adf7f008573ea86add2 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:56:59 -0700 Subject: [PATCH 14/98] feat(broker): UFI.1 snapshot lease lifecycle manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SnapshotLeaseManager, the Broker's in-memory VSS-lease bookkeeping behind a swappable VssProvider trait, per the implementation plan §4.2. Deliberately cross-platform (unlike the rest of this Windows-only crate) so the lease lifecycle — create/renew/release/expire/reconcile — is unit-tested against a fake provider on every host, not just Windows. Lease state is purely in-memory: there's no durable table surviving a Broker restart, so reconcile_at_startup can unconditionally delete every VSS snapshot the real backend still reports at startup — a freshly constructed manager holds no lease for anything, so all of them are definitionally orphaned. Adds the Win32_Storage_Vss windows-rs feature for the real VSS requestor COM implementation landing next. --- Cargo.lock | 1 + Cargo.toml | 3 + crates/uffs-broker/Cargo.toml | 33 +- crates/uffs-broker/src/main.rs | 6 + crates/uffs-broker/src/snapshot_lease.rs | 418 ++++++++++++++++++ .../uffs-broker/src/snapshot_lease/tests.rs | 250 +++++++++++ 6 files changed, 698 insertions(+), 13 deletions(-) create mode 100644 crates/uffs-broker/src/snapshot_lease.rs create mode 100644 crates/uffs-broker/src/snapshot_lease/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0eabe9f86..c202cd088 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4399,6 +4399,7 @@ name = "uffs-broker" version = "0.6.27" dependencies = [ "anyhow", + "thiserror 2.0.18", "tracing", "tracing-subscriber", "uffs-broker-protocol", diff --git a/Cargo.toml b/Cargo.toml index c88abd424..8c9184008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -219,6 +219,9 @@ windows = { version = "0.62.2", features = [ "Win32_Security_WinTrust", "Win32_Storage", "Win32_Storage_FileSystem", + # VSS requestor COM interfaces (`IVssBackupComponents`, `IVssAsync`) for + # `uffs-broker`'s Snapshot Manager (`broker/snapshot_manager/vss.rs`). + "Win32_Storage_Vss", "Win32_System_Com", "Win32_System_Console", "Win32_System_IO", diff --git a/crates/uffs-broker/Cargo.toml b/crates/uffs-broker/Cargo.toml index 69f5e359d..c7f2454e2 100644 --- a/crates/uffs-broker/Cargo.toml +++ b/crates/uffs-broker/Cargo.toml @@ -44,17 +44,29 @@ path = "src/main.rs" # Cross-platform deps: `main.rs` calls `uffs_version::handle_version!` on every # platform (so `uffs-broker --version` works and the self-update probe parses # it), so this is used on Linux/macOS too — not a visible-but-unused marker. +# +# `uffs-broker-protocol` and `thiserror` are also unconditional: the Snapshot +# Manager's lease-lifecycle state machine (`src/snapshot_lease.rs`) is +# deliberately cross-platform-testable — it's the one piece of this +# otherwise Windows-only crate the implementation plan (§4.2) calls out for +# real unit tests against a fake `VssProvider`, so its wire types +# (`VolumeIdentity`, `SnapshotLeaseState`) and error type need to compile on +# every host, not just Windows. [dependencies] uffs-version.workspace = true +uffs-broker-protocol.workspace = true +thiserror.workspace = true -# Windows-only deps: the elevated handle-broker logic in `broker.rs` is -# fully `#[cfg(windows)]`, so its supporting crates compile only on the -# Windows target. Scoped to `[target.'cfg(windows)'.dependencies]` so -# the bin's non-Windows compilation (a tiny error-message stub in -# `main.rs` that uses only `eprintln!`) doesn't pull in `anyhow`, -# `tracing`, `tracing-subscriber`, `windows`, or the broker protocol — -# each would otherwise be visible-but-unused on Linux/macOS and need a -# `use X as _;` marker. F5 / issue #205. +# Windows-only deps: the elevated handle-broker logic in `broker.rs` (and +# the Snapshot Manager's COM/pipe/reconciliation code in +# `broker/snapshot_manager/`) is fully `#[cfg(windows)]`, so these +# supporting crates compile only on the Windows target. Scoped to +# `[target.'cfg(windows)'.dependencies]` so the bin's non-Windows +# compilation (a tiny error-message stub in `main.rs` that uses only +# `eprintln!`, plus the cross-platform `snapshot_lease` module) doesn't pull +# in `anyhow`, `tracing`, `tracing-subscriber`, or `windows` — each would +# otherwise be visible-but-unused on Linux/macOS and need a `use X as _;` +# marker. F5 / issue #205. [target.'cfg(windows)'.dependencies] anyhow.workspace = true # `tracing` macros are used heavily in the Windows-only `broker.rs`; the @@ -63,11 +75,6 @@ anyhow.workspace = true tracing.workspace = true tracing-subscriber.workspace = true windows.workspace = true -# Wire-protocol types shared with `uffs-daemon::broker_client`. Lives -# in its own dedicated crate (`crates/uffs-broker-protocol/`) so the -# pure-logic protocol module is cross-platform-testable without -# dragging the Windows FFI surface along. -uffs-broker-protocol.workspace = true # Shared in-process Authenticode (`WinVerifyTrust`) verification — the # single implementation now lives in `uffs-security` (DRY with the # self-updater) instead of a broker-local copy. diff --git a/crates/uffs-broker/src/main.rs b/crates/uffs-broker/src/main.rs index 6de5296c7..3c364e486 100644 --- a/crates/uffs-broker/src/main.rs +++ b/crates/uffs-broker/src/main.rs @@ -40,6 +40,12 @@ extern crate alloc; #[cfg(windows)] mod broker; +// Snapshot lease lifecycle (VSS lease create/renew/release/query/expire), +// deliberately cross-platform-testable — see the module docs for why +// this crate makes an exception to its usual Windows-only structure for +// this one piece. +mod snapshot_lease; + #[expect( clippy::print_stderr, reason = "the --install/--uninstall paths run before any tracing subscriber \ diff --git a/crates/uffs-broker/src/snapshot_lease.rs b/crates/uffs-broker/src/snapshot_lease.rs new file mode 100644 index 000000000..12d6861c1 --- /dev/null +++ b/crates/uffs-broker/src/snapshot_lease.rs @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Snapshot lease lifecycle: the Broker's in-memory VSS-lease bookkeeping, +//! decoupled from the real Win32 VSS calls behind the [`VssProvider`] +//! trait. +//! +//! Deliberately cross-platform, unlike the rest of this crate (`broker` +//! and everything under it are `#[cfg(windows)]`): the implementation +//! plan (`uffs-ingest-implementation-plan.md` §4.2) calls for the +//! lease-lifecycle state machine (create/renew/release/expire/reconcile) +//! to be unit-tested against a fake `VssProvider` on every host, so a +//! logic bug here doesn't need a Windows box to catch. Only the real +//! Win32 VSS backend (`broker/snapshot_manager/vss.rs`), the named-pipe +//! wiring (`broker/snapshot_manager/pipe.rs`), and startup reconciliation +//! (`broker/snapshot_manager/reconcile.rs`) are `#[cfg(windows)]`. +//! +//! Lease state lives entirely in memory — there is no durable lease +//! table surviving a Broker restart (matching the same +//! "restart-from-zero" philosophy `uffs-content::run` uses). That's why +//! [`SnapshotLeaseManager::reconcile_at_startup`] can be so blunt: a +//! freshly started manager holds no lease for anything, so *every* +//! shadow copy the real VSS backend still finds at startup is, by +//! definition, orphaned from a previous run and gets deleted. + +// This module's only production consumer is the Windows-only +// `broker::snapshot_manager` subsystem (the real `WindowsVssProvider` + +// pipe wiring) — on a non-Windows compilation nothing outside `tests` +// below ever constructs a `SnapshotLeaseManager`, which is expected and +// correct, not a bug to fix. See the module docs for why this crate +// makes an exception to compile this logic on every platform at all. +#![cfg_attr( + not(windows), + allow( + dead_code, + reason = "only the Windows-only broker::snapshot_manager subsystem constructs \ + a SnapshotLeaseManager outside of this module's own tests" + ) +)] + +use core::sync::atomic::{AtomicU64, Ordering}; +use std::collections::HashMap; +use std::sync::Mutex; + +use uffs_broker_protocol::snapshot_manager::{SnapshotLeaseState, VolumeIdentity}; + +/// The result of successfully creating a VSS snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SnapshotHandle { + /// Opaque VSS snapshot identifier. + pub(crate) snapshot_id: Vec, + /// Device path the snapshot is reachable at (e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`). + pub(crate) device_identity: String, +} + +/// Errors from the underlying VSS backend (real or fake). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub(crate) enum VssError { + /// The requested volume could not be validated. + #[error("volume validation failed: {0}")] + VolumeValidationFailed(String), + /// Snapshot creation failed. + #[error("snapshot creation failed: {0}")] + CreateFailed(String), + /// Snapshot deletion failed. + #[error("snapshot deletion failed: {0}")] + DeleteFailed(String), + /// Copy-on-write storage pressure prevents further retention. + #[error("copy-on-write storage exhausted: {0}")] + StorageExhausted(String), + /// Enumerating existing snapshots (for startup reconciliation) failed. + #[error("enumerating existing snapshots failed: {0}")] + EnumerationFailed(String), +} + +/// A real or fake VSS snapshot creation/deletion/enumeration backend. +/// +/// The real Windows implementation +/// (`broker/snapshot_manager::vss::WindowsVssProvider`) wraps +/// `IVssBackupComponents`. Tests in this module use a fake implementation +/// so the lease-lifecycle logic below is exercised without ever calling +/// real VSS. +pub(crate) trait VssProvider: Send + Sync { + /// Create a new point-in-time snapshot of `volume`. + /// + /// `requested_root` is the lossless UTF-16LE-encoded path the job + /// wants to read under — most providers don't need it (a VSS + /// snapshot covers the whole volume), but it's threaded through so a + /// provider can validate the root actually resolves on `volume`. + /// + /// # Errors + /// See [`VssError`]. + fn create_snapshot( + &self, + volume: &VolumeIdentity, + requested_root: &[u8], + ) -> Result; + + /// Delete a previously created snapshot. + /// + /// # Errors + /// See [`VssError`]. + fn delete_snapshot(&self, snapshot_id: &[u8]) -> Result<(), VssError>; + + /// List every UFFS-owned snapshot the VSS backend currently knows + /// about, regardless of whether this process instance created it. + /// + /// Used only by [`SnapshotLeaseManager::reconcile_at_startup`]. Must + /// **not** include snapshots created by other software (System + /// Restore, third-party backup tools, etc.) — the real + /// implementation is responsible for that filtering (e.g. via a + /// persisted UFFS-owned backup-components document), not the caller. + /// + /// # Errors + /// See [`VssError`]. + fn list_existing_snapshots(&self) -> Result>, VssError>; +} + +/// Errors from a [`SnapshotLeaseManager`] operation. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum LeaseError { + /// The named lease is not known to the Broker. + #[error("lease not found")] + NotFound, + /// The named lease has already expired or been released. + #[error("lease is not active")] + NotActive, + /// The underlying VSS backend failed. + #[error(transparent)] + Vss(#[from] VssError), +} + +/// The result of successfully creating a lease. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreatedLease { + /// Lease identifier the Coordinator uses in all subsequent calls. + pub(crate) lease_id: u64, + /// Opaque VSS snapshot identifier. + pub(crate) snapshot_id: Vec, + /// Device path the snapshot is reachable at. + pub(crate) device_identity: String, + /// Snapshot creation time, Unix milliseconds. + pub(crate) created_at_unix_ms: i64, + /// Initial lease expiry, Unix milliseconds. + pub(crate) expires_at_unix_ms: i64, +} + +/// The result of a successful [`SnapshotLeaseManager::query_lease`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LeaseStatus { + /// Current lease state. + pub(crate) state: SnapshotLeaseState, + /// Opaque VSS snapshot identifier. + pub(crate) snapshot_id: Vec, + /// Creation time, Unix milliseconds. + pub(crate) created_at_unix_ms: i64, + /// Expiry time, Unix milliseconds. + pub(crate) expires_at_unix_ms: i64, +} + +/// Why a lease reached a terminal state — distinct from +/// [`SnapshotLeaseState::Active`] so [`SnapshotLeaseManager::query_lease`] +/// can report *why* a lease is no longer active, matching the wire +/// protocol's `Expired`/`Released` distinction (a timeout is not the same +/// outcome as an explicit release, even though both mean the underlying +/// snapshot is gone). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecordState { + /// Snapshot is retained; the lease has not reached its expiry. + Active, + /// The lease's expiry passed before it was renewed or released; the + /// snapshot has been auto-deleted. + Expired, + /// [`SnapshotLeaseManager::release_lease`] was called explicitly. + Released, +} + +impl RecordState { + /// Map to the wire [`SnapshotLeaseState`] (a 1:1 correspondence for + /// every state a live record can be in — `Unknown` only applies when + /// there's no record at all, which [`RecordState`] can't represent). + const fn to_wire(self) -> SnapshotLeaseState { + match self { + Self::Active => SnapshotLeaseState::Active, + Self::Expired => SnapshotLeaseState::Expired, + Self::Released => SnapshotLeaseState::Released, + } + } +} + +/// One tracked lease. +struct LeaseRecord { + /// Opaque VSS snapshot identifier. + snapshot_id: Vec, + /// Device path the snapshot is reachable at. + device_identity: String, + /// Snapshot creation time, Unix milliseconds. + created_at_unix_ms: i64, + /// Current expiry, Unix milliseconds (meaningless once `state` is no + /// longer `Active`, but kept for `query_lease`'s reporting). + expires_at_unix_ms: i64, + /// Current lifecycle state. + state: RecordState, +} + +/// In-memory registry of active VSS snapshot leases, backed by a +/// swappable [`VssProvider`]. +/// +/// Every public method takes `now_unix_ms` explicitly rather than reading +/// the wall clock internally, so the lease-lifecycle tests below are +/// fully deterministic — no sleeping, no flaky timing. +pub(crate) struct SnapshotLeaseManager

{ + /// The VSS backend (real or fake) this manager drives. + provider: P, + /// Monotonic lease-ID counter; starts at 1 (`0` is never issued). + next_lease_id: AtomicU64, + /// All tracked leases, keyed by `lease_id`. + leases: Mutex>, +} + +impl SnapshotLeaseManager

{ + /// Create a new, empty lease manager over `provider`. + pub(crate) fn new(provider: P) -> Self { + Self { + provider, + next_lease_id: AtomicU64::new(1), + leases: Mutex::new(HashMap::new()), + } + } + + /// Create a new snapshot and lease it for up to `maximum_lifetime_secs`. + /// + /// # Errors + /// Propagates [`VssError`] from the underlying `create_snapshot` call. + pub(crate) fn create_lease( + &self, + volume: &VolumeIdentity, + requested_root: &[u8], + maximum_lifetime_secs: u64, + now_unix_ms: i64, + ) -> Result { + self.sweep_expired(now_unix_ms); + + let handle = self.provider.create_snapshot(volume, requested_root)?; + let lease_id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); + let expires_at_unix_ms = + now_unix_ms.saturating_add(secs_to_ms_saturating(maximum_lifetime_secs)); + + let record = LeaseRecord { + snapshot_id: handle.snapshot_id.clone(), + device_identity: handle.device_identity.clone(), + created_at_unix_ms: now_unix_ms, + expires_at_unix_ms, + state: RecordState::Active, + }; + self.lock_leases().insert(lease_id, record); + + Ok(CreatedLease { + lease_id, + snapshot_id: handle.snapshot_id, + device_identity: handle.device_identity, + created_at_unix_ms: now_unix_ms, + expires_at_unix_ms, + }) + } + + /// Renew an active lease to a new absolute expiry. + /// + /// # Errors + /// [`LeaseError::NotFound`] if `lease_id` is unknown; + /// [`LeaseError::NotActive`] if it has already expired or been + /// released. + pub(crate) fn renew_lease( + &self, + lease_id: u64, + requested_expiry_unix_ms: i64, + now_unix_ms: i64, + ) -> Result { + self.sweep_expired(now_unix_ms); + let mut leases = self.lock_leases(); + let record = leases.get_mut(&lease_id).ok_or(LeaseError::NotFound)?; + if record.state != RecordState::Active { + return Err(LeaseError::NotActive); + } + record.expires_at_unix_ms = requested_expiry_unix_ms; + drop(leases); + Ok(requested_expiry_unix_ms) + } + + /// Release a lease, deleting its snapshot if it hasn't already been + /// torn down. Releasing an already-terminal (expired or + /// already-released) lease is a no-op success, not an error — only + /// an entirely unknown `lease_id` is an error. + /// + /// # Errors + /// [`LeaseError::NotFound`] if `lease_id` is unknown; propagates + /// [`VssError`] from `delete_snapshot` if the lease was active. + pub(crate) fn release_lease(&self, lease_id: u64) -> Result<(), LeaseError> { + let mut leases = self.lock_leases(); + let record = leases.get_mut(&lease_id).ok_or(LeaseError::NotFound)?; + if record.state != RecordState::Active { + return Ok(()); + } + self.provider.delete_snapshot(&record.snapshot_id)?; + record.state = RecordState::Released; + drop(leases); + Ok(()) + } + + /// Report a lease's current state, or `None` if the Broker has no + /// record of it (the wire protocol's `Unknown` state). + pub(crate) fn query_lease(&self, lease_id: u64, now_unix_ms: i64) -> Option { + self.sweep_expired(now_unix_ms); + let leases = self.lock_leases(); + let record = leases.get(&lease_id)?; + let status = LeaseStatus { + state: record.state.to_wire(), + snapshot_id: record.snapshot_id.clone(), + created_at_unix_ms: record.created_at_unix_ms, + expires_at_unix_ms: record.expires_at_unix_ms, + }; + drop(leases); + Some(status) + } + + /// The device identity a lease's snapshot is reachable at, if the + /// lease is currently active. Used by `DuplicateSnapshotHandle` + /// handling to know which device to open before duplicating a handle + /// to the Reader. + pub(crate) fn device_identity_if_active( + &self, + lease_id: u64, + now_unix_ms: i64, + ) -> Option { + self.sweep_expired(now_unix_ms); + let leases = self.lock_leases(); + let record = leases.get(&lease_id)?; + let identity = + (record.state == RecordState::Active).then(|| record.device_identity.clone()); + drop(leases); + identity + } + + /// Auto-release every lease whose expiry has passed and that is + /// still `Active` — deleting its snapshot and transitioning it to + /// `Expired`. Called at the start of every other method (so the + /// lifecycle tests below don't need a background thread), and also + /// callable directly by a periodic background sweep in the real + /// Windows serving loop, so an idle manager doesn't leak shadow-copy + /// storage indefinitely between requests. + #[expect( + clippy::iter_over_hash_type, + reason = "order doesn't matter: every Active-and-past-expiry record \ + is swept regardless of visitation order, and the map is \ + small (bounded by concurrently open jobs)" + )] + pub(crate) fn sweep_expired(&self, now_unix_ms: i64) { + let mut leases = self.lock_leases(); + for record in leases.values_mut() { + if record.state == RecordState::Active && now_unix_ms >= record.expires_at_unix_ms { + // Best-effort: a delete failure here shouldn't wedge the + // sweep or crash the Broker. The next `reconcile_at_startup` + // (or a later successful delete attempt, if this method + // instead retried failures) would catch a truly stuck + // snapshot; for v1 this matches the addendum's "monitor, + // don't guarantee" framing for background cleanup. + let _ignored = self.provider.delete_snapshot(&record.snapshot_id); + record.state = RecordState::Expired; + } + } + } + + /// Delete every snapshot the VSS backend reports as existing. + /// + /// Safe to call unconditionally at Broker startup, before any lease + /// is created this run: since lease state is purely in-memory (see + /// the module docs), a freshly constructed manager holds no lease + /// for anything the backend might still be retaining from a previous + /// (crashed or otherwise uncleanly stopped) run — so every entry + /// [`VssProvider::list_existing_snapshots`] reports is, by + /// definition, orphaned. + /// + /// Returns the number of snapshots successfully deleted (best-effort: + /// a single failed deletion doesn't stop the rest). + /// + /// # Errors + /// Propagates [`VssError`] only if *listing* existing snapshots + /// itself fails; individual deletion failures are counted, not + /// returned as an error. + pub(crate) fn reconcile_at_startup(&self) -> Result { + let existing = self.provider.list_existing_snapshots()?; + let deleted = existing + .iter() + .filter(|snapshot_id| self.provider.delete_snapshot(snapshot_id).is_ok()) + .count(); + Ok(deleted) + } + + /// Lock the lease map, recovering from a poisoned mutex (a panic in + /// one connection-handler thread must not wedge every other lease + /// operation). + fn lock_leases(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.leases + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Converts seconds to milliseconds, saturating rather than overflowing +/// for an implausibly large `maximum_lifetime_secs`. +fn secs_to_ms_saturating(secs: u64) -> i64 { + i64::try_from(secs.saturating_mul(1000)).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests; diff --git a/crates/uffs-broker/src/snapshot_lease/tests.rs b/crates/uffs-broker/src/snapshot_lease/tests.rs new file mode 100644 index 000000000..2b581de28 --- /dev/null +++ b/crates/uffs-broker/src/snapshot_lease/tests.rs @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Lease-lifecycle tests against a fake [`VssProvider`] — no real VSS is +//! ever called here (`uffs-ingest-implementation-plan.md` §4.3). + +use core::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use uffs_broker_protocol::snapshot_manager::{SnapshotLeaseState, VolumeIdentity}; + +use super::{LeaseError, SnapshotHandle, SnapshotLeaseManager, VssError, VssProvider}; + +/// A fake [`VssProvider`] that never touches real VSS: `create_snapshot` +/// hands out sequential fake snapshot IDs, `delete_snapshot` just records +/// what it was asked to delete, and `list_existing_snapshots` returns a +/// fixed, test-supplied set (simulating leftover shadow copies from a +/// previous run). +#[derive(Debug, Default)] +struct FakeVssProvider { + next_snapshot_id: AtomicU64, + deleted: Mutex>>, + existing_at_startup: Vec>, + fail_create: bool, +} + +impl FakeVssProvider { + fn with_existing_at_startup(existing: Vec>) -> Self { + Self { + existing_at_startup: existing, + ..Self::default() + } + } + + fn failing() -> Self { + Self { + fail_create: true, + ..Self::default() + } + } + + fn deleted_snapshot_ids(&self) -> Vec> { + self.deleted + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +impl VssProvider for FakeVssProvider { + fn create_snapshot( + &self, + _volume: &VolumeIdentity, + _requested_root: &[u8], + ) -> Result { + if self.fail_create { + return Err(VssError::CreateFailed("forced test failure".to_owned())); + } + let id = self.next_snapshot_id.fetch_add(1, Ordering::Relaxed); + Ok(SnapshotHandle { + snapshot_id: id.to_le_bytes().to_vec(), + device_identity: format!(r"\\?\GLOBALROOT\Device\FakeShadowCopy{id}"), + }) + } + + fn delete_snapshot(&self, snapshot_id: &[u8]) -> Result<(), VssError> { + self.deleted + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(snapshot_id.to_vec()); + Ok(()) + } + + fn list_existing_snapshots(&self) -> Result>, VssError> { + Ok(self.existing_at_startup.clone()) + } +} + +fn sample_volume() -> VolumeIdentity { + VolumeIdentity { + volume_serial: 0x1234_5678, + volume_guid: b"{11111111-2222-3333-4444-555555555555}".to_vec(), + } +} + +#[test] +fn create_then_query_reports_active() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 300, 1_000) + .expect("create must succeed"); + assert_eq!(created.lease_id, 1); + assert_eq!(created.expires_at_unix_ms, 1_000 + 300_000); + + let status = manager + .query_lease(created.lease_id, 1_500) + .expect("lease must be found"); + assert_eq!(status.state, SnapshotLeaseState::Active); + assert_eq!(status.snapshot_id, created.snapshot_id); +} + +#[test] +fn create_failure_propagates_vss_error() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::failing()); + let err = manager + .create_lease(&sample_volume(), b"C:\\data", 300, 0) + .expect_err("create must fail"); + assert!(matches!(err, LeaseError::Vss(VssError::CreateFailed(_)))); +} + +#[test] +fn renew_extends_expiry() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 60, 0) + .expect("create must succeed"); + + let new_expiry = manager + .renew_lease(created.lease_id, 999_999, 30_000) + .expect("renew must succeed"); + assert_eq!(new_expiry, 999_999); + + let status = manager + .query_lease(created.lease_id, 40_000) + .expect("lease must be found"); + assert_eq!(status.state, SnapshotLeaseState::Active); + assert_eq!(status.expires_at_unix_ms, 999_999); +} + +#[test] +fn renew_of_expired_lease_is_rejected() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 10, 0) + .expect("create must succeed"); + + // Advance well past the 10-second (10_000ms) lifetime. + let err = manager + .renew_lease(created.lease_id, 500_000, 20_000) + .expect_err("renew of an expired lease must fail"); + assert_eq!(err, LeaseError::NotActive); +} + +#[test] +fn create_renew_expire_auto_releases_and_reports_expired() { + let provider = FakeVssProvider::default(); + let manager = SnapshotLeaseManager::new(provider); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 10, 0) + .expect("create must succeed"); + + // Past expiry, with no renewal: querying must sweep it to Expired and + // auto-delete the underlying snapshot exactly once. + let status = manager + .query_lease(created.lease_id, 20_000) + .expect("lease record must still exist"); + assert_eq!(status.state, SnapshotLeaseState::Expired); + + // Querying again after it's already expired must not re-delete. + let status_again = manager + .query_lease(created.lease_id, 30_000) + .expect("lease record must still exist"); + assert_eq!(status_again.state, SnapshotLeaseState::Expired); +} + +#[test] +fn explicit_release_is_idempotent_and_deletes_exactly_once() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 300, 0) + .expect("create must succeed"); + + manager + .release_lease(created.lease_id) + .expect("first release must succeed"); + manager + .release_lease(created.lease_id) + .expect("second release must be a no-op, not an error"); + + let status = manager + .query_lease(created.lease_id, 1_000) + .expect("lease record must still exist"); + assert_eq!(status.state, SnapshotLeaseState::Released); +} + +#[test] +fn query_unknown_lease_returns_none() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + assert!(manager.query_lease(999, 0).is_none()); +} + +#[test] +fn renew_unknown_lease_is_not_found() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let err = manager + .renew_lease(999, 1_000, 0) + .expect_err("unknown lease must fail"); + assert_eq!(err, LeaseError::NotFound); +} + +#[test] +fn release_unknown_lease_is_not_found() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let err = manager + .release_lease(999) + .expect_err("unknown lease must fail"); + assert_eq!(err, LeaseError::NotFound); +} + +#[test] +fn device_identity_if_active_is_none_once_released() { + let manager = SnapshotLeaseManager::new(FakeVssProvider::default()); + let created = manager + .create_lease(&sample_volume(), b"C:\\data", 300, 0) + .expect("create must succeed"); + + assert_eq!( + manager.device_identity_if_active(created.lease_id, 100), + Some(created.device_identity.clone()) + ); + + manager + .release_lease(created.lease_id) + .expect("release must succeed"); + assert_eq!( + manager.device_identity_if_active(created.lease_id, 200), + None + ); +} + +#[test] +fn reconcile_at_startup_deletes_every_existing_snapshot() { + let existing = vec![vec![1, 1, 1, 1], vec![2, 2, 2, 2]]; + let provider = FakeVssProvider::with_existing_at_startup(existing.clone()); + let manager = SnapshotLeaseManager::new(provider); + + let deleted_count = manager + .reconcile_at_startup() + .expect("reconcile must succeed"); + assert_eq!(deleted_count, 2); + + let mut deleted_ids = manager.provider.deleted_snapshot_ids(); + deleted_ids.sort(); + let mut expected = existing; + expected.sort(); + assert_eq!(deleted_ids, expected); + + // A freshly reconciled manager has no leases of its own yet. + assert!(manager.query_lease(1, 0).is_none()); +} From 9b67b5a46d4e0f4f10a690e6f3207781e48a91db Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:30:51 -0700 Subject: [PATCH 15/98] feat(vss-requestor): per-run native VSS snapshot helper (UFI.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds uffs-vss-requestor, a tiny per-run Windows helper the Broker will spawn once per volume scan, per docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md. windows-rs does not generate bindings for IVssBackupComponents (the VSS requestor interface) — verified directly against the crate's own generated source, not assumed. Rather than hand-roll the COM vtable from memory (unverifiable, high risk of silent memory corruption), the actual requestor sequence lives in a narrow native C++ shim compiled against the official Windows SDK headers (already present in the cargo-xwin SDK cache) via build.rs + the cc crate. Everything else — the private control-pipe protocol, process lifecycle, parent-death watchdog — is ordinary Rust. Uses VSS_CTX_FILE_SHARE_BACKUP: ephemeral, auto-release, no writer participation. The session (and therefore the snapshot) lives exactly as long as this helper process does; on crash, releasing the last IVssBackupComponents reference is what deletes it — no orphan reconciliation or persistent snapshot tracking needed, matching the job model's "rerun from a fresh snapshot" recovery story. Compile- and link-verified end to end through cargo xwin build (clang-cl against the real vsbackup.h/vswriter.h/vss.h headers, linked against vssapi.lib/ole32.lib) and cargo xwin clippy at the same strictness as this workspace's lint-prod/lint-tests/lint-ci gates — not just a syntax probe, a real produced PE executable. Not yet wired into uffs-broker (spawning, Job Object assignment, pipe creation) — that's the next slice. --- Cargo.lock | 12 + Cargo.toml | 11 +- crates/uffs-vss-requestor/Cargo.toml | 70 +++++ crates/uffs-vss-requestor/build.rs | 37 +++ crates/uffs-vss-requestor/native/vss_shim.cpp | 280 ++++++++++++++++++ crates/uffs-vss-requestor/native/vss_shim.h | 103 +++++++ crates/uffs-vss-requestor/src/ffi.rs | 152 ++++++++++ crates/uffs-vss-requestor/src/main.rs | 58 ++++ crates/uffs-vss-requestor/src/pipe.rs | 84 ++++++ crates/uffs-vss-requestor/src/protocol.rs | 88 ++++++ crates/uffs-vss-requestor/src/run.rs | 220 ++++++++++++++ crates/uffs-vss-requestor/src/snapshot.rs | 206 +++++++++++++ 12 files changed, 1316 insertions(+), 5 deletions(-) create mode 100644 crates/uffs-vss-requestor/Cargo.toml create mode 100644 crates/uffs-vss-requestor/build.rs create mode 100644 crates/uffs-vss-requestor/native/vss_shim.cpp create mode 100644 crates/uffs-vss-requestor/native/vss_shim.h create mode 100644 crates/uffs-vss-requestor/src/ffi.rs create mode 100644 crates/uffs-vss-requestor/src/main.rs create mode 100644 crates/uffs-vss-requestor/src/pipe.rs create mode 100644 crates/uffs-vss-requestor/src/protocol.rs create mode 100644 crates/uffs-vss-requestor/src/run.rs create mode 100644 crates/uffs-vss-requestor/src/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index c202cd088..f61e8146a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4761,6 +4761,18 @@ dependencies = [ name = "uffs-version" version = "0.6.27" +[[package]] +name = "uffs-vss-requestor" +version = "0.6.27" +dependencies = [ + "anyhow", + "cc", + "serde", + "serde_json", + "uffs-version", + "windows 0.62.2", +] + [[package]] name = "uffs-winsvc" version = "0.6.27" diff --git a/Cargo.toml b/Cargo.toml index 8c9184008..74c67d9bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,11 +38,12 @@ members = [ "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) "crates/uffs-core", # 🎯 Query engine + compact search engine # ── Daemon Architecture ── - "crates/uffs-daemon", # 🛡️ Background service process - "crates/uffs-client", # 📡 Thin client library - "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents - "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) - "crates/uffs-content", # 📦 Content Service — VSS-snapshot-scoped file content export (optional) + "crates/uffs-daemon", # 🛡️ Background service process + "crates/uffs-client", # 📡 Thin client library + "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents + "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) + "crates/uffs-vss-requestor", # 🩹 Per-run native VSS snapshot helper, spawned by uffs-broker (optional) + "crates/uffs-content", # 📦 Content Service — VSS-snapshot-scoped file content export (optional) # ── Surfaces ── "crates/uffs-cli", # 🖥️ Command-line interface "crates/uffs-update", # ⬆️ Self-update acquire helper (HTTP/TLS isolated from the CLI) diff --git a/crates/uffs-vss-requestor/Cargo.toml b/crates/uffs-vss-requestor/Cargo.toml new file mode 100644 index 000000000..d338b601d --- /dev/null +++ b/crates/uffs-vss-requestor/Cargo.toml @@ -0,0 +1,70 @@ +# ============================================================================ +# uffs-vss-requestor: per-run native VSS snapshot helper +# ============================================================================ +# Tiny Windows-only helper process, spawned once per volume scan by +# uffs-broker's Snapshot Manager. Creates one VSS_CTX_FILE_SHARE_BACKUP +# snapshot (ephemeral, auto-release, no writer participation), holds the +# VSS requestor session alive for the whole scan over a private pipe +# protocol, and deletes/releases it on command or parent death. See +# docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md +# for the full design rationale. +# +# The actual IVssBackupComponents COM sequence lives in a narrow native +# C++ shim (native/vss_shim.cpp), compiled against the official Windows +# SDK headers via build.rs — windows-rs does not generate bindings for +# that requestor-side interface (verified directly against the crate's +# own generated source, not assumed). Everything else (pipe protocol, +# process lifecycle, argument parsing) is ordinary Rust. +# ============================================================================ + +[package] +name = "uffs-vss-requestor" +description = "UFFS VSS Requestor — per-run native VSS snapshot helper" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +# Intentionally NOT published — a Windows-only helper process with no +# library API, meaningless outside the UFFS Broker pairing. Mirrors +# uffs-broker's own rationale. +publish.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] +targets = ["x86_64-pc-windows-msvc"] +default-target = "x86_64-pc-windows-msvc" + +[[bin]] +name = "uffs-vss-requestor" +path = "src/main.rs" + +# Cross-platform: `main.rs` calls `uffs_version::handle_version!` on +# every platform, matching `uffs-broker`/`uffs-content`'s convention. +[dependencies] +uffs-version.workspace = true + +# Windows-only: the pipe protocol, process lifecycle, and FFI into the +# native shim are all `#[cfg(windows)]`. Scoped so the non-Windows stub +# (a bare `eprintln!`) doesn't pull these in — see `uffs-broker/Cargo.toml` +# for the identical rationale (F5 / issue #205). +[target.'cfg(windows)'.dependencies] +anyhow.workspace = true +windows.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true + +[build-dependencies] +# Compiles `native/vss_shim.cpp` into a static library linked into this +# binary — see `build.rs`. No PE resource embedding (icon/version info) +# for this internal helper — it's spawned by the Broker, never invoked +# directly by a user, so it skips the `winresource` step +# `uffs-broker`/`uffs-content` use. +cc = "1.2.63" + +[lints] +workspace = true diff --git a/crates/uffs-vss-requestor/build.rs b/crates/uffs-vss-requestor/build.rs new file mode 100644 index 000000000..d26ee63fc --- /dev/null +++ b/crates/uffs-vss-requestor/build.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host; the workspace's runtime +// `deny(expect_used)`/`deny(unwrap_used)` lints don't apply here. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-vss-requestor`. +//! +//! Compiles `native/vss_shim.cpp` — the narrow VSS requestor shim +//! against the official Windows SDK's `vsbackup.h` (see +//! `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md`) +//! — into a static library and links it into this crate's binary. A +//! no-op on every non-Windows build target. + +fn main() { + println!("cargo:rerun-if-changed=native/vss_shim.cpp"); + println!("cargo:rerun-if-changed=native/vss_shim.h"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "windows" { + return; + } + + cc::Build::new() + .cpp(true) + .file("native/vss_shim.cpp") + .flag_if_supported("-EHsc") + .warnings(false) + .compile("uffs_vss_shim"); + + println!("cargo:rustc-link-lib=dylib=vssapi"); + println!("cargo:rustc-link-lib=dylib=ole32"); +} diff --git a/crates/uffs-vss-requestor/native/vss_shim.cpp b/crates/uffs-vss-requestor/native/vss_shim.cpp new file mode 100644 index 000000000..ea8a7c1e9 --- /dev/null +++ b/crates/uffs-vss-requestor/native/vss_shim.cpp @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Implementation of the narrow VSS requestor shim declared in +// `vss_shim.h`. Compiled against the official Windows SDK's `vss.h` / +// `vswriter.h` / `vsbackup.h` (via `cargo-xwin`'s bundled SDK) and linked +// against `vssapi.lib` + `ole32.lib` — see this crate's `build.rs`. +// +// Sequence and context choice follow +// `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md`: +// `VSS_CTX_FILE_SHARE_BACKUP` (ephemeral, auto-release, no writers) — +// this is a requestor for a filesystem point-in-time content scan, not +// an application-consistent backup, so no writer coordination +// (`GatherWriterMetadata`, `PrepareForBackup`, `BackupComplete`) is +// performed; the guide notes these are not valid in the no-writer flow. + +#include "vss_shim.h" + +#include +#include +#include + +#include + +// One VSS requestor session: the live `IVssBackupComponents` plus enough +// state to delete its snapshot set later. Deliberately not reused across +// operations — `IVssBackupComponents` is documented as single-use per +// backup/restore/query operation. +struct UffsVssSession { + IVssBackupComponents *backup_components = nullptr; + GUID snapshot_set_id = GUID_NULL; + bool com_initialized = false; +}; + +namespace { + +wchar_t *duplicate_wide(const wchar_t *source) { + if (source == nullptr) { + return nullptr; + } + size_t length = 0; + while (source[length] != L'\0') { + ++length; + } + wchar_t *copy = new (std::nothrow) wchar_t[length + 1]; + if (copy == nullptr) { + return nullptr; + } + for (size_t index = 0; index <= length; ++index) { + copy[index] = source[index]; + } + return copy; +} + +void set_error(UffsVssError *out_error, HRESULT hr, UffsVssStage stage, const wchar_t *message) { + if (out_error == nullptr) { + return; + } + out_error->hresult = static_cast(hr); + out_error->stage = stage; + out_error->message = duplicate_wide(message != nullptr ? message : L"(no message)"); +} + +void zero_info(UffsVssSnapshotInfo *out_info) { + if (out_info == nullptr) { + return; + } + out_info->snapshot_set_id = GUID_NULL; + out_info->snapshot_id = GUID_NULL; + out_info->provider_id = GUID_NULL; + out_info->original_volume_name = nullptr; + out_info->snapshot_device_object = nullptr; + out_info->creation_timestamp_unix_ms = 0; +} + +// `VSS_TIMESTAMP` is a Windows `FILETIME`-shaped 64-bit value (100ns +// intervals since 1601-01-01). Convert to Unix milliseconds. +int64_t vss_timestamp_to_unix_ms(VSS_TIMESTAMP timestamp) { + constexpr int64_t kFiletimeToUnixEpochOffsetIn100ns = 116444736000000000LL; + int64_t hundred_ns_since_unix_epoch = static_cast(timestamp) - kFiletimeToUnixEpochOffsetIn100ns; + return hundred_ns_since_unix_epoch / 10000; +} + +void destroy_session(UffsVssSession *session) { + if (session == nullptr) { + return; + } + if (session->backup_components != nullptr) { + // Releasing the last reference is what actually deletes the + // snapshot for an auto-release context if it wasn't already + // explicitly deleted — see this file's header-comment note. + session->backup_components->Release(); + session->backup_components = nullptr; + } + if (session->com_initialized) { + CoUninitialize(); + session->com_initialized = false; + } + delete session; +} + +} // namespace + +int32_t uffs_vss_create_file_share_snapshot( + const wchar_t *volume_path, + UffsVssSession **out_session, + UffsVssSnapshotInfo *out_info, + UffsVssError *out_error) { + if (out_session != nullptr) { + *out_session = nullptr; + } + zero_info(out_info); + + if (volume_path == nullptr || out_session == nullptr || out_info == nullptr) { + set_error(out_error, E_INVALIDARG, UFFS_VSS_STAGE_INVALID_ARGUMENT, L"null argument"); + return E_INVALIDARG; + } + + UffsVssSession *session = new (std::nothrow) UffsVssSession(); + if (session == nullptr) { + set_error(out_error, E_OUTOFMEMORY, UFFS_VSS_STAGE_COM_INIT, L"allocation failure"); + return E_OUTOFMEMORY; + } + + // A dedicated single-purpose helper process: this is the only COM + // user in the whole process, so initializing the apartment here + // (once, for this session's lifetime) and uninitializing on release + // is the simplest correct pattern. + HRESULT hr = CoInitializeEx(nullptr, COINIT_MULTITHREADED); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_COM_INIT, L"CoInitializeEx failed"); + delete session; + return hr; + } + // S_FALSE means COM was already initialized on this thread; either + // way we now hold a reference this session's release must balance. + session->com_initialized = true; + + IVssBackupComponents *backup_components = nullptr; + hr = CreateVssBackupComponents(&backup_components); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_CREATE_COMPONENTS, L"CreateVssBackupComponents failed"); + destroy_session(session); + return hr; + } + session->backup_components = backup_components; + + hr = backup_components->InitializeForBackup(); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_INITIALIZE_BACKUP, L"InitializeForBackup failed"); + destroy_session(session); + return hr; + } + + hr = backup_components->SetContext(VSS_CTX_FILE_SHARE_BACKUP); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_SET_CONTEXT, L"SetContext(VSS_CTX_FILE_SHARE_BACKUP) failed"); + destroy_session(session); + return hr; + } + + hr = backup_components->SetBackupState(FALSE, FALSE, VSS_BT_COPY, FALSE); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_SET_BACKUP_STATE, L"SetBackupState failed"); + destroy_session(session); + return hr; + } + + VSS_ID snapshot_set_id = GUID_NULL; + hr = backup_components->StartSnapshotSet(&snapshot_set_id); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_START_SET, L"StartSnapshotSet failed"); + destroy_session(session); + return hr; + } + session->snapshot_set_id = snapshot_set_id; + + VSS_ID snapshot_id = GUID_NULL; + hr = backup_components->AddToSnapshotSet(const_cast(volume_path), GUID_NULL, &snapshot_id); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_ADD_VOLUME, L"AddToSnapshotSet failed"); + destroy_session(session); + return hr; + } + + IVssAsync *async = nullptr; + hr = backup_components->DoSnapshotSet(&async); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_DO_SET_SUBMIT, L"DoSnapshotSet failed to submit"); + destroy_session(session); + return hr; + } + + hr = async->Wait(); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_DO_SET_WAIT, L"IVssAsync::Wait failed"); + async->Release(); + destroy_session(session); + return hr; + } + + HRESULT async_result = S_OK; + hr = async->QueryStatus(&async_result, nullptr); + async->Release(); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_DO_SET_STATUS, L"IVssAsync::QueryStatus failed"); + destroy_session(session); + return hr; + } + if (FAILED(async_result)) { + set_error(out_error, async_result, UFFS_VSS_STAGE_DO_SET_STATUS, L"DoSnapshotSet completed with a failure result"); + destroy_session(session); + return async_result; + } + + VSS_SNAPSHOT_PROP properties; + hr = backup_components->GetSnapshotProperties(snapshot_id, &properties); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_GET_PROPERTIES, L"GetSnapshotProperties failed"); + destroy_session(session); + return hr; + } + + // Copy every field we need into shim-owned memory before freeing + // VSS's own copy — never hand a pointer into `VSS_SNAPSHOT_PROP` + // itself back across the ABI boundary. + out_info->snapshot_set_id = properties.m_SnapshotSetId; + out_info->snapshot_id = properties.m_SnapshotId; + out_info->provider_id = properties.m_ProviderId; + out_info->original_volume_name = duplicate_wide(properties.m_pwszOriginalVolumeName); + out_info->snapshot_device_object = duplicate_wide(properties.m_pwszSnapshotDeviceObject); + out_info->creation_timestamp_unix_ms = vss_timestamp_to_unix_ms(properties.m_tsCreationTimestamp); + VssFreeSnapshotProperties(&properties); + + *out_session = session; + return S_OK; +} + +int32_t uffs_vss_delete_snapshot_set(UffsVssSession *session, UffsVssError *out_error) { + if (session == nullptr || session->backup_components == nullptr) { + set_error(out_error, E_INVALIDARG, UFFS_VSS_STAGE_INVALID_ARGUMENT, L"null or already-torn-down session"); + return E_INVALIDARG; + } + + LONG deleted_count = 0; + VSS_ID first_non_deleted = GUID_NULL; + HRESULT hr = session->backup_components->DeleteSnapshots( + session->snapshot_set_id, + VSS_OBJECT_SNAPSHOT_SET, + TRUE, + &deleted_count, + &first_non_deleted); + if (FAILED(hr)) { + set_error(out_error, hr, UFFS_VSS_STAGE_DELETE_SET, L"DeleteSnapshots failed"); + return hr; + } + return S_OK; +} + +void uffs_vss_session_release(UffsVssSession *session) { + destroy_session(session); +} + +void uffs_vss_snapshot_info_free(UffsVssSnapshotInfo *info) { + if (info == nullptr) { + return; + } + delete[] info->original_volume_name; + delete[] info->snapshot_device_object; + zero_info(info); +} + +void uffs_vss_error_free(UffsVssError *error) { + if (error == nullptr) { + return; + } + delete[] error->message; + error->message = nullptr; + error->hresult = S_OK; +} diff --git a/crates/uffs-vss-requestor/native/vss_shim.h b/crates/uffs-vss-requestor/native/vss_shim.h new file mode 100644 index 000000000..f49475201 --- /dev/null +++ b/crates/uffs-vss-requestor/native/vss_shim.h @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Narrow C ABI over the official `vsbackup.h` VSS requestor sequence. +// This is the entire native surface `uffs-vss-requestor`'s Rust side +// calls into — no raw IUnknown*, no vtable pointers, no caller-managed +// BSTR/VSS_SNAPSHOT_PROP ownership. Every out-pointer here is either a +// plain value type or a pointer this header's own `*_free` function +// releases; nothing SDK-owned crosses this boundary. + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Opaque handle to a live VSS requestor session (one `IVssBackupComponents` +// instance plus the snapshot-set/context state needed to delete it later). +// Only one snapshot set is ever created per session, matching +// `IVssBackupComponents`'s documented one-operation-per-instance contract. +typedef struct UffsVssSession UffsVssSession; + +// Which step of the requestor sequence an error occurred at, for +// diagnostics (design doc: "Preserve stage and HRESULT"). +typedef enum UffsVssStage { + UFFS_VSS_STAGE_COM_INIT = 0, + UFFS_VSS_STAGE_CREATE_COMPONENTS = 1, + UFFS_VSS_STAGE_INITIALIZE_BACKUP = 2, + UFFS_VSS_STAGE_SET_CONTEXT = 3, + UFFS_VSS_STAGE_SET_BACKUP_STATE = 4, + UFFS_VSS_STAGE_START_SET = 5, + UFFS_VSS_STAGE_ADD_VOLUME = 6, + UFFS_VSS_STAGE_DO_SET_SUBMIT = 7, + UFFS_VSS_STAGE_DO_SET_WAIT = 8, + UFFS_VSS_STAGE_DO_SET_STATUS = 9, + UFFS_VSS_STAGE_GET_PROPERTIES = 10, + UFFS_VSS_STAGE_DELETE_SET = 11, + UFFS_VSS_STAGE_INVALID_ARGUMENT = 12, +} UffsVssStage; + +typedef struct UffsVssSnapshotInfo { + GUID snapshot_set_id; + GUID snapshot_id; + GUID provider_id; + // Both fields below are heap-allocated by this shim (a private copy, + // never a pointer into VSS-owned VSS_SNAPSHOT_PROP memory) and must + // be released via `uffs_vss_snapshot_info_free`. + wchar_t *original_volume_name; + wchar_t *snapshot_device_object; + int64_t creation_timestamp_unix_ms; +} UffsVssSnapshotInfo; + +typedef struct UffsVssError { + int32_t hresult; + UffsVssStage stage; + // Heap-allocated by this shim; release via `uffs_vss_error_free`. + wchar_t *message; +} UffsVssError; + +// Create a `VSS_CTX_FILE_SHARE_BACKUP` (ephemeral, auto-release, no +// writer participation) snapshot of `volume_path` (a canonical +// `\\?\Volume{GUID}\`-style path). +// +// On success (return value `S_OK`): `*out_session` owns a live +// `IVssBackupComponents`, kept alive until `uffs_vss_session_release`; +// `*out_info` is populated and must be released via +// `uffs_vss_snapshot_info_free`. Because the context is auto-release, +// the underlying snapshot is deleted the moment the session is released +// without an explicit `uffs_vss_delete_snapshot_set` call — that is the +// crash-safety net; normal completion should still call +// `uffs_vss_delete_snapshot_set` first for deterministic, observable +// cleanup. +// +// On failure: `*out_session` and `*out_info` are zeroed; `*out_error` is +// populated and must be released via `uffs_vss_error_free`. +int32_t uffs_vss_create_file_share_snapshot( + const wchar_t *volume_path, + UffsVssSession **out_session, + UffsVssSnapshotInfo *out_info, + UffsVssError *out_error); + +// Explicitly delete the snapshot set owned by `session`. Does not +// release the session itself — call `uffs_vss_session_release` +// afterward regardless of the outcome here. +int32_t uffs_vss_delete_snapshot_set( + UffsVssSession *session, + UffsVssError *out_error); + +// Release `session` (a no-op if `session` is `NULL`). If the snapshot +// set was never explicitly deleted, this is where the auto-release +// context's actual cleanup happens (releasing the last reference to +// `IVssBackupComponents`). +void uffs_vss_session_release(UffsVssSession *session); + +void uffs_vss_snapshot_info_free(UffsVssSnapshotInfo *info); +void uffs_vss_error_free(UffsVssError *error); + +#ifdef __cplusplus +} +#endif diff --git a/crates/uffs-vss-requestor/src/ffi.rs b/crates/uffs-vss-requestor/src/ffi.rs new file mode 100644 index 000000000..72a8923ee --- /dev/null +++ b/crates/uffs-vss-requestor/src/ffi.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Raw FFI declarations for the native VSS shim (`native/vss_shim.h`/ +//! `.cpp`, compiled and linked in by `build.rs`). +//! +//! Every `#[repr(C)]` type here must exactly mirror the C header's field +//! order, size, and alignment — see `Guid`'s doc comment for why it's +//! not a bare `[u8; 16]`. This module only declares the raw boundary; +//! [`crate::snapshot`] owns the safe wrapper (RAII session, string +//! decoding, error conversion). + +/// Mirrors the Win32 `GUID` struct field-for-field (`data1: u32`, +/// `data2`/`data3: u16`, `data4: [u8; 8]`) rather than a bare `[u8; 16]`. +/// A byte array has alignment 1, but `GUID`'s actual alignment is 4 (from +/// its leading `u32`) — since [`SnapshotInfo`] embeds three of these +/// directly (not as pointers), a wrong alignment here would shift every +/// field after them, corrupting the ABI silently. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Guid { + /// First 4 bytes (`Data1`). + pub(crate) data1: u32, + /// Next 2 bytes (`Data2`). + pub(crate) data2: u16, + /// Next 2 bytes (`Data3`). + pub(crate) data3: u16, + /// Final 8 bytes (`Data4`). + pub(crate) data4: [u8; 8], +} + +impl Guid { + /// Render in the canonical `{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}` + /// form. + pub(crate) fn to_braced_string(self) -> String { + format!( + "{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + self.data1, + self.data2, + self.data3, + self.data4[0], + self.data4[1], + self.data4[2], + self.data4[3], + self.data4[4], + self.data4[5], + self.data4[6], + self.data4[7], + ) + } +} + +/// Mirrors `UffsVssSnapshotInfo` in `native/vss_shim.h` field-for-field. +#[repr(C)] +pub(crate) struct SnapshotInfo { + /// The snapshot set's GUID. + pub(crate) snapshot_set_id: Guid, + /// This specific snapshot's GUID. + pub(crate) snapshot_id: Guid, + /// The VSS provider's GUID. + pub(crate) provider_id: Guid, + /// NUL-terminated UTF-16 original volume name, or null. + pub(crate) original_volume_name: *mut u16, + /// NUL-terminated UTF-16 snapshot device path, or null. + pub(crate) snapshot_device_object: *mut u16, + /// Snapshot creation time, Unix milliseconds. + pub(crate) creation_timestamp_unix_ms: i64, +} + +impl SnapshotInfo { + /// A zeroed value, matching the shim's `zero_info` — safe to pass as + /// an out-parameter before the shim populates (or fails to + /// populate) it. + pub(crate) const fn zeroed() -> Self { + Self { + snapshot_set_id: Guid { + data1: 0, + data2: 0, + data3: 0, + data4: [0; 8], + }, + snapshot_id: Guid { + data1: 0, + data2: 0, + data3: 0, + data4: [0; 8], + }, + provider_id: Guid { + data1: 0, + data2: 0, + data3: 0, + data4: [0; 8], + }, + original_volume_name: core::ptr::null_mut(), + snapshot_device_object: core::ptr::null_mut(), + creation_timestamp_unix_ms: 0, + } + } +} + +/// Mirrors `UffsVssError` in `native/vss_shim.h` field-for-field. `stage` +/// is `i32`, not `u32`: a plain C/C++ enum with no explicit underlying +/// type (as `UffsVssStage` is declared) has an implementation-defined +/// signed underlying type — `int` for every target this workspace +/// builds against. +#[repr(C)] +pub(crate) struct VssError { + /// The failing `HRESULT`. + pub(crate) hresult: i32, + /// Which step of the requestor sequence failed (`UffsVssStage`). + pub(crate) stage: i32, + /// NUL-terminated UTF-16 diagnostic message, or null. + pub(crate) message: *mut u16, +} + +impl VssError { + /// A zeroed value, safe to pass as an out-parameter. + pub(crate) const fn zeroed() -> Self { + Self { + hresult: 0, + stage: 0, + message: core::ptr::null_mut(), + } + } +} + +/// Opaque handle to a live VSS requestor session — never constructed or +/// read from Rust, only passed back to the shim that produced it. +pub(crate) enum Session {} + +#[expect( + unsafe_code, + reason = "raw FFI declarations for the native VSS shim compiled by build.rs; \ + every call site documents its own safety contract in crate::snapshot" +)] +unsafe extern "C" { + pub(crate) fn uffs_vss_create_file_share_snapshot( + volume_path: *const u16, + out_session: *mut *mut Session, + out_info: *mut SnapshotInfo, + out_error: *mut VssError, + ) -> i32; + + pub(crate) fn uffs_vss_delete_snapshot_set( + session: *mut Session, + out_error: *mut VssError, + ) -> i32; + + pub(crate) fn uffs_vss_session_release(session: *mut Session); + pub(crate) fn uffs_vss_snapshot_info_free(info: *mut SnapshotInfo); + pub(crate) fn uffs_vss_error_free(error: *mut VssError); +} diff --git a/crates/uffs-vss-requestor/src/main.rs b/crates/uffs-vss-requestor/src/main.rs new file mode 100644 index 000000000..6d781835a --- /dev/null +++ b/crates/uffs-vss-requestor/src/main.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! UFFS VSS Requestor — per-run native VSS snapshot helper. +//! +//! Spawned once per volume scan by `uffs-broker`'s Snapshot Manager, per +//! `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md`. +//! Creates one `VSS_CTX_FILE_SHARE_BACKUP` snapshot (ephemeral, +//! auto-release, no writer participation), holds the VSS requestor +//! session alive for the whole scan, and exits only after an explicit +//! `Release`/`Cancel` over its private control pipe, the pipe closing, +//! or its parent Broker process dying (this process's own watchdog is a +//! second, independent safety net alongside the Job Object the Broker +//! assigns it to). +//! +//! # Usage +//! +//! ```bash +//! uffs-vss-requestor --version +//! uffs-vss-requestor --pipe-name --volume-path --parent-pid +//! ``` +//! +//! Not meant to be run manually outside of debugging — the pipe name +//! and parent PID are private, Broker-assigned values. + +#[cfg(windows)] +mod ffi; +#[cfg(windows)] +mod pipe; +#[cfg(windows)] +mod protocol; +#[cfg(windows)] +mod run; +#[cfg(windows)] +mod snapshot; + +#[expect( + clippy::print_stderr, + reason = "no tracing subscriber in this tiny helper; stderr is the only \ + diagnostic channel and is captured by the spawning Broker" +)] +fn main() { + uffs_version::handle_version!("uffs-vss-requestor"); + + #[cfg(windows)] + { + if let Err(run_err) = run::run() { + eprintln!("uffs-vss-requestor: {run_err:#}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!("uffs-vss-requestor is a Windows-only component."); + std::process::exit(1); + } +} diff --git a/crates/uffs-vss-requestor/src/pipe.rs b/crates/uffs-vss-requestor/src/pipe.rs new file mode 100644 index 000000000..597e5121e --- /dev/null +++ b/crates/uffs-vss-requestor/src/pipe.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Connect to the Broker-created private control pipe as a client. +//! +//! The Broker (running as `LocalSystem`, same identity this helper +//! inherits) creates the named pipe *before* spawning this process, so +//! in the normal case the first connect attempt succeeds; the retry loop +//! below is a safety margin, not a load-bearing race handler. + +use core::time::Duration; +use std::fs::File; +use std::os::windows::ffi::OsStrExt as _; +use std::os::windows::io::FromRawHandle as _; + +use windows::Win32::Foundation::ERROR_PIPE_BUSY; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_MODE, + OPEN_EXISTING, +}; +use windows::Win32::System::Pipes::WaitNamedPipeW; +use windows::core::PCWSTR; + +/// Maximum connect attempts before giving up. +const MAX_ATTEMPTS: u32 = 50; + +/// Delay between attempts that aren't following an explicit +/// `ERROR_PIPE_BUSY` wait. +const RETRY_DELAY: Duration = Duration::from_millis(100); + +/// Connect to `pipe_name` as a duplex client, retrying briefly if the +/// Broker hasn't finished creating the pipe instance yet. +/// +/// # Errors +/// Returns an error if every attempt fails. +#[expect( + unsafe_code, + reason = "CreateFileW and WaitNamedPipeW are FFI calls; File::from_raw_handle \ + takes ownership of a HANDLE this function itself just opened" +)] +pub(crate) fn connect(pipe_name: &str) -> anyhow::Result { + let wide_name: Vec = std::ffi::OsStr::new(pipe_name) + .encode_wide() + .chain(Some(0)) + .collect(); + + for attempt in 0..MAX_ATTEMPTS { + // SAFETY: `wide_name` is a NUL-terminated UTF-16 buffer valid for + // the duration of this call. + let result = unsafe { + CreateFileW( + PCWSTR(wide_name.as_ptr()), + (FILE_GENERIC_READ | FILE_GENERIC_WRITE).0, + FILE_SHARE_MODE(0), + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + }; + + match result { + Ok(handle) => { + // SAFETY: `handle` is a valid, freshly opened, exclusively + // owned HANDLE to a byte-mode duplex pipe; `File` takes + // ownership and will `CloseHandle` it on drop. + let file = unsafe { File::from_raw_handle(handle.0.cast::()) }; + return Ok(file); + } + Err(err) if err.code() == ERROR_PIPE_BUSY.to_hresult() => { + // SAFETY: `wide_name` is valid for the call; waits up to + // 5s for an instance to free up before retrying. + let _wait_result = unsafe { WaitNamedPipeW(PCWSTR(wide_name.as_ptr()), 5000) }; + } + Err(err) if attempt + 1 == MAX_ATTEMPTS => { + anyhow::bail!("CreateFileW failed connecting to {pipe_name}: {err}"); + } + Err(_err) => { + std::thread::sleep(RETRY_DELAY); + } + } + } + anyhow::bail!("failed to connect to {pipe_name} after {MAX_ATTEMPTS} attempts") +} diff --git a/crates/uffs-vss-requestor/src/protocol.rs b/crates/uffs-vss-requestor/src/protocol.rs new file mode 100644 index 000000000..de4c4d29c --- /dev/null +++ b/crates/uffs-vss-requestor/src/protocol.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! The private Broker↔helper control protocol: one JSON object per line +//! over the pipe [`super::pipe::connect`] returns. This is a narrow, +//! internal-only protocol (never shared with any other process or +//! language), so a JSON-lines encoding is simpler and safer than +//! hand-rolled binary framing for this crate's tiny message set. + +use std::io::BufRead; + +use serde::{Deserialize, Serialize}; + +/// Helper → Broker messages. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +pub(crate) enum HelperEvent { + /// The snapshot was created; the Broker may now hand out a read + /// lease against `snapshot_device_object`. + Ready { + /// The snapshot set's GUID, canonical `{...}` string form. + snapshot_set_id: String, + /// This specific snapshot's GUID, canonical `{...}` string form. + snapshot_id: String, + /// The VSS provider's GUID, canonical `{...}` string form. + provider_id: String, + /// The original volume's name, if the shim reported one. + original_volume_name: Option, + /// The snapshot's device path, if the shim reported one. + snapshot_device_object: Option, + /// Snapshot creation time, Unix milliseconds. + created_at_unix_ms: i64, + }, + /// The snapshot set was explicitly deleted in response to + /// [`BrokerCommand::Release`]. + Released, + /// A VSS requestor operation failed — either the initial creation, + /// or an explicit [`BrokerCommand::Release`]'s deletion. + Failed { + /// Which step of the requestor sequence failed. + stage: i32, + /// The failing `HRESULT`. + hresult: i32, + /// Human-readable diagnostic message. + message: String, + }, + /// Reply to [`BrokerCommand::Ping`]. + Pong, +} + +/// Broker → Helper messages. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub(crate) enum BrokerCommand { + /// Delete the snapshot set and exit. + Release, + /// Exit without explicit deletion (relies on + /// `VSS_CTX_FILE_SHARE_BACKUP`'s auto-release on session drop). + Cancel, + /// Liveness check; expects a [`HelperEvent::Pong`] reply. + Ping, +} + +/// Write one [`HelperEvent`] as a single JSON line, flushing +/// immediately. +pub(crate) fn write_event( + writer: &mut impl std::io::Write, + event: &HelperEvent, +) -> std::io::Result<()> { + let line = serde_json::to_string(event) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + writeln!(writer, "{line}")?; + writer.flush() +} + +/// Read one [`BrokerCommand`] line, or `Ok(None)` at EOF — the pipe +/// closing is treated as an implicit [`BrokerCommand::Cancel`] by the +/// caller, not an error. +pub(crate) fn read_command(reader: &mut impl BufRead) -> std::io::Result> { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + let command = serde_json::from_str(line.trim_end()) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Ok(Some(command)) +} diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs new file mode 100644 index 000000000..039e12a7b --- /dev/null +++ b/crates/uffs-vss-requestor/src/run.rs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Top-level orchestration: parse arguments, create the snapshot, +//! report readiness, then wait for `Release`/`Cancel`/pipe-closed/ +//! parent-death and tear down accordingly. + +use std::io::BufReader; +use std::sync::mpsc; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject, +}; + +use crate::pipe; +use crate::protocol::{self, BrokerCommand, HelperEvent}; +use crate::snapshot::VssSnapshotSession; + +/// Parsed command-line arguments. +struct Args { + /// Name of the private control pipe to connect to. + pipe_name: String, + /// Canonical volume path to snapshot. + volume_path: String, + /// PID of the spawning Broker, watched for early exit. + parent_pid: u32, +} + +impl Args { + /// Parse `std::env::args()`, skipping argv\[0\]. + /// + /// # Errors + /// Returns an error if a required flag is missing or a value fails + /// to parse. + fn parse() -> anyhow::Result { + let mut pipe_name = None; + let mut volume_path = None; + let mut parent_pid = None; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + match flag.as_str() { + "--pipe-name" => pipe_name = Some(next_value(&mut args, "--pipe-name")?), + "--volume-path" => volume_path = Some(next_value(&mut args, "--volume-path")?), + "--parent-pid" => { + let value = next_value(&mut args, "--parent-pid")?; + parent_pid = Some( + value + .parse::() + .map_err(|err| anyhow::anyhow!("invalid --parent-pid: {err}"))?, + ); + } + other => anyhow::bail!("unrecognized argument: {other}"), + } + } + + Ok(Self { + pipe_name: pipe_name.ok_or_else(|| anyhow::anyhow!("--pipe-name is required"))?, + volume_path: volume_path.ok_or_else(|| anyhow::anyhow!("--volume-path is required"))?, + parent_pid: parent_pid.ok_or_else(|| anyhow::anyhow!("--parent-pid is required"))?, + }) + } +} + +/// Take the next positional value for `flag`, or an error if argv ran +/// out. +fn next_value(args: &mut impl Iterator, flag: &str) -> anyhow::Result { + args.next() + .ok_or_else(|| anyhow::anyhow!("{flag} requires a value")) +} + +/// An event the main loop reacts to — a decoded command from the +/// Broker, the pipe closing, or the parent process dying (a second, +/// independent safety net alongside the Job Object the Broker assigns +/// this process to). +enum MainEvent { + /// A decoded command arrived from the Broker. + Command(BrokerCommand), + /// The control pipe closed (EOF) or a read failed. + PipeClosed, + /// The watched parent process exited. + ParentDied, +} + +/// Run the helper end to end. +/// +/// # Errors +/// Returns an error if arguments are invalid, the pipe can't be +/// connected, or the initial snapshot creation fails (after reporting +/// [`HelperEvent::Failed`] to the Broker). +pub(crate) fn run() -> anyhow::Result<()> { + let args = Args::parse()?; + let mut writer = pipe::connect(&args.pipe_name)?; + let reader_file = writer + .try_clone() + .map_err(|err| anyhow::anyhow!("failed to clone pipe handle for reading: {err}"))?; + + let mut session = match VssSnapshotSession::create(&args.volume_path) { + Ok((session, descriptor)) => { + protocol::write_event(&mut writer, &HelperEvent::Ready { + snapshot_set_id: descriptor.snapshot_set_id, + snapshot_id: descriptor.snapshot_id, + provider_id: descriptor.provider_id, + original_volume_name: descriptor.original_volume_name, + snapshot_device_object: descriptor.snapshot_device_object, + created_at_unix_ms: descriptor.created_at_unix_ms, + })?; + session + } + Err(err) => { + let stage = err.stage; + let hresult = err.hresult; + protocol::write_event(&mut writer, &HelperEvent::Failed { + stage: err.stage, + hresult: err.hresult, + message: err.message, + })?; + anyhow::bail!("snapshot creation failed: stage={stage} hresult={hresult:#x}"); + } + }; + + let (event_tx, event_rx) = mpsc::channel::(); + + let reader_tx = event_tx.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(reader_file); + loop { + if let Ok(Some(command)) = protocol::read_command(&mut reader) { + if reader_tx.send(MainEvent::Command(command)).is_err() { + return; + } + } else { + drop(reader_tx.send(MainEvent::PipeClosed)); + return; + } + } + }); + + let watchdog_tx = event_tx.clone(); + let parent_pid = args.parent_pid; + std::thread::spawn(move || { + wait_for_process_exit(parent_pid); + drop(watchdog_tx.send(MainEvent::ParentDied)); + }); + drop(event_tx); + + for event in event_rx { + match event { + MainEvent::Command(BrokerCommand::Ping) => { + drop(protocol::write_event(&mut writer, &HelperEvent::Pong)); + } + MainEvent::Command(BrokerCommand::Release) => { + match session.delete_snapshot_set() { + Ok(()) => { + drop(protocol::write_event(&mut writer, &HelperEvent::Released)); + } + Err(err) => { + drop(protocol::write_event(&mut writer, &HelperEvent::Failed { + stage: err.stage, + hresult: err.hresult, + message: err.message, + })); + } + } + drop(session); + return Ok(()); + } + MainEvent::Command(BrokerCommand::Cancel) + | MainEvent::PipeClosed + | MainEvent::ParentDied => { + // Auto-release path: dropping `session` releases the + // last `IVssBackupComponents` reference, which is where + // `VSS_CTX_FILE_SHARE_BACKUP`'s auto-delete happens if + // the snapshot set was never explicitly deleted. + drop(session); + return Ok(()); + } + } + } + + drop(session); + Ok(()) +} + +/// Block until the process identified by `pid` exits, or return +/// immediately if it can't be opened (already gone, or never existed — +/// either way, "wait for it to die" is trivially satisfied). +#[expect( + unsafe_code, + reason = "OpenProcess, WaitForSingleObject, and CloseHandle are FFI calls" +)] +fn wait_for_process_exit(pid: u32) { + // SAFETY: `pid` is a plain integer; a failed open (process already + // gone) is handled by returning immediately, never dereferenced. + let handle_result = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + false, + pid, + ) + }; + let Ok(handle) = handle_result else { + return; + }; + // SAFETY: `handle` is the valid handle just opened above; waiting + // indefinitely is intentional — this whole thread's job is to block + // until the parent exits. + let _wait_result = unsafe { WaitForSingleObject(handle, u32::MAX) }; + close_handle(handle); +} + +/// Close `handle`, logging nothing on failure (this is best-effort +/// cleanup in a thread that's about to signal process exit anyway). +#[expect(unsafe_code, reason = "CloseHandle is an FFI call")] +fn close_handle(handle: HANDLE) { + // SAFETY: `handle` was opened by this module and is not used again + // after this call. + drop(unsafe { CloseHandle(handle) }); +} diff --git a/crates/uffs-vss-requestor/src/snapshot.rs b/crates/uffs-vss-requestor/src/snapshot.rs new file mode 100644 index 000000000..23229249a --- /dev/null +++ b/crates/uffs-vss-requestor/src/snapshot.rs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Safe RAII wrapper over the raw VSS shim FFI ([`crate::ffi`]). + +use crate::ffi; + +/// A live VSS requestor session: exactly one snapshot set, created via +/// [`VssSnapshotSession::create`] and torn down exactly once, on +/// [`Drop`]. +/// +/// Deliberately not `Send`/`Sync` — this process holds exactly one +/// session for its entire lifetime and drives it from a single thread +/// (see `main.rs`); a background thread that notices the parent Broker +/// has died signals cleanup via a channel rather than touching the +/// session directly. +pub(crate) struct VssSnapshotSession { + /// The live shim session handle. + raw: *mut ffi::Session, +} + +/// Everything about a created snapshot the Broker needs to hand off a +/// read lease: identifiers for cleanup/diagnostics, the device path a +/// [`super`]-level consumer would open, and the creation time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SnapshotDescriptor { + /// The snapshot set's GUID, canonical `{...}` string form. + pub(crate) snapshot_set_id: String, + /// This specific snapshot's GUID, canonical `{...}` string form. + pub(crate) snapshot_id: String, + /// The VSS provider's GUID, canonical `{...}` string form. + pub(crate) provider_id: String, + /// The original volume's name, if the shim reported one. + pub(crate) original_volume_name: Option, + /// The snapshot's device path, if the shim reported one. + pub(crate) snapshot_device_object: Option, + /// Snapshot creation time, Unix milliseconds. + pub(crate) created_at_unix_ms: i64, +} + +/// A failed VSS requestor operation, preserving stage + `HRESULT` for +/// diagnostics (never flattened to a bare string — see the +/// implementation guide's error-handling section). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VssRequestError { + /// Which step of the requestor sequence failed. + pub(crate) stage: i32, + /// The failing `HRESULT`. + pub(crate) hresult: i32, + /// Human-readable diagnostic message. + pub(crate) message: String, +} + +impl VssSnapshotSession { + /// Create a `VSS_CTX_FILE_SHARE_BACKUP` snapshot of `volume_path` + /// (a canonical `\\?\Volume{GUID}\`-style path). + /// + /// # Errors + /// Returns [`VssRequestError`] if any step of the requestor sequence + /// fails; no session is returned in that case (nothing to release). + #[expect( + unsafe_code, + reason = "calls into the native VSS shim; see the inline SAFETY comment" + )] + pub(crate) fn create(volume_path: &str) -> Result<(Self, SnapshotDescriptor), VssRequestError> { + let wide_path: Vec = volume_path.encode_utf16().chain(Some(0)).collect(); + let mut raw_session: *mut ffi::Session = core::ptr::null_mut(); + let mut info = ffi::SnapshotInfo::zeroed(); + let mut error = ffi::VssError::zeroed(); + + // SAFETY: `wide_path` is a NUL-terminated UTF-16 buffer valid for + // the duration of this call; the three out-parameters are + // stack-owned locals passed as exclusive raw pointers, matching + // `native/vss_shim.h`'s documented contract. + let hresult = unsafe { + ffi::uffs_vss_create_file_share_snapshot( + wide_path.as_ptr(), + &raw mut raw_session, + &raw mut info, + &raw mut error, + ) + }; + + if hresult < 0_i32 { + return Err(take_error(&mut error)); + } + + let descriptor = SnapshotDescriptor { + snapshot_set_id: info.snapshot_set_id.to_braced_string(), + snapshot_id: info.snapshot_id.to_braced_string(), + provider_id: info.provider_id.to_braced_string(), + original_volume_name: read_optional_wide(info.original_volume_name), + snapshot_device_object: read_optional_wide(info.snapshot_device_object), + created_at_unix_ms: info.creation_timestamp_unix_ms, + }; + free_snapshot_info(&mut info); + + Ok((Self { raw: raw_session }, descriptor)) + } + + /// Explicitly delete this session's snapshot set (deterministic + /// cleanup on normal completion — see [`Drop`] for the crash-safety + /// net). + /// + /// # Errors + /// Returns [`VssRequestError`] if `DeleteSnapshots` fails. + #[expect( + unsafe_code, + reason = "calls into the native VSS shim; see the inline SAFETY comment" + )] + pub(crate) fn delete_snapshot_set(&mut self) -> Result<(), VssRequestError> { + let mut error = ffi::VssError::zeroed(); + // SAFETY: `self.raw` is a valid session handle for `self`'s + // entire lifetime (released exactly once, in `Drop`); `error` is + // a stack-owned out-parameter. + let hresult = unsafe { ffi::uffs_vss_delete_snapshot_set(self.raw, &raw mut error) }; + if hresult < 0_i32 { + return Err(take_error(&mut error)); + } + Ok(()) + } +} + +impl Drop for VssSnapshotSession { + #[expect( + unsafe_code, + reason = "releases the native VSS shim session; see the inline SAFETY comment" + )] + fn drop(&mut self) { + // SAFETY: `self.raw` was produced by `create` and is released + // exactly once here. If the snapshot set was never explicitly + // deleted above, this is where `VSS_CTX_FILE_SHARE_BACKUP`'s + // auto-release semantics actually remove it — the crash-safety + // net this whole design relies on. + unsafe { + ffi::uffs_vss_session_release(self.raw); + } + } +} + +/// Convert a populated `VssError` out-parameter into an owned +/// [`VssRequestError`], freeing the shim-allocated message string. +fn take_error(error: &mut ffi::VssError) -> VssRequestError { + let message = read_optional_wide(error.message).unwrap_or_else(|| "(no message)".to_owned()); + let request_error = VssRequestError { + stage: error.stage, + hresult: error.hresult, + message, + }; + free_error(error); + request_error +} + +/// Free `info`'s shim-allocated string fields. +#[expect( + unsafe_code, + reason = "frees shim-allocated memory; see the inline SAFETY comment" +)] +fn free_snapshot_info(info: &mut ffi::SnapshotInfo) { + // SAFETY: `info`'s string fields (if any) were allocated by the shim + // and must be released through its own free function. + unsafe { ffi::uffs_vss_snapshot_info_free(info) } +} + +/// Free `error`'s shim-allocated message string. +#[expect( + unsafe_code, + reason = "frees shim-allocated memory; see the inline SAFETY comment" +)] +fn free_error(error: &mut ffi::VssError) { + // SAFETY: `error.message`, if non-null, was allocated by the shim. + unsafe { ffi::uffs_vss_error_free(error) } +} + +/// Read a NUL-terminated UTF-16 string the shim allocated, or `None` if +/// `ptr` is null. Does not free `ptr` — the caller is responsible for +/// that via the matching `*_free` function. +#[expect( + unsafe_code, + reason = "reads a shim-allocated string; see the inline SAFETY comments" +)] +fn read_optional_wide(ptr: *mut u16) -> Option { + if ptr.is_null() { + return None; + } + + let mut length = 0_usize; + loop { + // SAFETY: `ptr` is non-null and points to a NUL-terminated + // UTF-16 buffer per the shim's contract; `length` stays in + // bounds because the loop stops at the first NUL unit. + let unit_ptr = unsafe { ptr.add(length) }; + // SAFETY: `unit_ptr` was just computed as an in-bounds offset + // from a valid buffer, established immediately above. + let unit = unsafe { *unit_ptr }; + if unit == 0 { + break; + } + length += 1; + } + + // SAFETY: `ptr` is non-null and valid for `length` `u16` elements — + // established by the loop above stopping at the first NUL unit. + let slice = unsafe { core::slice::from_raw_parts(ptr, length) }; + Some(String::from_utf16_lossy(slice)) +} From 4952993e873a56e195764e6b919910b797cefb02 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:53:19 -0700 Subject: [PATCH 16/98] feat(broker): wire uffs-vss-requestor into the Broker's Snapshot Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Coordinator-facing Snapshot Manager pipe server (broker/snapshot_manager/mod.rs): a separate named pipe from the daemon's MFT-handle channel, verifying the connected client is uffs-content before dispatching CreateSnapshotLease/ DuplicateSnapshotHandle/RenewSnapshotLease/ReleaseSnapshotLease/ QuerySnapshotLease requests to the lease manager, and handling DuplicateSnapshotHandle's separate reader-identity check + DuplicateHandle call for the approved uffs-content-reader process. Spawned from serve_pipe_requests so it runs under both --run and the SCM-dispatched service path. Adds WindowsVssProvider (broker/snapshot_manager/vss_helper.rs): the real VssProvider backend, spawning one uffs-vss-requestor helper process per snapshot, suspended, assigned to a JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE Job Object before it ever runs, then resumed — so a dead Broker's handle-table teardown kills every live helper automatically. Drives each helper over a private JSON-lines control pipe (a deliberate, documented duplicate of uffs-vss-requestor::protocol, since that crate has no library target and the protocol is tiny/internal-only). list_existing_snapshots is now a no-op: VSS_CTX_FILE_SHARE_BACKUP is auto-release, so there is nothing to reconcile at startup — the OS itself cleans up a crashed Broker's helpers. Compile- and link-verified end to end via cargo xwin build/clippy at lint-prod/lint-tests/lint-ci strictness, producing a real linked uffs-broker.exe. --- Cargo.lock | 2 + Cargo.toml | 7 +- crates/uffs-broker/Cargo.toml | 6 + crates/uffs-broker/src/broker.rs | 17 + .../src/broker/snapshot_manager/mod.rs | 587 ++++++++++++++++++ .../src/broker/snapshot_manager/vss_helper.rs | 519 ++++++++++++++++ crates/uffs-broker/src/snapshot_lease.rs | 8 +- 7 files changed, 1136 insertions(+), 10 deletions(-) create mode 100644 crates/uffs-broker/src/broker/snapshot_manager/mod.rs create mode 100644 crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs diff --git a/Cargo.lock b/Cargo.lock index f61e8146a..a8d8971f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4399,6 +4399,8 @@ name = "uffs-broker" version = "0.6.27" dependencies = [ "anyhow", + "serde", + "serde_json", "thiserror 2.0.18", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 74c67d9bf..d2db2b6ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -220,13 +220,14 @@ windows = { version = "0.62.2", features = [ "Win32_Security_WinTrust", "Win32_Storage", "Win32_Storage_FileSystem", - # VSS requestor COM interfaces (`IVssBackupComponents`, `IVssAsync`) for - # `uffs-broker`'s Snapshot Manager (`broker/snapshot_manager/vss.rs`). - "Win32_Storage_Vss", "Win32_System_Com", "Win32_System_Console", "Win32_System_IO", "Win32_System_Ioctl", + # Job Object kill-on-close (`uffs-broker`'s Snapshot Manager spawns + # `uffs-vss-requestor` per lease, assigned to a Job Object so it's + # killed if the Broker dies — `broker/snapshot_manager/vss_helper.rs`). + "Win32_System_JobObjects", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_ProcessStatus", diff --git a/crates/uffs-broker/Cargo.toml b/crates/uffs-broker/Cargo.toml index c7f2454e2..184e95532 100644 --- a/crates/uffs-broker/Cargo.toml +++ b/crates/uffs-broker/Cargo.toml @@ -82,6 +82,12 @@ uffs-security.workspace = true # Native SCM control for the operator-facing `--status` / `--start` / # `--stop` commands — the same locale-proof primitive the updater uses. uffs-winsvc.workspace = true +# The private Broker<->uffs-vss-requestor control protocol +# (`broker/snapshot_manager/vss_helper.rs`) is JSON-lines — a tiny, +# internal-only wire format that doesn't warrant a hand-rolled binary +# codec the way the public/Coordinator-facing protocols do. +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true # Embeds the UFFS icon + version info + shared app.manifest into # `uffs-broker.exe` (see build.rs). A metadata-less binary is both unbranded diff --git a/crates/uffs-broker/src/broker.rs b/crates/uffs-broker/src/broker.rs index 31ce8eef3..d007a9f02 100644 --- a/crates/uffs-broker/src/broker.rs +++ b/crates/uffs-broker/src/broker.rs @@ -63,6 +63,13 @@ mod pipe; #[cfg(windows)] use pipe::create_broker_pipe; +// Snapshot Manager: the Coordinator-facing VSS-lease API (a separate pipe +// from the daemon's MFT-handle channel above), spawned from +// `serve_pipe_requests` so it runs under both `--run` (foreground) and the +// SCM-dispatched service path. See `broker/snapshot_manager/mod.rs`. +#[path = "broker/snapshot_manager/mod.rs"] +mod snapshot_manager; + /// Per-drive rate-limit state (`drive → last grant time`), shared across the /// FU-5 per-connection worker threads behind a `Mutex`. #[cfg(windows)] @@ -192,6 +199,16 @@ fn serve_pipe_requests() -> anyhow::Result<()> { "Listening for handle requests" ); + // Snapshot Manager (Coordinator-facing VSS-lease API) runs on its own + // pipe, in its own accept loop, on a dedicated thread — independent of + // the MFT-handle serve loop below. A failure here is logged, not fatal + // to the daemon-facing handle service. + std::thread::spawn(|| { + if let Err(err) = snapshot_manager::run() { + tracing::error!(error = %err, "Snapshot Manager stopped unexpectedly"); + } + }); + // S5.4: rate-limit state, shared across per-connection workers. let rate_limit: Arc = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())); diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs new file mode 100644 index 000000000..2e917f9df --- /dev/null +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -0,0 +1,587 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Snapshot Manager: the Broker's VSS-lease API for `uffs-content` (the +//! Content Coordinator), per `uffs-ingest-implementation-plan.md` §4. +//! +//! Serves [`SNAPSHOT_PIPE_NAME`] — a **separate** named pipe from +//! [`uffs_broker_protocol::PIPE_NAME`] (the daemon's MFT-handle channel): +//! Coordinator↔Broker is a distinct channel with a distinct peer and +//! distinct trust check (only `uffs-content` may call it, not `uffsd`). +//! +//! The lease-lifecycle state machine lives in [`crate::snapshot_lease`] +//! (cross-platform, unit-tested against a fake `VssProvider`); the real +//! backend is [`vss_helper::WindowsVssProvider`] (spawns +//! `uffs-vss-requestor` per lease). This module is the remaining +//! Windows-only wiring: pipe creation, per-connection identity +//! verification (reusing this crate's existing +//! `OwnedProcessHandle`/Authenticode machinery), wire framing, and +//! request dispatch. + +mod vss_helper; + +use alloc::sync::Arc; +use core::time::Duration; + +use uffs_broker_protocol::snapshot_manager::{ + CreateSnapshotLeaseResult, DuplicateSnapshotHandle, SNAPSHOT_PIPE_NAME, SnapshotLeaseState, + SnapshotLeaseStatus, SnapshotManagerErrorCode, SnapshotManagerRequest, SnapshotManagerResponse, +}; +use vss_helper::WindowsVssProvider; +use windows::Win32::Foundation::HANDLE; +use windows::core::PCWSTR; + +use super::owned_handle::OwnedHandle; +use super::process_handle::{OwnedProcessHandle, query_process_image_name}; +use crate::snapshot_lease::{LeaseError, SnapshotLeaseManager, VssError}; + +/// Maximum accepted request payload, before allocation — a generous bound +/// for this small, narrow API (no bulk data ever crosses this pipe). +const MAX_REQUEST_BYTES: u32 = 64 * 1024; + +/// How often the background sweep checks for expired leases, for an +/// otherwise-idle manager that would never otherwise notice an expiry +/// (every request-handling path also sweeps opportunistically — see +/// `crate::snapshot_lease::SnapshotLeaseManager::sweep_expired`). +const SWEEP_INTERVAL: Duration = Duration::from_secs(30); + +/// Run the Snapshot Manager: reconcile orphaned snapshots from any +/// previous run, then serve requests until the process exits. +/// +/// Called from `broker::run_foreground` on its own thread, alongside the +/// existing MFT-handle pipe server. +/// +/// # Errors +/// Returns an error only if the pipe itself cannot be created at all; +/// per-connection failures are logged and never propagate here. +pub(super) fn run() -> anyhow::Result<()> { + let manager = Arc::new(SnapshotLeaseManager::new(WindowsVssProvider::new())); + + let reconciled = manager.reconcile_at_startup().unwrap_or_else(|err| { + tracing::warn!(error = %err, "startup snapshot reconciliation failed to list existing snapshots"); + 0 + }); + if reconciled > 0 { + tracing::info!( + count = reconciled, + "reconciled orphaned VSS snapshots from a previous run" + ); + } + + let sweep_manager = Arc::clone(&manager); + #[expect( + clippy::infinite_loop, + reason = "runs for the Broker's whole lifetime, sweeping expired leases; \ + there is no termination condition short of process exit" + )] + std::thread::spawn(move || { + loop { + std::thread::sleep(SWEEP_INTERVAL); + sweep_manager.sweep_expired(unix_ms_now()); + } + }); + + serve_snapshot_pipe(&manager) +} + +/// Accept loop for [`SNAPSHOT_PIPE_NAME`], mirroring the shape of +/// `broker::serve_pipe_requests` (separate pipe, separate instances, one +/// worker thread per connection). +fn serve_snapshot_pipe( + manager: &Arc>, +) -> anyhow::Result<()> { + tracing::info!( + pipe = SNAPSHOT_PIPE_NAME, + "Listening for Snapshot Manager requests" + ); + let mut first_instance = true; + + loop { + if super::service::stop_requested() { + return Ok(()); + } + + let pipe = match create_snapshot_pipe(first_instance) { + Ok(pipe) => { + first_instance = false; + pipe + } + Err(err) => { + tracing::warn!(error = %err, "snapshot pipe instance unavailable; retrying shortly"); + std::thread::sleep(Duration::from_millis(100)); + continue; + } + }; + if let Err(err) = super::wait_for_client(pipe) { + tracing::warn!(error = %err, "wait_for_client (snapshot pipe) failed; dropping instance"); + super::disconnect_pipe(pipe); + super::close_pipe(pipe); + continue; + } + if super::service::stop_requested() { + super::disconnect_pipe(pipe); + super::close_pipe(pipe); + return Ok(()); + } + + let owned = OwnedHandle::new(pipe); + let worker_manager = Arc::clone(manager); + std::thread::spawn(move || { + handle_connection(owned.raw(), &worker_manager); + super::disconnect_pipe(owned.raw()); + }); + } +} + +/// Handle one connected client: verify it is the Content Coordinator +/// (`uffs-content`), then read, dispatch, and respond to exactly one +/// framed request. +fn handle_connection(pipe: HANDLE, manager: &SnapshotLeaseManager) { + let Some(pid) = super::get_pipe_client_pid(pipe) else { + tracing::warn!("snapshot pipe: could not determine client PID — rejecting"); + return; + }; + let Some(client_process) = OwnedProcessHandle::open_client(pid) else { + tracing::warn!( + pid, + "snapshot pipe: could not open client process — rejecting" + ); + return; + }; + let exe_path = query_process_image_name(client_process.raw()); + if !verify_coordinator_identity(exe_path.as_deref()) { + tracing::warn!(pid, "snapshot pipe: rejected client — not uffs-content"); + return; + } + drop(client_process); + + let request_bytes = match read_framed_message(pipe) { + Ok(bytes) => bytes, + Err(err) => { + tracing::debug!(error = %err, "snapshot pipe: failed to read request"); + return; + } + }; + let response = match SnapshotManagerRequest::decode(&request_bytes) { + Ok(request) => dispatch_request(request, manager), + Err(decode_err) => SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::InternalError, + message: format!("malformed request: {decode_err}"), + }, + }; + if let Err(err) = write_framed_message(pipe, &response.encode()) { + tracing::debug!(error = %err, "snapshot pipe: failed to write response"); + } +} + +/// Dispatch one decoded request against `manager`, returning the wire +/// response. +fn dispatch_request( + wire_request: SnapshotManagerRequest, + manager: &SnapshotLeaseManager, +) -> SnapshotManagerResponse { + let now = unix_ms_now(); + match wire_request { + SnapshotManagerRequest::Create(request) => { + match manager.create_lease( + &request.source_volume_identity, + &request.requested_root, + request.maximum_lifetime_secs, + now, + ) { + Ok(created) => SnapshotManagerResponse::Created(CreateSnapshotLeaseResult { + snapshot_lease_id: created.lease_id, + snapshot_id: created.snapshot_id, + snapshot_device_identity: created.device_identity, + snapshot_created_at_unix_ms: created.created_at_unix_ms, + expires_at_unix_ms: created.expires_at_unix_ms, + }), + Err(err) => lease_error_response(&err), + } + } + SnapshotManagerRequest::Duplicate(request) => handle_duplicate(&request, manager), + SnapshotManagerRequest::Renew(request) => match manager.renew_lease( + request.snapshot_lease_id, + request.requested_expiry_unix_ms, + now, + ) { + Ok(new_expires_at_unix_ms) => SnapshotManagerResponse::Renewed { + new_expires_at_unix_ms, + }, + Err(err) => lease_error_response(&err), + }, + SnapshotManagerRequest::Release(request) => { + match manager.release_lease(request.snapshot_lease_id) { + Ok(()) => SnapshotManagerResponse::Released, + Err(err) => lease_error_response(&err), + } + } + SnapshotManagerRequest::Query(request) => { + let status = manager + .query_lease(request.snapshot_lease_id, now) + .map_or_else( + || SnapshotLeaseStatus { + snapshot_lease_id: request.snapshot_lease_id, + state: SnapshotLeaseState::Unknown, + snapshot_id: Vec::new(), + created_at_unix_ms: 0, + expires_at_unix_ms: 0, + }, + |status| SnapshotLeaseStatus { + snapshot_lease_id: request.snapshot_lease_id, + state: status.state, + snapshot_id: status.snapshot_id, + created_at_unix_ms: status.created_at_unix_ms, + expires_at_unix_ms: status.expires_at_unix_ms, + }, + ); + SnapshotManagerResponse::Status(status) + } + } +} + +/// Handle `DuplicateSnapshotHandle`: verify the *named* reader process +/// (not the connected Coordinator) is a legitimate `uffs-content-reader`, +/// then open the lease's snapshot device and duplicate a read-only +/// handle into it. +/// +/// **Open item**: the wire response (`SnapshotManagerResponse::Duplicated`) +/// carries no handle value — `DuplicateHandle`'s result is only +/// meaningful inside the target process's own handle table. How the +/// Reader itself learns *which* handle value to use is a UFI.2 decision +/// (`uffs-content-reader` doesn't exist yet); this function performs the +/// real duplication and trusts that mechanism to be settled before the +/// Reader is built. +fn handle_duplicate( + request: &DuplicateSnapshotHandle, + manager: &SnapshotLeaseManager, +) -> SnapshotManagerResponse { + let now = unix_ms_now(); + let Some(device_identity) = manager.device_identity_if_active(request.snapshot_lease_id, now) + else { + return lease_error_response(&LeaseError::NotFound); + }; + + let Some(reader_process) = OwnedProcessHandle::open_client(request.approved_reader_process_id) + else { + return SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::ReaderIdentityRejected, + message: "could not open the approved reader process".to_owned(), + }; + }; + let reader_exe = query_process_image_name(reader_process.raw()); + if !verify_reader_identity(reader_exe.as_deref()) { + return SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::ReaderIdentityRejected, + message: "reader process failed identity verification".to_owned(), + }; + } + + match duplicate_snapshot_device_to_reader(&device_identity, &reader_process) { + Ok(()) => SnapshotManagerResponse::Duplicated, + Err(err) => SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::InternalError, + message: err.to_string(), + }, + } +} + +/// Open `device_identity` read-only and duplicate the handle into +/// `reader_process` — the same open+duplicate shape as +/// `broker::open_volume_read_only` / +/// `broker::duplicate_volume_handle_to_client`, generalized to an arbitrary +/// snapshot device path instead of a bare drive letter. +#[expect(unsafe_code, reason = "CreateFileW + CloseHandle are FFI calls")] +fn duplicate_snapshot_device_to_reader( + device_identity: &str, + reader_process: &OwnedProcessHandle, +) -> anyhow::Result<()> { + use std::os::windows::ffi::OsStrExt as _; + + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_FLAG_SEQUENTIAL_SCAN, + FILE_GENERIC_READ, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + + let wide_path: Vec = std::ffi::OsStr::new(device_identity) + .encode_wide() + .chain(Some(0)) + .collect(); + // SAFETY: `wide_path` is a NUL-terminated UTF-16 buffer owned for the + // duration of this call; every other argument is a plain integer or + // `None`. Mirrors `broker::open_volume_read_only`'s flags exactly — + // `FILE_FLAG_OVERLAPPED` because the Reader performs overlapped/IOCP + // reads on the vended handle, matching `uffs-mft::VolumeHandle`. + let device_handle = unsafe { + CreateFileW( + PCWSTR(wide_path.as_ptr()), + FILE_GENERIC_READ.0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED | FILE_FLAG_SEQUENTIAL_SCAN, + None, + ) + } + .map_err(|err| anyhow::anyhow!("CreateFileW failed for {device_identity}: {err}"))?; + + let dup_result = super::duplicate_volume_handle_to_client(device_handle, reader_process); + + // SAFETY: `device_handle` came from `CreateFileW` above; our copy is + // closed regardless of whether the duplicate succeeded. + if let Err(close_err) = unsafe { CloseHandle(device_handle) } { + tracing::debug!(err = ?close_err, "CloseHandle(device_handle) failed after dup"); + } + + dup_result.map(|_client_handle| ()) +} + +/// Map a [`LeaseError`] to the wire error response. +fn lease_error_response(err: &LeaseError) -> SnapshotManagerResponse { + let code = match err { + LeaseError::NotFound => SnapshotManagerErrorCode::LeaseNotFound, + LeaseError::NotActive => SnapshotManagerErrorCode::LeaseNotActive, + LeaseError::Vss(VssError::InvalidVolume(_)) => { + SnapshotManagerErrorCode::VolumeValidationFailed + } + LeaseError::Vss(VssError::CreateFailed(_)) => { + SnapshotManagerErrorCode::SnapshotCreateFailed + } + LeaseError::Vss(VssError::DeleteFailed(_)) => SnapshotManagerErrorCode::InternalError, + }; + SnapshotManagerResponse::Error { + code, + message: err.to_string(), + } +} + +/// Whether `exe_path`'s file name matches the Content Coordinator binary. +fn is_uffs_content_image(exe_path: &std::ffi::OsStr) -> bool { + let name = std::path::Path::new(exe_path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(""); + name == "uffs-content" || name == "uffs-content.exe" || name.starts_with("uffs-content") +} + +/// Whether `exe_path`'s file name matches the Snapshot Reader binary. +fn is_uffs_content_reader_image(exe_path: &std::ffi::OsStr) -> bool { + let name = std::path::Path::new(exe_path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(""); + name == "uffs-content-reader" + || name == "uffs-content-reader.exe" + || name.starts_with("uffs-content-reader") +} + +/// Verify the connected pipe client is a legitimate `uffs-content` +/// (image name allow-list + Authenticode), mirroring +/// `broker::check_client_identity`'s two checks but against a different +/// name allow-list and without the drive-specific audit logging. +fn verify_coordinator_identity(coordinator_exe_path: Option<&std::ffi::OsStr>) -> bool { + let Some(exe_path) = coordinator_exe_path else { + return false; + }; + if !is_uffs_content_image(exe_path) { + return false; + } + exe_path + .to_str() + .is_some_and(uffs_security::authenticode::verify_authenticode) +} + +/// Verify the named reader process is a legitimate `uffs-content-reader` +/// (image name allow-list + Authenticode). +fn verify_reader_identity(reader_exe_path: Option<&std::ffi::OsStr>) -> bool { + let Some(exe_path) = reader_exe_path else { + return false; + }; + if !is_uffs_content_reader_image(exe_path) { + return false; + } + exe_path + .to_str() + .is_some_and(uffs_security::authenticode::verify_authenticode) +} + +/// Read a `u32`-LE-length-prefixed message from the pipe, bounded by +/// [`MAX_REQUEST_BYTES`] before allocating the payload buffer. +fn read_framed_message(pipe: HANDLE) -> anyhow::Result> { + let mut length_bytes = [0_u8; 4]; + read_exact(pipe, &mut length_bytes)?; + let length = u32::from_le_bytes(length_bytes); + if length > MAX_REQUEST_BYTES { + anyhow::bail!("request length {length} exceeds maximum {MAX_REQUEST_BYTES}"); + } + + let mut payload = vec![0_u8; usize::try_from(length).unwrap_or(0)]; + read_exact(pipe, &mut payload)?; + Ok(payload) +} + +/// Read exactly `buf.len()` bytes from the pipe. +#[expect(unsafe_code, reason = "ReadFile is an FFI call")] +fn read_exact(pipe: HANDLE, buf: &mut [u8]) -> anyhow::Result<()> { + use windows::Win32::Storage::FileSystem::ReadFile; + + let mut bytes_read = 0_u32; + // SAFETY: `pipe` is a valid open pipe HANDLE; `buf` is a caller-owned + // mutable slice; `bytes_read` is a stack-owned u32 accessed exclusively. + let result = unsafe { ReadFile(pipe, Some(buf), Some(&raw mut bytes_read), None) }; + if let Err(win_err) = result { + anyhow::bail!("ReadFile failed: {win_err}"); + } + if (bytes_read as usize) < buf.len() { + anyhow::bail!("short read: got {bytes_read}, expected {}", buf.len()); + } + Ok(()) +} + +/// Write a `u32`-LE-length-prefixed message to the pipe. +#[expect(unsafe_code, reason = "WriteFile is an FFI call")] +fn write_framed_message(pipe: HANDLE, payload: &[u8]) -> anyhow::Result<()> { + use windows::Win32::Storage::FileSystem::WriteFile; + + let length = u32::try_from(payload.len()).unwrap_or(u32::MAX); + let mut framed = Vec::with_capacity(payload.len() + 4); + framed.extend_from_slice(&length.to_le_bytes()); + framed.extend_from_slice(payload); + + let mut bytes_written = 0_u32; + // SAFETY: `pipe` is a valid open pipe HANDLE; `framed` is a locally + // owned buffer; `bytes_written` is a stack-owned u32. + let result = unsafe { WriteFile(pipe, Some(&framed), Some(&raw mut bytes_written), None) }; + if let Err(win_err) = result { + anyhow::bail!("WriteFile failed: {win_err}"); + } + Ok(()) +} + +/// Create a Snapshot Manager named-pipe instance, reusing the exact same +/// SDDL trust model as `broker::pipe::create_broker_pipe`: Authenticated +/// Users may connect (identity is checked at the app layer per +/// connection), with a low mandatory-integrity label so the non-elevated +/// Coordinator can open it despite the elevated/SYSTEM Broker creating it. +#[expect(unsafe_code, reason = "CreateNamedPipeW is an FFI call")] +fn create_snapshot_pipe(first_instance: bool) -> anyhow::Result { + use std::os::windows::ffi::OsStrExt as _; + + use windows::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; + use windows::Win32::Storage::FileSystem::{FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX}; + use windows::Win32::System::Pipes::{ + CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, + }; + + let pipe_name: Vec = std::ffi::OsStr::new(SNAPSHOT_PIPE_NAME) + .encode_wide() + .chain(Some(0)) + .collect(); + + let sddl: Vec = "D:(A;;GRGW;;;AU)S:(ML;;NW;;;LW)" + .encode_utf16() + .chain(Some(0)) + .collect(); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + // SAFETY: `sddl` is a NUL-terminated UTF-16 string valid for the call; + // `descriptor` is a valid out-pointer receiving a `LocalAlloc`-ed + // descriptor, freed below regardless of the outcome past this point. + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl.as_ptr()), + SDDL_REVISION_1, + &raw mut descriptor, + None, + ) + } + .map_err(|err| anyhow::anyhow!("failed to build snapshot pipe security descriptor: {err}"))?; + + let sa = SECURITY_ATTRIBUTES { + nLength: u32::try_from(size_of::()).unwrap_or(0), + lpSecurityDescriptor: descriptor.0, + bInheritHandle: false.into(), + }; + let open_mode = if first_instance { + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE + } else { + PIPE_ACCESS_DUPLEX + }; + + // SAFETY: `pipe_name` is a NUL-terminated UTF-16 buffer and `sa` (with + // its security descriptor) both live until after this call returns; + // the pipe copies the descriptor, so it may be freed afterwards. + let handle = unsafe { + CreateNamedPipeW( + PCWSTR(pipe_name.as_ptr()), + open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + super::MAX_PIPE_INSTANCES, + 8192, + 8192, + 0, + Some(&raw const sa), + ) + }; + + if !descriptor.0.is_null() { + // SAFETY: `descriptor.0` was allocated by + // `ConvertStringSecurityDescriptorToSecurityDescriptorW` via + // `LocalAlloc`; freeing it once here is the documented contract + // (the pipe already copied what it needed from `sa`). + _ = unsafe { + windows::Win32::Foundation::LocalFree(Some(windows::Win32::Foundation::HLOCAL( + descriptor.0, + ))) + }; + } + + if handle.is_invalid() { + anyhow::bail!( + "CreateNamedPipeW (snapshot pipe) failed: {}", + std::io::Error::last_os_error() + ); + } + Ok(handle) +} + +/// Current wall-clock time, Unix milliseconds, saturating to `0` if the +/// clock is somehow set before the epoch. +fn unix_ms_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) + }) +} + +#[cfg(test)] +mod tests { + use super::{is_uffs_content_image, is_uffs_content_reader_image}; + + #[test] + fn recognizes_coordinator_image_names() { + assert!(is_uffs_content_image(std::ffi::OsStr::new( + r"C:\uffs\uffs-content.exe" + ))); + assert!(is_uffs_content_image(std::ffi::OsStr::new( + "/usr/local/bin/uffs-content" + ))); + assert!(!is_uffs_content_image(std::ffi::OsStr::new( + r"C:\uffs\uffs-content-reader.exe" + ))); + } + + #[test] + fn recognizes_reader_image_names() { + assert!(is_uffs_content_reader_image(std::ffi::OsStr::new( + r"C:\uffs\uffs-content-reader.exe" + ))); + assert!(!is_uffs_content_reader_image(std::ffi::OsStr::new( + r"C:\uffs\uffs-content.exe" + ))); + } +} diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs new file mode 100644 index 000000000..decfedee4 --- /dev/null +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! [`crate::snapshot_lease::VssProvider`] implementation backed by +//! `uffs-vss-requestor`: spawns one helper process per snapshot, keeps +//! it alive (assigned to a `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` Job +//! Object) for the lease's entire lifetime, and drives it over a private +//! JSON-lines control pipe. See +//! `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md` +//! for the full design. +//! +//! The wire shape here (`HelperEvent`/`BrokerCommand`) is a deliberate, +//! documented duplicate of `uffs-vss-requestor::protocol` — that crate +//! is bin-only (no library target) and this protocol is tiny, private, +//! and owned end-to-end by this same Broker↔helper pairing, so a +//! dedicated shared protocol crate (the pattern this workspace otherwise +//! uses for every cross-process wire boundary) would be overhead without +//! benefit here. Keep the two definitions in sync by hand if either side +//! changes. + +use core::sync::atomic::{AtomicU64, Ordering}; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead as _, BufReader, Write as _}; +use std::os::windows::ffi::OsStrExt as _; +use std::os::windows::io::FromRawHandle as _; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use uffs_broker_protocol::snapshot_manager::VolumeIdentity; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Storage::FileSystem::{FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX}; +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_TYPE_BYTE, PIPE_WAIT, +}; +use windows::Win32::System::Threading::{ + CREATE_SUSPENDED, CreateProcessW, PROCESS_INFORMATION, ResumeThread, STARTUPINFOW, +}; +use windows::core::PCWSTR; + +use crate::snapshot_lease::{SnapshotHandle, VssError, VssProvider}; + +/// Mirrors `uffs_vss_requestor::protocol::HelperEvent` — see this +/// module's doc comment. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +enum HelperEvent { + /// The snapshot was created. + Ready { + /// This specific snapshot's GUID, canonical `{...}` string form. + snapshot_id: String, + /// The snapshot's device path, if the helper reported one. + snapshot_device_object: Option, + }, + /// The snapshot set was explicitly deleted. + Released, + /// A VSS requestor operation failed. + Failed { + /// Which step of the requestor sequence failed. + stage: i32, + /// The failing `HRESULT`. + hresult: i32, + /// Human-readable diagnostic message. + message: String, + }, + /// Reply to [`BrokerCommand::Ping`]. + Pong, +} + +/// Mirrors `uffs_vss_requestor::protocol::BrokerCommand` — see this +/// module's doc comment. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type")] +enum BrokerCommand { + /// Delete the snapshot set and exit. + Release, +} + +/// One live helper-process session. +struct HelperSession { + /// Reader half of the control pipe (a clone of `writer`'s handle). + reader: BufReader, + /// Writer half of the control pipe. + writer: File, + /// The helper process, kept open so the Job Object's kill-on-close + /// net stays armed until this session is dropped. + process_handle: HANDLE, + /// The Job Object the helper was assigned to. + job_handle: HANDLE, +} + +#[expect( + unsafe_code, + reason = "kernel HANDLEs have no thread affinity, so moving them between \ + threads is sound; `File`/`BufReader` are themselves already \ + Send, so this only concerns the two raw HANDLEs" +)] +// SAFETY: `process_handle` and `job_handle` are process-wide kernel object +// handles with no thread affinity — moving a `HelperSession` between threads +// (e.g. into the `SnapshotLeaseManager`'s session map, itself behind a +// `Mutex`) is sound. Concurrent *use* of the same handle would still need +// external synchronization, exactly as with the raw Win32 API; `Send` only +// makes the *move* type-safe. +unsafe impl Send for HelperSession {} + +impl Drop for HelperSession { + #[expect( + unsafe_code, + reason = "CloseHandle is an FFI call; see the inline SAFETY comment" + )] + fn drop(&mut self) { + // SAFETY: both handles were opened by `spawn_helper` and are + // closed exactly once here. If the helper already exited + // gracefully (the normal `Release` path), this is inert; if it + // is still alive for any other reason, closing the job's last + // handle triggers `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. + let _job_close_result = unsafe { CloseHandle(self.job_handle) }; + // SAFETY: see above. + let _process_close_result = unsafe { CloseHandle(self.process_handle) }; + } +} + +/// Cleanup guard for a spawned-but-not-yet-adopted helper: closes the +/// process and Job Object handles (killing the helper via +/// kill-on-close) unless [`PendingSpawn::into_handles`] transfers +/// ownership to a [`HelperSession`] first. +struct PendingSpawn { + /// The helper process, not yet confirmed ready. + process_handle: HANDLE, + /// The Job Object the helper was assigned to. + job_handle: HANDLE, +} + +impl PendingSpawn { + /// Disarm cleanup and take ownership of the raw handles. + fn into_handles(self) -> (HANDLE, HANDLE) { + let this = core::mem::ManuallyDrop::new(self); + (this.process_handle, this.job_handle) + } +} + +impl Drop for PendingSpawn { + #[expect( + unsafe_code, + reason = "CloseHandle is an FFI call; see the inline SAFETY comment" + )] + fn drop(&mut self) { + // SAFETY: both handles were opened by `spawn_helper`; closing + // the job's last handle here (before the helper ever confirmed + // readiness) kills the abandoned helper process. + let _job_close_result = unsafe { CloseHandle(self.job_handle) }; + // SAFETY: see above. + let _process_close_result = unsafe { CloseHandle(self.process_handle) }; + } +} + +/// [`VssProvider`] backed by per-lease `uffs-vss-requestor` helper +/// processes. +pub(crate) struct WindowsVssProvider { + /// Live sessions, keyed by snapshot ID bytes. + sessions: Mutex, HelperSession>>, + /// Monotonic counter for unique per-lease pipe names. + next_pipe_id: AtomicU64, +} + +impl WindowsVssProvider { + /// Construct an empty provider. + pub(crate) fn new() -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + next_pipe_id: AtomicU64::new(1), + } + } + + /// Lock the session map, recovering from a poisoned mutex. + fn lock_sessions(&self) -> std::sync::MutexGuard<'_, HashMap, HelperSession>> { + self.sessions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl VssProvider for WindowsVssProvider { + #[expect( + unsafe_code, + reason = "wraps a freshly connected pipe HANDLE in a File; see the inline SAFETY comment" + )] + fn create_snapshot( + &self, + _volume: &VolumeIdentity, + requested_root: &[u8], + ) -> Result { + let volume_path = decode_utf16le(requested_root).ok_or_else(|| { + VssError::InvalidVolume("requested_root is not valid UTF-16LE".to_owned()) + })?; + + let pipe_id = self.next_pipe_id.fetch_add(1, Ordering::Relaxed); + let pipe_name = format!(r"\\.\pipe\uffs-vss-requestor-{pipe_id:016x}"); + + let pipe_handle = create_control_pipe(&pipe_name).map_err(|err| { + VssError::CreateFailed(format!("failed to create control pipe: {err}")) + })?; + + let pending = spawn_helper(&pipe_name, &volume_path).map_err(|err| { + VssError::CreateFailed(format!("failed to spawn uffs-vss-requestor: {err}")) + })?; + + if let Err(err) = connect_pipe(pipe_handle) { + close_pipe_handle(pipe_handle); + return Err(VssError::CreateFailed(format!( + "helper did not connect to control pipe: {err}" + ))); + } + + // SAFETY: `pipe_handle` is a valid, connected, exclusively owned + // duplex pipe HANDLE; `File` takes ownership and closes it on drop. + let pipe_file = unsafe { File::from_raw_handle(pipe_handle.0.cast::()) }; + let writer = pipe_file + .try_clone() + .map_err(|err| VssError::CreateFailed(format!("failed to clone pipe handle: {err}")))?; + let mut reader = BufReader::new(pipe_file); + + let event = read_helper_event(&mut reader) + .map_err(|err| VssError::CreateFailed(format!("failed to read helper event: {err}")))? + .ok_or_else(|| { + VssError::CreateFailed( + "helper closed the control pipe before reporting readiness".to_owned(), + ) + })?; + + match event { + HelperEvent::Ready { + snapshot_id, + snapshot_device_object, + } => { + let (process_handle, job_handle) = pending.into_handles(); + let snapshot_id_bytes = snapshot_id.into_bytes(); + let session = HelperSession { + reader, + writer, + process_handle, + job_handle, + }; + self.lock_sessions() + .insert(snapshot_id_bytes.clone(), session); + Ok(SnapshotHandle { + snapshot_id: snapshot_id_bytes, + device_identity: snapshot_device_object.unwrap_or_default(), + }) + } + HelperEvent::Failed { + stage, + hresult, + message, + } => Err(VssError::CreateFailed(format!( + "stage={stage} hresult={hresult:#x}: {message}" + ))), + HelperEvent::Released | HelperEvent::Pong => Err(VssError::CreateFailed( + "unexpected event from helper before Ready".to_owned(), + )), + } + } + + fn delete_snapshot(&self, snapshot_id: &[u8]) -> Result<(), VssError> { + let mut session = self.lock_sessions().remove(snapshot_id).ok_or_else(|| { + VssError::DeleteFailed("no live session for this snapshot".to_owned()) + })?; + + let command = BrokerCommand::Release; + let write_result = serde_json::to_string(&command) + .map_err(|err| VssError::DeleteFailed(format!("failed to encode Release: {err}"))) + .and_then(|line| { + writeln!(session.writer, "{line}") + .and_then(|()| session.writer.flush()) + .map_err(|err| VssError::DeleteFailed(format!("failed to send Release: {err}"))) + }); + write_result?; + + let event = read_helper_event(&mut session.reader).map_err(|err| { + VssError::DeleteFailed(format!("failed to read helper response: {err}")) + })?; + match event { + Some(HelperEvent::Released) | None => Ok(()), + Some(HelperEvent::Failed { + stage, + hresult, + message, + }) => Err(VssError::DeleteFailed(format!( + "stage={stage} hresult={hresult:#x}: {message}" + ))), + Some(HelperEvent::Ready { .. } | HelperEvent::Pong) => Err(VssError::DeleteFailed( + "unexpected event from helper after Release".to_owned(), + )), + } + // `session` drops here regardless of outcome, closing the + // process/job handles. + } + + fn list_existing_snapshots(&self) -> Result>, VssError> { + // `VSS_CTX_FILE_SHARE_BACKUP` is ephemeral and auto-release: a + // snapshot's lifetime is tied to its helper process/Job Object, + // which the OS itself tears down if the Broker dies (closing + // every handle it held, including each Job Object — see + // `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md` + // §6/§7). There is nothing left to reconcile at startup. + Ok(Vec::new()) + } +} + +/// Decode `bytes` as UTF-16LE, or `None` if the length is odd or the +/// units don't form valid UTF-16. +fn decode_utf16le(bytes: &[u8]) -> Option { + if !bytes.len().is_multiple_of(2) { + return None; + } + let (chunks, _remainder) = bytes.as_chunks::<2>(); + let units: Vec = chunks.iter().copied().map(u16::from_le_bytes).collect(); + String::from_utf16(&units).ok() +} + +/// Read one [`HelperEvent`] line, or `Ok(None)` at EOF. +fn read_helper_event(reader: &mut BufReader) -> std::io::Result> { + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line)?; + if bytes_read == 0 { + return Ok(None); + } + let event = serde_json::from_str(line.trim_end()) + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Ok(Some(event)) +} + +/// Close a raw pipe `HANDLE` that was never adopted into a [`File`]. +#[expect(unsafe_code, reason = "CloseHandle is an FFI call")] +fn close_pipe_handle(handle: HANDLE) { + // SAFETY: `handle` was opened by `create_control_pipe` and has not + // been wrapped in a `File` (which would otherwise double-close it). + let _close_result = unsafe { CloseHandle(handle) }; +} + +/// Create the Broker-side control pipe instance for one snapshot lease. +/// +/// Uses default security: the helper runs under the same identity as +/// the Broker (`CreateProcessW` inherits the parent's token unless told +/// otherwise), so no custom SDDL is needed the way the Coordinator- and +/// daemon-facing pipes require. +#[expect(unsafe_code, reason = "CreateNamedPipeW is an FFI call")] +fn create_control_pipe(pipe_name: &str) -> anyhow::Result { + let wide_name: Vec = std::ffi::OsStr::new(pipe_name) + .encode_wide() + .chain(Some(0)) + .collect(); + + // SAFETY: `wide_name` is a NUL-terminated UTF-16 buffer valid for the + // duration of this call; `None` security attributes fall back to the + // creating thread's default DACL, which already permits the + // same-identity helper process to connect. + let handle = unsafe { + CreateNamedPipeW( + PCWSTR(wide_name.as_ptr()), + PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, + 8192, + 8192, + 0, + None, + ) + }; + if handle.is_invalid() { + anyhow::bail!( + "CreateNamedPipeW failed: {}", + std::io::Error::last_os_error() + ); + } + Ok(handle) +} + +/// Block until the helper connects to `pipe_handle`. +#[expect(unsafe_code, reason = "ConnectNamedPipe is an FFI call")] +fn connect_pipe(pipe_handle: HANDLE) -> anyhow::Result<()> { + // SAFETY: `pipe_handle` is a valid, freshly created pipe instance + // HANDLE; `None` requests a synchronous (blocking) connect wait. + let result = unsafe { ConnectNamedPipe(pipe_handle, None) }; + if let Err(win_err) = result { + // ERROR_PIPE_CONNECTED means the client connected before this + // call ran — not an error. + if win_err.code().0 != 535_i32 { + anyhow::bail!("ConnectNamedPipe failed: {win_err}"); + } + } + Ok(()) +} + +/// Locate `uffs-vss-requestor.exe`, assumed to live alongside this +/// Broker binary (the same install directory). +fn helper_exe_path() -> anyhow::Result { + let current_exe = std::env::current_exe() + .map_err(|err| anyhow::anyhow!("failed to resolve current_exe: {err}"))?; + let parent = current_exe + .parent() + .ok_or_else(|| anyhow::anyhow!("current_exe has no parent directory"))?; + Ok(parent.join("uffs-vss-requestor.exe")) +} + +/// Spawn `uffs-vss-requestor.exe`, suspended, assign it to a fresh +/// kill-on-close Job Object, then resume it. +#[expect( + unsafe_code, + reason = "CreateProcessW, CreateJobObjectW, SetInformationJobObject, \ + AssignProcessToJobObject, and ResumeThread are FFI calls" +)] +fn spawn_helper(pipe_name: &str, volume_path: &str) -> anyhow::Result { + let exe_path = helper_exe_path()?; + let parent_pid = std::process::id(); + let mut command_line = build_command_line(&exe_path, pipe_name, volume_path, parent_pid); + + let startup_info = STARTUPINFOW { + cb: u32::try_from(size_of::()).unwrap_or(0), + ..Default::default() + }; + let mut process_information = PROCESS_INFORMATION::default(); + + // SAFETY: `command_line` is a mutable, NUL-terminated UTF-16 buffer + // (required: `CreateProcessW` may write into it); `startup_info` and + // `process_information` are stack-owned and exclusively borrowed for + // the call. + unsafe { + CreateProcessW( + PCWSTR::null(), + Some(windows::core::PWSTR(command_line.as_mut_ptr())), + None, + None, + false, + CREATE_SUSPENDED, + None, + PCWSTR::null(), + &raw const startup_info, + &raw mut process_information, + ) + } + .map_err(|err| anyhow::anyhow!("CreateProcessW failed: {err}"))?; + + let process_handle = process_information.hProcess; + let thread_handle = process_information.hThread; + + // SAFETY: `None` name creates an anonymous Job Object; the returned + // handle is owned by this function until transferred via + // `PendingSpawn`. + let job_handle = unsafe { CreateJobObjectW(None, PCWSTR::null()) } + .map_err(|err| anyhow::anyhow!("CreateJobObjectW failed: {err}"))?; + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `job_handle` is a valid, freshly created Job Object handle; + // `limits` is a stack-owned, correctly sized structure for + // `JobObjectExtendedLimitInformation`. + let set_info_result = unsafe { + SetInformationJobObject( + job_handle, + JobObjectExtendedLimitInformation, + (&raw const limits).cast::(), + u32::try_from(size_of::()).unwrap_or(0), + ) + }; + if let Err(err) = set_info_result { + close_pipe_handle(job_handle); + close_pipe_handle(process_handle); + close_pipe_handle(thread_handle); + anyhow::bail!("SetInformationJobObject failed: {err}"); + } + + // SAFETY: `job_handle` and `process_handle` are both valid; assigning + // a suspended process to the job before it ever runs means it can + // never escape the job's kill-on-close net. + let assign_result = unsafe { AssignProcessToJobObject(job_handle, process_handle) }; + if let Err(err) = assign_result { + close_pipe_handle(job_handle); + close_pipe_handle(process_handle); + close_pipe_handle(thread_handle); + anyhow::bail!("AssignProcessToJobObject failed: {err}"); + } + + // SAFETY: `thread_handle` is the valid main-thread handle from + // `CreateProcessW`, still suspended. + let resume_result = unsafe { ResumeThread(thread_handle) }; + close_pipe_handle(thread_handle); + if resume_result == u32::MAX { + close_pipe_handle(job_handle); + close_pipe_handle(process_handle); + anyhow::bail!("ResumeThread failed: {}", std::io::Error::last_os_error()); + } + + Ok(PendingSpawn { + process_handle, + job_handle, + }) +} + +/// Build the helper's command line: `"" --pipe-name +/// --volume-path "" --parent-pid `, NUL-terminated UTF-16. +fn build_command_line( + exe_path: &Path, + pipe_name: &str, + volume_path: &str, + parent_pid: u32, +) -> Vec { + let command = format!( + "\"{}\" --pipe-name {pipe_name} --volume-path \"{volume_path}\" --parent-pid {parent_pid}", + exe_path.display(), + ); + command.encode_utf16().chain(Some(0)).collect() +} diff --git a/crates/uffs-broker/src/snapshot_lease.rs b/crates/uffs-broker/src/snapshot_lease.rs index 12d6861c1..e4c480630 100644 --- a/crates/uffs-broker/src/snapshot_lease.rs +++ b/crates/uffs-broker/src/snapshot_lease.rs @@ -60,19 +60,13 @@ pub(crate) struct SnapshotHandle { pub(crate) enum VssError { /// The requested volume could not be validated. #[error("volume validation failed: {0}")] - VolumeValidationFailed(String), + InvalidVolume(String), /// Snapshot creation failed. #[error("snapshot creation failed: {0}")] CreateFailed(String), /// Snapshot deletion failed. #[error("snapshot deletion failed: {0}")] DeleteFailed(String), - /// Copy-on-write storage pressure prevents further retention. - #[error("copy-on-write storage exhausted: {0}")] - StorageExhausted(String), - /// Enumerating existing snapshots (for startup reconciliation) failed. - #[error("enumerating existing snapshots failed: {0}")] - EnumerationFailed(String), } /// A real or fake VSS snapshot creation/deletion/enumeration backend. From c9104ff06da85d70573bfdeaccd7a04866d4b5a8 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:30:17 -0700 Subject: [PATCH 17/98] test(broker): add real elevated end-to-end VSS snapshot round-trip test Adds an #[ignore]d integration test exercising the whole pipeline built so far (native shim -> uffs-vss-requestor helper -> Broker lease manager -> Job Object cleanup) at runtime for the first time, rather than only compile/link-verified. Fixes two real compile errors this uncovered on the actual Windows target: file_identity()'s unstable file_index() had no feature-gate, and handle_connection tripped clippy's cognitive-complexity limit. Co-Authored-By: Claude Sonnet 5 --- .../src/broker/snapshot_manager/mod.rs | 29 +++-- .../src/broker/snapshot_manager/vss_helper.rs | 120 +++++++++++++++++- crates/uffs-content/src/lib.rs | 10 ++ 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 2e917f9df..71dd3e489 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -133,28 +133,31 @@ fn serve_snapshot_pipe( } } -/// Handle one connected client: verify it is the Content Coordinator -/// (`uffs-content`), then read, dispatch, and respond to exactly one -/// framed request. -fn handle_connection(pipe: HANDLE, manager: &SnapshotLeaseManager) { +/// Verify the client connected to `pipe` is the Content Coordinator +/// (`uffs-content`), logging and returning `false` if not. +fn verify_connected_coordinator(pipe: HANDLE) -> bool { let Some(pid) = super::get_pipe_client_pid(pipe) else { tracing::warn!("snapshot pipe: could not determine client PID — rejecting"); - return; + return false; }; let Some(client_process) = OwnedProcessHandle::open_client(pid) else { tracing::warn!( pid, "snapshot pipe: could not open client process — rejecting" ); - return; + return false; }; let exe_path = query_process_image_name(client_process.raw()); if !verify_coordinator_identity(exe_path.as_deref()) { tracing::warn!(pid, "snapshot pipe: rejected client — not uffs-content"); - return; + return false; } - drop(client_process); + true +} +/// Read one framed request from `pipe`, dispatch it against `manager`, +/// and write the framed response back. +fn handle_one_request(pipe: HANDLE, manager: &SnapshotLeaseManager) { let request_bytes = match read_framed_message(pipe) { Ok(bytes) => bytes, Err(err) => { @@ -174,6 +177,16 @@ fn handle_connection(pipe: HANDLE, manager: &SnapshotLeaseManager) { + if !verify_connected_coordinator(pipe) { + return; + } + handle_one_request(pipe, manager); +} + /// Dispatch one decoded request against `manager`, returning the wire /// response. fn dispatch_request( diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index decfedee4..aaac35411 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -398,15 +398,38 @@ fn connect_pipe(pipe_handle: HANDLE) -> anyhow::Result<()> { Ok(()) } -/// Locate `uffs-vss-requestor.exe`, assumed to live alongside this -/// Broker binary (the same install directory). +/// Locate `uffs-vss-requestor.exe`. +/// +/// In production this lives alongside the Broker binary (same install +/// directory) — `current_exe()`'s parent. `uffs-vss-requestor` is a +/// bin-only crate (Cargo refuses to add it as a dependency of any kind: +/// "ignoring invalid dependency ... missing a lib target", the same +/// restriction the root `Cargo.toml` documents for why bin-only crates +/// get no workspace-dependency alias), so there is no +/// `CARGO_BIN_EXE_*` env var to fall back on under `cargo test` either. +/// Test binaries run one directory deeper than production binaries +/// (`target///deps/`, not +/// `target///`), so also check the parent's parent — +/// where `cargo build -p uffs-vss-requestor` actually places the `.exe` +/// — before giving up and returning the production guess for the caller +/// to fail against with a clear `CreateProcessW` error. fn helper_exe_path() -> anyhow::Result { let current_exe = std::env::current_exe() .map_err(|err| anyhow::anyhow!("failed to resolve current_exe: {err}"))?; let parent = current_exe .parent() .ok_or_else(|| anyhow::anyhow!("current_exe has no parent directory"))?; - Ok(parent.join("uffs-vss-requestor.exe")) + let sibling = parent.join("uffs-vss-requestor.exe"); + if sibling.is_file() { + return Ok(sibling); + } + if let Some(profile_dir) = parent.parent() { + let candidate = profile_dir.join("uffs-vss-requestor.exe"); + if candidate.is_file() { + return Ok(candidate); + } + } + Ok(sibling) } /// Spawn `uffs-vss-requestor.exe`, suspended, assign it to a fresh @@ -517,3 +540,94 @@ fn build_command_line( ); command.encode_utf16().chain(Some(0)).collect() } + +#[cfg(test)] +mod tests { + use uffs_broker_protocol::snapshot_manager::VolumeIdentity; + + use super::WindowsVssProvider; + use crate::snapshot_lease::VssProvider as _; + + /// Encode `path` as the lossless UTF-16LE `requested_root` wire + /// format [`WindowsVssProvider::create_snapshot`] expects. + fn utf16le_bytes(path: &str) -> Vec { + path.encode_utf16().flat_map(u16::to_le_bytes).collect() + } + + /// Real, runnable, elevated end-to-end proof that the whole VSS + /// pipeline (native shim → `uffs-vss-requestor` helper process → + /// Broker lease/session bookkeeping → Job Object cleanup) actually + /// works at runtime, not just compiles and links. Everything built + /// for the Snapshot Manager across Phases 4-6 of + /// `uffs-ingest-implementation-plan.md` had, until this test, never + /// been executed. + /// + /// Requires a real Windows host, Administrator elevation (creating a + /// `VSS_CTX_FILE_SHARE_BACKUP` snapshot needs it, and reading a + /// shadow-copy device path back needs it too), and + /// `uffs-vss-requestor.exe` already built in the same profile + /// directory `super::helper_exe_path` searches (it cannot be a + /// Cargo dependency of any kind — see that function's doc comment — + /// so nothing builds it automatically here): run + /// `cargo build -p uffs-vss-requestor` once, then run this test + /// elevated with `cargo test -p uffs-broker -- --ignored`. + #[test] + #[ignore = "requires a real Windows host, Administrator elevation, and live VSS"] + fn create_read_delete_snapshot_round_trip() { + let temp_dir = std::env::temp_dir(); + // `Path::ancestors()` walks from the path itself up to the root, + // so the last ancestor is the drive root (e.g. `C:\`) — exactly + // the form `IVssBackupComponents::AddToSnapshotSet` requires. + let drive_root = temp_dir + .ancestors() + .last() + .expect("temp_dir has at least one ancestor") + .to_path_buf(); + + let marker_name = format!( + "uffs-vss-e2e-{}.txt", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_nanos() + ); + let marker_path = temp_dir.join(&marker_name); + let marker_content = b"uffs vss round-trip marker"; + std::fs::write(&marker_path, marker_content).expect("failed to write marker file"); + + let provider = WindowsVssProvider::new(); + let volume = VolumeIdentity { + volume_serial: 0, + volume_guid: Vec::new(), + }; + let requested_root = utf16le_bytes(&drive_root.to_string_lossy()); + + let handle = provider + .create_snapshot(&volume, &requested_root) + .expect("create_snapshot failed"); + assert!( + !handle.device_identity.is_empty(), + "helper reported no snapshot device path" + ); + + let relative_path = marker_path + .strip_prefix(&drive_root) + .expect("marker_path is under drive_root"); + let snapshot_path = std::path::Path::new(&handle.device_identity).join(relative_path); + + let read_back = std::fs::read(&snapshot_path) + .expect("failed to read marker file back from the snapshot device path"); + assert_eq!(read_back, marker_content); + + provider + .delete_snapshot(&handle.snapshot_id) + .expect("delete_snapshot failed"); + + if let Err(err) = std::fs::remove_file(&marker_path) { + eprintln!( + "warning: failed to clean up {}: {err}", + marker_path.display() + ); + } + } +} diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 56c93a358..8e74bae87 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -1,6 +1,16 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. +// `MetadataExt::file_index` (the Windows analogue of a Unix inode, +// used by `job::candidate_source::file_identity` for hard-link +// detection) is still gated behind this unstable std feature +// (rust-lang/rust#63010) with no stable alternative. Sound to rely on +// here: `rust-toolchain.toml` pins the exact same nightly across every +// environment (host, Windows, Linux) workspace-wide, not just for this +// crate, and `just toolchain-sync` re-validates every bump attempt +// against it before the pin moves. +#![cfg_attr(windows, feature(windows_by_handle))] + //! UFFS Content Service — library crate. //! //! `uffs-content` is the unprivileged content **coordinator**: read-mode From 5dfb85765562f65c85db83b27e55bc3757d6f841 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:42:07 -0700 Subject: [PATCH 18/98] feat(broker): add --self-test-vss subcommand + rust-script smoke test Extracts the VSS create/read/delete round-trip logic (previously only in an #[ignore]d test) into a production self_test_round_trip function shared by `uffs-broker --self-test-vss

` and the test itself, so the two paths can never drift apart. Adds scripts/windows/vss-snapshot-validation.rs, a thin rust-script wrapper matching the existing scripts/windows/*.rs convention, so the whole VSS pipeline (native shim -> uffs-vss-requestor -> Broker lease manager) can be smoke-tested standalone on a real Windows box without a running daemon or Coordinator. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-broker/src/broker.rs | 43 ++++ .../src/broker/snapshot_manager/mod.rs | 3 + .../src/broker/snapshot_manager/vss_helper.rs | 192 +++++++++++------- scripts/windows/vss-snapshot-validation.rs | 187 +++++++++++++++++ 4 files changed, 356 insertions(+), 69 deletions(-) create mode 100644 scripts/windows/vss-snapshot-validation.rs diff --git a/crates/uffs-broker/src/broker.rs b/crates/uffs-broker/src/broker.rs index d007a9f02..cbe41b673 100644 --- a/crates/uffs-broker/src/broker.rs +++ b/crates/uffs-broker/src/broker.rs @@ -116,6 +116,9 @@ pub(crate) fn run() -> anyhow::Result<()> { if args.iter().any(|arg| arg == "--run") { return run_foreground(); } + if let Some(test_dir) = self_test_vss_dir(&args) { + return self_test_vss(&test_dir); + } // No recognised flag: this is how the Service Control Manager launches the // service at boot. Hand control to the dispatcher; when run interactively @@ -140,9 +143,49 @@ fn print_usage() { eprintln!(" --start Start the service (waits for RUNNING)"); eprintln!(" --stop Stop the service (waits for STOPPED)"); eprintln!(" --run Run in foreground (debugging)"); + eprintln!(" --self-test-vss Elevated smoke test: real VSS snapshot create/read/delete"); eprintln!(" --version Print version (also -V)"); } +/// Return the directory argument following `--self-test-vss`, if present. +#[cfg(windows)] +fn self_test_vss_dir(args: &[String]) -> Option { + let flag_index = args.iter().position(|arg| arg == "--self-test-vss")?; + args.get(flag_index + 1).map(std::path::PathBuf::from) +} + +/// Run `snapshot_manager::self_test_round_trip` standalone (no service, +/// no pipe server) and print a PASS/FAIL result — a manual, elevated +/// smoke test proving the real VSS snapshot pipeline (native shim → +/// `uffs-vss-requestor` helper → Broker lease bookkeeping) works at +/// runtime on this machine. +/// +/// # Errors +/// Returns an error (and prints "FAIL: ") if any stage of the +/// round trip fails. +#[cfg(windows)] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn self_test_vss(test_dir: &std::path::Path) -> anyhow::Result<()> { + init_tracing(); + warn_if_not_elevated(); + match snapshot_manager::self_test_round_trip(test_dir) { + Ok(()) => { + eprintln!( + "PASS: VSS create/read/delete round trip succeeded ({})", + test_dir.display() + ); + Ok(()) + } + Err(err) => { + eprintln!("FAIL: {err:#}"); + Err(err) + } + } +} + /// Run the broker in foreground mode. #[cfg(windows)] fn run_foreground() -> anyhow::Result<()> { diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 71dd3e489..2df166b91 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -28,6 +28,9 @@ use uffs_broker_protocol::snapshot_manager::{ SnapshotLeaseStatus, SnapshotManagerErrorCode, SnapshotManagerRequest, SnapshotManagerResponse, }; use vss_helper::WindowsVssProvider; +// Re-exported so `broker::run` can wire up `--self-test-vss` without +// reaching past this module's own submodule privacy boundary. +pub(super) use vss_helper::self_test_round_trip; use windows::Win32::Foundation::HANDLE; use windows::core::PCWSTR; diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index aaac35411..abdafc741 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -541,26 +541,133 @@ fn build_command_line( command.encode_utf16().chain(Some(0)).collect() } -#[cfg(test)] -mod tests { - use uffs_broker_protocol::snapshot_manager::VolumeIdentity; +/// Encode `path` as the lossless UTF-16LE `requested_root` wire format +/// [`WindowsVssProvider::create_snapshot`] expects. +fn utf16le_bytes(path: &str) -> Vec { + path.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +/// Content written to, and verified against, the marker file +/// [`self_test_round_trip`] snapshots. +const SELF_TEST_MARKER_CONTENT: &[u8] = b"uffs-broker --self-test-vss marker"; + +/// Real, runnable, elevated end-to-end proof that the whole VSS pipeline +/// (native shim → `uffs-vss-requestor` helper process → Broker +/// lease/session bookkeeping → Job Object cleanup) actually works at +/// runtime, not just compiles and links. +/// +/// Creates a marker file under `test_dir`, snapshots that file's volume, +/// reads the marker back through the resulting snapshot device path, +/// verifies the content matches, then deletes the snapshot and the +/// marker file. Backs `uffs-broker --self-test-vss ` (see +/// `broker::run`); also exercised directly by this module's own +/// `#[ignore]`d test so the CLI path and the test path can never drift +/// apart. +/// +/// `test_dir` must be an absolute, plain (non `\\?\`-prefixed) path — +/// e.g. `C:\Users\me\AppData\Local\Temp\uffs-vss-self-test` — since its +/// drive root is passed directly to `IVssBackupComponents:: +/// AddToSnapshotSet`, which expects that exact form. +/// +/// # Errors +/// Returns an error if `test_dir` can't be created, has no root +/// component, the marker file can't be written, snapshot creation +/// fails, the marker can't be read back from the snapshot device path, +/// its content doesn't match, or snapshot deletion fails. +pub(crate) fn self_test_round_trip(test_dir: &Path) -> anyhow::Result<()> { + std::fs::create_dir_all(test_dir) + .map_err(|err| anyhow::anyhow!("failed to create {}: {err}", test_dir.display()))?; + // `Path::ancestors()` walks from the path itself up to the root, so + // the last ancestor is the drive root (e.g. `C:\`) — exactly the + // form `AddToSnapshotSet` requires. + let drive_root = test_dir + .ancestors() + .last() + .ok_or_else(|| anyhow::anyhow!("{} has no root component", test_dir.display()))? + .to_path_buf(); + + let marker_path = test_dir.join("uffs-vss-self-test-marker.txt"); + std::fs::write(&marker_path, SELF_TEST_MARKER_CONTENT) + .map_err(|err| anyhow::anyhow!("failed to write {}: {err}", marker_path.display()))?; + + let test_result = run_self_test_round_trip(&marker_path, &drive_root); + + if let Err(err) = std::fs::remove_file(&marker_path) { + tracing::warn!( + error = %err, + path = %marker_path.display(), + "self-test: failed to remove marker file" + ); + } + test_result +} + +/// Create the real snapshot, verify `marker_path` round-trips through +/// it, and delete it — the body of [`self_test_round_trip`], split out +/// so the marker-file cleanup above always runs regardless of outcome. +fn run_self_test_round_trip(marker_path: &Path, drive_root: &Path) -> anyhow::Result<()> { + let provider = WindowsVssProvider::new(); + let volume = VolumeIdentity { + volume_serial: 0, + volume_guid: Vec::new(), + }; + let requested_root = utf16le_bytes(&drive_root.to_string_lossy()); + + let handle = provider + .create_snapshot(&volume, &requested_root) + .map_err(|err| anyhow::anyhow!("create_snapshot failed: {err}"))?; + + let verify_result = verify_marker_round_trip(marker_path, drive_root, &handle); + + if let Err(err) = provider.delete_snapshot(&handle.snapshot_id) { + tracing::warn!(error = %err, "self-test: delete_snapshot failed"); + if verify_result.is_ok() { + return Err(anyhow::anyhow!("delete_snapshot failed: {err}")); + } + } + verify_result +} - use super::WindowsVssProvider; - use crate::snapshot_lease::VssProvider as _; +/// Read `marker_path` back through `handle`'s snapshot device path and +/// confirm it matches [`SELF_TEST_MARKER_CONTENT`]. +fn verify_marker_round_trip( + marker_path: &Path, + drive_root: &Path, + handle: &SnapshotHandle, +) -> anyhow::Result<()> { + if handle.device_identity.is_empty() { + anyhow::bail!("helper reported no snapshot device path"); + } + let relative_path = marker_path.strip_prefix(drive_root).map_err(|err| { + anyhow::anyhow!( + "{} is not under {}: {err}", + marker_path.display(), + drive_root.display() + ) + })?; + let snapshot_path = Path::new(&handle.device_identity).join(relative_path); - /// Encode `path` as the lossless UTF-16LE `requested_root` wire - /// format [`WindowsVssProvider::create_snapshot`] expects. - fn utf16le_bytes(path: &str) -> Vec { - path.encode_utf16().flat_map(u16::to_le_bytes).collect() + let read_back = std::fs::read(&snapshot_path) + .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", snapshot_path.display()))?; + if read_back != SELF_TEST_MARKER_CONTENT { + anyhow::bail!( + "content mismatch: snapshot read back {} bytes, expected {} bytes matching the marker", + read_back.len(), + SELF_TEST_MARKER_CONTENT.len() + ); } + Ok(()) +} +#[cfg(test)] +mod tests { /// Real, runnable, elevated end-to-end proof that the whole VSS - /// pipeline (native shim → `uffs-vss-requestor` helper process → - /// Broker lease/session bookkeeping → Job Object cleanup) actually - /// works at runtime, not just compiles and links. Everything built - /// for the Snapshot Manager across Phases 4-6 of + /// pipeline actually works at runtime, not just compiles and links. + /// Everything built for the Snapshot Manager across Phases 4-6 of /// `uffs-ingest-implementation-plan.md` had, until this test, never - /// been executed. + /// been executed. Delegates directly to [`super::self_test_round_trip`] + /// — the exact same function `uffs-broker --self-test-vss` runs — + /// so this test and the CLI path can never drift apart. /// /// Requires a real Windows host, Administrator elevation (creating a /// `VSS_CTX_FILE_SHARE_BACKUP` snapshot needs it, and reading a @@ -574,60 +681,7 @@ mod tests { #[test] #[ignore = "requires a real Windows host, Administrator elevation, and live VSS"] fn create_read_delete_snapshot_round_trip() { - let temp_dir = std::env::temp_dir(); - // `Path::ancestors()` walks from the path itself up to the root, - // so the last ancestor is the drive root (e.g. `C:\`) — exactly - // the form `IVssBackupComponents::AddToSnapshotSet` requires. - let drive_root = temp_dir - .ancestors() - .last() - .expect("temp_dir has at least one ancestor") - .to_path_buf(); - - let marker_name = format!( - "uffs-vss-e2e-{}.txt", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_nanos() - ); - let marker_path = temp_dir.join(&marker_name); - let marker_content = b"uffs vss round-trip marker"; - std::fs::write(&marker_path, marker_content).expect("failed to write marker file"); - - let provider = WindowsVssProvider::new(); - let volume = VolumeIdentity { - volume_serial: 0, - volume_guid: Vec::new(), - }; - let requested_root = utf16le_bytes(&drive_root.to_string_lossy()); - - let handle = provider - .create_snapshot(&volume, &requested_root) - .expect("create_snapshot failed"); - assert!( - !handle.device_identity.is_empty(), - "helper reported no snapshot device path" - ); - - let relative_path = marker_path - .strip_prefix(&drive_root) - .expect("marker_path is under drive_root"); - let snapshot_path = std::path::Path::new(&handle.device_identity).join(relative_path); - - let read_back = std::fs::read(&snapshot_path) - .expect("failed to read marker file back from the snapshot device path"); - assert_eq!(read_back, marker_content); - - provider - .delete_snapshot(&handle.snapshot_id) - .expect("delete_snapshot failed"); - - if let Err(err) = std::fs::remove_file(&marker_path) { - eprintln!( - "warning: failed to clean up {}: {err}", - marker_path.display() - ); - } + let test_dir = std::env::temp_dir().join("uffs-vss-self-test"); + super::self_test_round_trip(&test_dir).expect("VSS round trip self-test failed"); } } diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs new file mode 100644 index 000000000..9cf95a153 --- /dev/null +++ b/scripts/windows/vss-snapshot-validation.rs @@ -0,0 +1,187 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1.0" +//! colored = "2.0" +//! ``` +// ============================================================================= +// scripts/windows/vss-snapshot-validation — Broker VSS Snapshot Smoke Test +// ============================================================================= +// +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Real, runnable proof that the whole VSS snapshot pipeline (native +// vss_shim.cpp -> uffs-vss-requestor helper process -> uffs-broker's +// Snapshot Manager lease bookkeeping -> Job Object cleanup) actually +// works at runtime on this machine, not just compiles and links. +// +// This is a thin wrapper: it spawns `uffs-broker --self-test-vss ` +// and reports its exit status. The round-trip logic itself +// (create snapshot -> read a marker file back through the snapshot +// device path -> verify -> delete snapshot) lives once, in production +// code, at crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +// (`self_test_round_trip`) — the exact same function this script's +// target and `cargo test -p uffs-broker -- --ignored` both exercise, so +// none of the three ever drift apart. +// +// Requirements: +// - Windows with NTFS +// - Administrator privileges (VSS_CTX_FILE_SHARE_BACKUP snapshot +// creation, and reading a shadow-copy device path back, both need it) +// - uffs-broker.exe and uffs-vss-requestor.exe built and sitting in +// the same directory (production install layout, or +// `cargo build --release` output: both land in target/release/) +// +// Usage: +// rust-script scripts/windows/vss-snapshot-validation.rs +// rust-script scripts/windows/vss-snapshot-validation.rs C:\Temp\uffs-vss-test +// rust-script scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe + +use std::path::PathBuf; +use std::process::Command; +use std::time::Instant; + +use colored::Colorize; + +/// Parsed script arguments. +struct ScriptArgs { + /// Path to the `uffs-broker` binary to exercise. + bin: String, + /// Directory the self-test creates its marker file under. + test_dir: String, +} + +/// Parse CLI args. +/// +/// Usage: `rust-script vss-snapshot-validation [test-dir] [--bin ]` +fn parse_script_args() -> ScriptArgs { + let args: Vec = std::env::args().collect(); + let mut test_dir: Option = None; + let mut bin_override: Option = None; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--bin" | "--binary" => { + bin_override = args.get(i + 1).cloned(); + i += 2; + } + other if !other.starts_with('-') && test_dir.is_none() => { + test_dir = Some(other.to_string()); + i += 1; + } + _ => { + i += 1; + } + } + } + + ScriptArgs { + bin: bin_override.unwrap_or_else(default_binary), + test_dir: test_dir.unwrap_or_else(default_test_dir), + } +} + +/// Locate an existing `uffs-broker` binary; do **not** auto-build. +/// +/// Search order: +/// 1. `$USERPROFILE\bin\uffs-broker.exe` — `just use` install location +/// 2. `target\release\uffs-broker.exe` — `cargo build --release` output +/// 3. Bare `uffs-broker.exe` — falls through to PATH lookup +fn default_binary() -> String { + let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string()); + let candidates = [ + PathBuf::from(&home).join("bin").join("uffs-broker.exe"), + PathBuf::from("target").join("release").join("uffs-broker.exe"), + ]; + for candidate in &candidates { + if candidate.exists() { + return candidate.to_string_lossy().into_owned(); + } + } + "uffs-broker.exe".to_string() +} + +/// Default self-test directory: `%TEMP%\uffs-vss-self-test`, or +/// `.\uffs-vss-self-test` if `TEMP` isn't set. +fn default_test_dir() -> String { + let temp = std::env::var("TEMP") + .or_else(|_| std::env::var("TMP")) + .unwrap_or_else(|_| ".".to_string()); + PathBuf::from(temp) + .join("uffs-vss-self-test") + .to_string_lossy() + .into_owned() +} + +fn main() { + let script_start = Instant::now(); + let args = parse_script_args(); + + eprintln!(); + eprintln!("╔═══════════════════════════════════════════════════════════════╗"); + eprintln!("║ UFFS Broker VSS Snapshot Smoke Test ║"); + eprintln!("╚═══════════════════════════════════════════════════════════════╝"); + eprintln!(" Binary: {}", args.bin.cyan()); + eprintln!(" Test dir: {}", args.test_dir.cyan()); + eprintln!(); + + if !cfg!(windows) { + eprintln!( + " {} uffs-broker's VSS snapshot pipeline is Windows-only — nothing to test on this platform.", + "⚠".yellow() + ); + std::process::exit(1); + } + + eprintln!(" Running: {} --self-test-vss {}", args.bin, args.test_dir); + eprintln!( + " ─────────────────────────────────────────────────────────────────" + ); + + let output = Command::new(&args.bin) + .arg("--self-test-vss") + .arg(&args.test_dir) + .output(); + + let elapsed_ms = script_start.elapsed().as_millis(); + + let output = match output { + Ok(output) => output, + Err(err) => { + eprintln!( + " {} failed to spawn {}: {err}", + "✗".red(), + args.bin + ); + eprintln!( + " (build it first: cargo build --release -p uffs-broker -p uffs-vss-requestor)" + ); + std::process::exit(1); + } + }; + + // Relay the broker's own PASS/FAIL line and any tracing output + // verbatim — it already carries the diagnosis on failure. + print!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + + eprintln!( + " ─────────────────────────────────────────────────────────────────" + ); + if output.status.success() { + eprintln!( + " {} VSS create/read/delete round trip passed ({elapsed_ms}ms)", + "✓".green() + ); + } else { + eprintln!( + " {} VSS create/read/delete round trip failed ({elapsed_ms}ms)", + "✗".red() + ); + } + eprintln!(); + + std::process::exit(output.status.code().unwrap_or(1)); +} From e1ad17da9a47eea81172d11b3ccfbce71047914d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:51:20 -0700 Subject: [PATCH 19/98] fix(docs): repair three broken intra-doc links blocking the rustdoc gate Pre-existing links using unqualified item names that aren't in scope from their defining module: SnapshotManagerResponse and ReadMode are defined in the parent module (need super::), and digest32 was a stale name for the digest function. Caught by the pre-push rustdoc gate, unrelated to this session's VSS/broker work but blocking the push. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-broker-protocol/src/snapshot_manager/messages.rs | 2 +- crates/uffs-content-protocol/src/codec.rs | 2 +- crates/uffs-content-protocol/src/frame/job_begin.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs b/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs index da54fb172..08eafa231 100644 --- a/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs +++ b/crates/uffs-broker-protocol/src/snapshot_manager/messages.rs @@ -359,7 +359,7 @@ impl SnapshotLeaseStatus { } } -/// Stable error codes for a [`SnapshotManagerResponse::Error`]. +/// Stable error codes for a [`super::SnapshotManagerResponse::Error`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u8)] #[non_exhaustive] diff --git a/crates/uffs-content-protocol/src/codec.rs b/crates/uffs-content-protocol/src/codec.rs index bb0f83c52..b3e45c7a8 100644 --- a/crates/uffs-content-protocol/src/codec.rs +++ b/crates/uffs-content-protocol/src/codec.rs @@ -335,7 +335,7 @@ pub fn write_bytes_u16_prefixed(out: &mut Vec, bytes: &[u8]) { /// content-integrity digest (§15.1), so truncating it to 32 bits for the /// cheaper structural checksums keeps this crate to one hash primitive. /// This is explicitly *not* used for content integrity — see -/// [`digest32`] for the full 256-bit digest used there. +/// [`digest`] for the full 256-bit digest used there. #[must_use] pub fn checksum32(bytes: &[u8]) -> u32 { let hash = blake3::hash(bytes); diff --git a/crates/uffs-content-protocol/src/frame/job_begin.rs b/crates/uffs-content-protocol/src/frame/job_begin.rs index 0c2a0fa1d..f871f820d 100644 --- a/crates/uffs-content-protocol/src/frame/job_begin.rs +++ b/crates/uffs-content-protocol/src/frame/job_begin.rs @@ -45,7 +45,7 @@ pub struct JobBegin { /// candidates. `None` means no ceiling: every matched candidate gets /// its content delivered. This is independent of the query's own /// candidate-match filters (ext/date/etc.) — see - /// [`ReadMode::MetadataOnly`]. + /// [`super::ReadMode::MetadataOnly`]. pub max_content_delivery_bytes: Option, } From 9024d889a8a37cc9890f86dcb59008cc9c8b1b48 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:12:55 -0700 Subject: [PATCH 20/98] fix(broker): quote helper command-line args correctly, stamp vss-requestor version Root cause of the reported hang: build_command_line wrapped volume_path in a bare quoted string ("C:\"), but a single trailing backslash immediately before a closing quote escapes the quote instead of terminating the argument under Windows command-line parsing rules. Every argument after volume_path (including --parent-pid) was silently swallowed into it, so uffs-vss-requestor failed to parse its own args and exited before ever connecting to the control pipe -- leaving the Broker blocked forever in ConnectNamedPipe waiting for a connection that would never come, with the process already gone by the time anyone checked tasklist. Added quote_windows_arg implementing the real escaping rule (double any backslash run before an embedded/closing quote) and applied it to every string argument in the command line, with unit tests covering the trailing-backslash case plus the other standard pitfalls. Also wires uffs-vss-requestor's build.rs to call uffs_version::emit_build_env(), matching every other UFFS binary -- its --version output was silently missing the git-sha/commit-date fingerprint entirely, making exactly this kind of stale-vs-fresh-binary mismatch invisible. The validation script now prints `--version -v` for both uffs-broker and uffs-vss-requestor up front, and defaults to target\release\ over the installed ~\bin\ copy so it exercises the just-built dev binary for this not-yet-released flag. Co-Authored-By: Claude Sonnet 5 --- .../src/broker/snapshot_manager/vss_helper.rs | 86 ++++++++++++++- crates/uffs-vss-requestor/Cargo.toml | 11 +- crates/uffs-vss-requestor/build.rs | 11 +- scripts/windows/vss-snapshot-validation.rs | 103 ++++++++++++++---- 4 files changed, 180 insertions(+), 31 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index abdafc741..5fa232436 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -526,7 +526,7 @@ fn spawn_helper(pipe_name: &str, volume_path: &str) -> anyhow::Result" --pipe-name +/// Build the helper's command line: `"" --pipe-name "" /// --volume-path "" --parent-pid `, NUL-terminated UTF-16. fn build_command_line( exe_path: &Path, @@ -535,12 +535,54 @@ fn build_command_line( parent_pid: u32, ) -> Vec { let command = format!( - "\"{}\" --pipe-name {pipe_name} --volume-path \"{volume_path}\" --parent-pid {parent_pid}", - exe_path.display(), + "{} --pipe-name {} --volume-path {} --parent-pid {parent_pid}", + quote_windows_arg(&exe_path.display().to_string()), + quote_windows_arg(pipe_name), + quote_windows_arg(volume_path), ); command.encode_utf16().chain(Some(0)).collect() } +/// Quote `arg` for a `CreateProcessW` command line, following the +/// escaping rules `CommandLineToArgvW` (and the C-runtime argv parser +/// `std::env::args()` also uses) expect: a run of backslashes is only +/// literal if followed by something other than a quote, so any run +/// immediately preceding an embedded or closing quote must be doubled. +/// +/// This matters here because a volume root (e.g. `C:\`) always ends in +/// exactly one backslash — naively wrapping it as `"C:\"` makes that +/// single trailing backslash escape the closing quote instead of +/// terminating the argument, silently merging every argument after it +/// (`--parent-pid `) into this one. The helper then never receives +/// a `--parent-pid`, fails to parse its own arguments, and exits before +/// ever connecting to the control pipe — which left `create_snapshot` +/// blocked in `connect_pipe` forever, waiting on a process that had +/// already exited. +fn quote_windows_arg(arg: &str) -> String { + let mut quoted = String::with_capacity(arg.len() + 2); + quoted.push('"'); + let mut pending_backslashes = 0_usize; + for ch in arg.chars() { + if ch == '\\' { + pending_backslashes += 1; + continue; + } + if ch == '"' { + quoted.extend(core::iter::repeat_n('\\', pending_backslashes * 2 + 1)); + quoted.push('"'); + } else { + quoted.extend(core::iter::repeat_n('\\', pending_backslashes)); + quoted.push(ch); + } + pending_backslashes = 0; + } + // Any backslashes still pending are immediately followed by the + // closing quote we're about to append, so they must be doubled too. + quoted.extend(core::iter::repeat_n('\\', pending_backslashes * 2)); + quoted.push('"'); + quoted +} + /// Encode `path` as the lossless UTF-16LE `requested_root` wire format /// [`WindowsVssProvider::create_snapshot`] expects. fn utf16le_bytes(path: &str) -> Vec { @@ -684,4 +726,42 @@ mod tests { let test_dir = std::env::temp_dir().join("uffs-vss-self-test"); super::self_test_round_trip(&test_dir).expect("VSS round trip self-test failed"); } + + /// A drive root's trailing backslash must be doubled, not passed + /// through as-is — a single backslash immediately before the + /// closing quote escapes the quote instead of terminating the + /// argument, which is exactly the bug that let `--parent-pid` + /// silently vanish (see `quote_windows_arg`'s doc comment). + #[test] + fn quote_windows_arg_doubles_a_trailing_backslash() { + assert_eq!(super::quote_windows_arg(r"C:\"), r#""C:\\""#); + } + + #[test] + fn quote_windows_arg_passes_plain_text_through() { + assert_eq!(super::quote_windows_arg("plain"), r#""plain""#); + } + + #[test] + fn quote_windows_arg_escapes_embedded_quotes() { + assert_eq!(super::quote_windows_arg(r#"a"b"#), r#""a\"b""#); + } + + #[test] + fn quote_windows_arg_doubles_backslashes_before_an_embedded_quote() { + assert_eq!(super::quote_windows_arg(r#"a\"b"#), r#""a\\\"b""#); + } + + #[test] + fn quote_windows_arg_leaves_interior_backslashes_alone() { + assert_eq!( + super::quote_windows_arg(r"C:\Users\rnio\bin\uffs-vss-requestor.exe"), + r#""C:\Users\rnio\bin\uffs-vss-requestor.exe""# + ); + } + + #[test] + fn quote_windows_arg_handles_empty_string() { + assert_eq!(super::quote_windows_arg(""), r#""""#); + } } diff --git a/crates/uffs-vss-requestor/Cargo.toml b/crates/uffs-vss-requestor/Cargo.toml index d338b601d..632e8f64e 100644 --- a/crates/uffs-vss-requestor/Cargo.toml +++ b/crates/uffs-vss-requestor/Cargo.toml @@ -59,11 +59,18 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true [build-dependencies] +# Stamps the git-sha/commit-date/rustc/target/profile fields +# `uffs_version::handle_version!` prints for `--version` — the same +# machinery every other UFFS binary uses (`uffs-broker`, `uffs-mft`, +# etc.), so `uffs-vss-requestor.exe --version` doesn't shortchange the +# operator with a bare `(unknown)` when they run it standalone to +# diagnose a spawn failure (see `run.rs`'s `--version` handling). +uffs-version = { workspace = true, features = ["build"] } # Compiles `native/vss_shim.cpp` into a static library linked into this # binary — see `build.rs`. No PE resource embedding (icon/version info) # for this internal helper — it's spawned by the Broker, never invoked -# directly by a user, so it skips the `winresource` step -# `uffs-broker`/`uffs-content` use. +# directly by a user in normal operation, so it skips the `winresource` +# step `uffs-broker` uses. cc = "1.2.63" [lints] diff --git a/crates/uffs-vss-requestor/build.rs b/crates/uffs-vss-requestor/build.rs index d26ee63fc..f2d7e17fe 100644 --- a/crates/uffs-vss-requestor/build.rs +++ b/crates/uffs-vss-requestor/build.rs @@ -10,13 +10,16 @@ //! Build script for `uffs-vss-requestor`. //! -//! Compiles `native/vss_shim.cpp` — the narrow VSS requestor shim -//! against the official Windows SDK's `vsbackup.h` (see +//! Stamps the git-sha/commit-date/rustc/target/profile build-metadata +//! env vars `uffs_version::handle_version!` reads (matching every other +//! UFFS binary), then compiles `native/vss_shim.cpp` — the narrow VSS +//! requestor shim against the official Windows SDK's `vsbackup.h` (see //! `docs/dev/architecture/uffs-vss-rust-cpp-shim-implementation-guide.md`) -//! — into a static library and links it into this crate's binary. A -//! no-op on every non-Windows build target. +//! — into a static library and links it into this crate's binary. The +//! native-shim compile is a no-op on every non-Windows build target. fn main() { + uffs_version::emit_build_env(); println!("cargo:rerun-if-changed=native/vss_shim.cpp"); println!("cargo:rerun-if-changed=native/vss_shim.h"); diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs index 9cf95a153..9ce900d81 100644 --- a/scripts/windows/vss-snapshot-validation.rs +++ b/scripts/windows/vss-snapshot-validation.rs @@ -27,16 +27,17 @@ // // Requirements: // - Windows with NTFS -// - Administrator privileges (VSS_CTX_FILE_SHARE_BACKUP snapshot -// creation, and reading a shadow-copy device path back, both need it) -// - uffs-broker.exe and uffs-vss-requestor.exe built and sitting in -// the same directory (production install layout, or -// `cargo build --release` output: both land in target/release/) +// - Administrator privileges (VSS_CTX_FILE_SHARE_BACKUP snapshot creation, +// and reading a shadow-copy device path back, both need it) +// - uffs-broker.exe and uffs-vss-requestor.exe built and sitting in the same +// directory (production install layout, or `cargo build --release` output: +// both land in target/release/) // // Usage: // rust-script scripts/windows/vss-snapshot-validation.rs -// rust-script scripts/windows/vss-snapshot-validation.rs C:\Temp\uffs-vss-test -// rust-script scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe +// rust-script scripts/windows/vss-snapshot-validation.rs +// C:\Temp\uffs-vss-test rust-script +// scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe use std::path::PathBuf; use std::process::Command; @@ -85,15 +86,24 @@ fn parse_script_args() -> ScriptArgs { /// Locate an existing `uffs-broker` binary; do **not** auto-build. /// -/// Search order: -/// 1. `$USERPROFILE\bin\uffs-broker.exe` — `just use` install location -/// 2. `target\release\uffs-broker.exe` — `cargo build --release` output +/// Search order (deliberately the reverse of the other +/// `scripts/windows/*.rs` validation scripts, which prefer the +/// installed `~\bin\` copy to test "whatever's released"): this script +/// exercises `--self-test-vss`, a brand-new flag that has never shipped +/// in any release, so an installed broker predating it would silently +/// fall through to the Service-Control-Manager dispatch path and hang +/// waiting for an SCM that never arrives — confusing to debug. Prefer +/// the just-built dev binary instead: +/// 1. `target\release\uffs-broker.exe` — `cargo build --release` output +/// 2. `$USERPROFILE\bin\uffs-broker.exe` — `just use` install location /// 3. Bare `uffs-broker.exe` — falls through to PATH lookup fn default_binary() -> String { let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string()); let candidates = [ + PathBuf::from("target") + .join("release") + .join("uffs-broker.exe"), PathBuf::from(&home).join("bin").join("uffs-broker.exe"), - PathBuf::from("target").join("release").join("uffs-broker.exe"), ]; for candidate in &candidates { if candidate.exists() { @@ -115,6 +125,59 @@ fn default_test_dir() -> String { .into_owned() } +/// The expected `uffs-vss-requestor.exe` path: alongside `bin`, +/// mirroring `helper_exe_path()`'s production lookup in +/// crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs (it +/// must be a sibling of the running `uffs-broker.exe`). +fn helper_binary_path(bin: &str) -> PathBuf { + PathBuf::from(bin).parent().map_or_else( + || PathBuf::from("uffs-vss-requestor.exe"), + |dir| dir.join("uffs-vss-requestor.exe"), + ) +} + +/// Print ` --version -v` (the long, build-fingerprinted form +/// every UFFS binary supports) before running anything. +/// +/// This exists because a stale binary is exactly what caused a silent, +/// indefinite hang once already: an installed `uffs-broker.exe` +/// predating `--self-test-vss` fell through to the Service-Control- +/// Manager dispatch path instead of running the self-test, with zero +/// output to say so. Printing the git-sha/commit-date fingerprint for +/// *both* binaries up front makes a version mismatch (or a +/// `uffs-vss-requestor.exe` that's older than the `uffs-broker.exe` +/// spawning it) obvious before the test even starts, instead of +/// something you have to reverse-engineer from a hang. +fn print_binary_version(label: &str, path: &std::path::Path) { + match Command::new(path).args(["--version", "-v"]).output() { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + for (i, line) in text.lines().enumerate() { + if i == 0 { + eprintln!(" {label} {}", line.cyan()); + } else { + eprintln!(" {} {line}", " ".repeat(label.len())); + } + } + } + Ok(output) => { + eprintln!( + " {label} {} exited {} — {}", + "?".yellow(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Err(err) => { + eprintln!( + " {label} {} not found at {}: {err}", + "✗".red(), + path.display() + ); + } + } +} + fn main() { let script_start = Instant::now(); let args = parse_script_args(); @@ -135,10 +198,12 @@ fn main() { std::process::exit(1); } + print_binary_version("uffs-broker: ", std::path::Path::new(&args.bin)); + print_binary_version("uffs-vss-requestor:", &helper_binary_path(&args.bin)); + eprintln!(); + eprintln!(" Running: {} --self-test-vss {}", args.bin, args.test_dir); - eprintln!( - " ─────────────────────────────────────────────────────────────────" - ); + eprintln!(" ─────────────────────────────────────────────────────────────────"); let output = Command::new(&args.bin) .arg("--self-test-vss") @@ -150,11 +215,7 @@ fn main() { let output = match output { Ok(output) => output, Err(err) => { - eprintln!( - " {} failed to spawn {}: {err}", - "✗".red(), - args.bin - ); + eprintln!(" {} failed to spawn {}: {err}", "✗".red(), args.bin); eprintln!( " (build it first: cargo build --release -p uffs-broker -p uffs-vss-requestor)" ); @@ -167,9 +228,7 @@ fn main() { print!("{}", String::from_utf8_lossy(&output.stdout)); eprint!("{}", String::from_utf8_lossy(&output.stderr)); - eprintln!( - " ─────────────────────────────────────────────────────────────────" - ); + eprintln!(" ─────────────────────────────────────────────────────────────────"); if output.status.success() { eprintln!( " {} VSS create/read/delete round trip passed ({elapsed_ms}ms)", From aa742188c26f1f7b50414aa13c8b840abc2cd223 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:36:17 -0700 Subject: [PATCH 21/98] feat(broker): add live progress logging to the VSS self-test round trip Every stage of --self-test-vss was previously silent between "Running:" and the final PASS/FAIL line, so a hang (like the arg-quoting bug just fixed) gave zero indication of where it was stuck. Adds tracing::info! checkpoints at every step: pipe creation, helper spawn (with pid), waiting for the helper to connect, waiting for its Ready/Failed event, snapshot creation, marker verification, and deletion/release. Split create_snapshot into wait_for_helper_ready + finish_create_snapshot to keep its cognitive complexity under the lint ceiling with the new logging in place, and split the self-test round-trip logic out of vss_helper.rs into a new sibling file (vss_self_test.rs) since the added instrumentation pushed vss_helper.rs over the workspace's 800-LOC file-size policy. Co-Authored-By: Claude Sonnet 5 --- .../src/broker/snapshot_manager/mod.rs | 5 +- .../src/broker/snapshot_manager/vss_helper.rs | 286 +++++++----------- .../broker/snapshot_manager/vss_self_test.rs | 165 ++++++++++ 3 files changed, 270 insertions(+), 186 deletions(-) create mode 100644 crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 2df166b91..1c39f858a 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -19,6 +19,9 @@ //! request dispatch. mod vss_helper; +// The `--self-test-vss` round trip: split into its own file purely to +// keep `vss_helper.rs` under the workspace's 800-LOC file-size policy. +mod vss_self_test; use alloc::sync::Arc; use core::time::Duration; @@ -30,7 +33,7 @@ use uffs_broker_protocol::snapshot_manager::{ use vss_helper::WindowsVssProvider; // Re-exported so `broker::run` can wire up `--self-test-vss` without // reaching past this module's own submodule privacy boundary. -pub(super) use vss_helper::self_test_round_trip; +pub(super) use vss_self_test::self_test_round_trip; use windows::Win32::Foundation::HANDLE; use windows::core::PCWSTR; diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index 5fa232436..99800a123 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -186,59 +186,28 @@ impl WindowsVssProvider { } } -impl VssProvider for WindowsVssProvider { - #[expect( - unsafe_code, - reason = "wraps a freshly connected pipe HANDLE in a File; see the inline SAFETY comment" - )] - fn create_snapshot( +impl WindowsVssProvider { + /// Register a newly-ready helper's session and build its + /// [`SnapshotHandle`], or translate a `Failed`/unexpected event into + /// a [`VssError`] — the tail half of [`Self::create_snapshot`], + /// split out to keep that function's cognitive complexity down. + fn finish_create_snapshot( &self, - _volume: &VolumeIdentity, - requested_root: &[u8], + pending: PendingSpawn, + reader: BufReader, + writer: File, + event: HelperEvent, ) -> Result { - let volume_path = decode_utf16le(requested_root).ok_or_else(|| { - VssError::InvalidVolume("requested_root is not valid UTF-16LE".to_owned()) - })?; - - let pipe_id = self.next_pipe_id.fetch_add(1, Ordering::Relaxed); - let pipe_name = format!(r"\\.\pipe\uffs-vss-requestor-{pipe_id:016x}"); - - let pipe_handle = create_control_pipe(&pipe_name).map_err(|err| { - VssError::CreateFailed(format!("failed to create control pipe: {err}")) - })?; - - let pending = spawn_helper(&pipe_name, &volume_path).map_err(|err| { - VssError::CreateFailed(format!("failed to spawn uffs-vss-requestor: {err}")) - })?; - - if let Err(err) = connect_pipe(pipe_handle) { - close_pipe_handle(pipe_handle); - return Err(VssError::CreateFailed(format!( - "helper did not connect to control pipe: {err}" - ))); - } - - // SAFETY: `pipe_handle` is a valid, connected, exclusively owned - // duplex pipe HANDLE; `File` takes ownership and closes it on drop. - let pipe_file = unsafe { File::from_raw_handle(pipe_handle.0.cast::()) }; - let writer = pipe_file - .try_clone() - .map_err(|err| VssError::CreateFailed(format!("failed to clone pipe handle: {err}")))?; - let mut reader = BufReader::new(pipe_file); - - let event = read_helper_event(&mut reader) - .map_err(|err| VssError::CreateFailed(format!("failed to read helper event: {err}")))? - .ok_or_else(|| { - VssError::CreateFailed( - "helper closed the control pipe before reporting readiness".to_owned(), - ) - })?; - match event { HelperEvent::Ready { snapshot_id, snapshot_device_object, } => { + tracing::info!( + snapshot_id = %snapshot_id, + device = %snapshot_device_object.as_deref().unwrap_or(""), + "vss: snapshot ready" + ); let (process_handle, job_handle) = pending.into_handles(); let snapshot_id_bytes = snapshot_id.into_bytes(); let session = HelperSession { @@ -266,8 +235,79 @@ impl VssProvider for WindowsVssProvider { )), } } +} + +/// Block until the helper connects to `pipe_handle`, then read its +/// first event — the middle third of `create_snapshot`, split out to +/// keep that function's cognitive complexity down. +#[expect( + unsafe_code, + reason = "wraps a freshly connected pipe HANDLE in a File; see the inline SAFETY comment" +)] +fn wait_for_helper_ready( + pipe_handle: HANDLE, +) -> Result<(BufReader, File, HelperEvent), VssError> { + tracing::info!("vss: waiting for helper to connect to the control pipe"); + if let Err(err) = connect_pipe(pipe_handle) { + close_pipe_handle(pipe_handle); + return Err(VssError::CreateFailed(format!( + "helper did not connect to control pipe: {err}" + ))); + } + tracing::info!("vss: helper connected"); + + // SAFETY: `pipe_handle` is a valid, connected, exclusively owned + // duplex pipe HANDLE; `File` takes ownership and closes it on drop. + let pipe_file = unsafe { File::from_raw_handle(pipe_handle.0.cast::()) }; + let writer = pipe_file + .try_clone() + .map_err(|err| VssError::CreateFailed(format!("failed to clone pipe handle: {err}")))?; + let mut reader = BufReader::new(pipe_file); + + tracing::info!("vss: waiting for Ready/Failed from helper"); + let event = read_helper_event(&mut reader) + .map_err(|err| VssError::CreateFailed(format!("failed to read helper event: {err}")))? + .ok_or_else(|| { + VssError::CreateFailed( + "helper closed the control pipe before reporting readiness".to_owned(), + ) + })?; + + Ok((reader, writer, event)) +} + +impl VssProvider for WindowsVssProvider { + fn create_snapshot( + &self, + _volume: &VolumeIdentity, + requested_root: &[u8], + ) -> Result { + let volume_path = decode_utf16le(requested_root).ok_or_else(|| { + VssError::InvalidVolume("requested_root is not valid UTF-16LE".to_owned()) + })?; + + let pipe_id = self.next_pipe_id.fetch_add(1, Ordering::Relaxed); + let pipe_name = format!(r"\\.\pipe\uffs-vss-requestor-{pipe_id:016x}"); + tracing::info!(volume = %volume_path, pipe = %pipe_name, "vss: creating control pipe"); + + let pipe_handle = create_control_pipe(&pipe_name).map_err(|err| { + VssError::CreateFailed(format!("failed to create control pipe: {err}")) + })?; + + tracing::info!(volume = %volume_path, "vss: spawning uffs-vss-requestor"); + let pending = spawn_helper(&pipe_name, &volume_path).map_err(|err| { + VssError::CreateFailed(format!("failed to spawn uffs-vss-requestor: {err}")) + })?; + + let (reader, writer, event) = wait_for_helper_ready(pipe_handle)?; + self.finish_create_snapshot(pending, reader, writer, event) + } fn delete_snapshot(&self, snapshot_id: &[u8]) -> Result<(), VssError> { + tracing::info!( + snapshot_id = %String::from_utf8_lossy(snapshot_id), + "vss: requesting snapshot deletion" + ); let mut session = self.lock_sessions().remove(snapshot_id).ok_or_else(|| { VssError::DeleteFailed("no live session for this snapshot".to_owned()) })?; @@ -282,10 +322,11 @@ impl VssProvider for WindowsVssProvider { }); write_result?; + tracing::info!("vss: waiting for Released confirmation"); let event = read_helper_event(&mut session.reader).map_err(|err| { VssError::DeleteFailed(format!("failed to read helper response: {err}")) })?; - match event { + let result = match event { Some(HelperEvent::Released) | None => Ok(()), Some(HelperEvent::Failed { stage, @@ -297,7 +338,11 @@ impl VssProvider for WindowsVssProvider { Some(HelperEvent::Ready { .. } | HelperEvent::Pong) => Err(VssError::DeleteFailed( "unexpected event from helper after Release".to_owned(), )), + }; + if result.is_ok() { + tracing::info!("vss: snapshot released"); } + result // `session` drops here regardless of outcome, closing the // process/job handles. } @@ -442,6 +487,7 @@ fn helper_exe_path() -> anyhow::Result { fn spawn_helper(pipe_name: &str, volume_path: &str) -> anyhow::Result { let exe_path = helper_exe_path()?; let parent_pid = std::process::id(); + tracing::info!(exe = %exe_path.display(), parent_pid, "vss: launching helper process"); let mut command_line = build_command_line(&exe_path, pipe_name, volume_path, parent_pid); let startup_info = STARTUPINFOW { @@ -472,6 +518,10 @@ fn spawn_helper(pipe_name: &str, volume_path: &str) -> anyhow::Result anyhow::Result String { } /// Encode `path` as the lossless UTF-16LE `requested_root` wire format -/// [`WindowsVssProvider::create_snapshot`] expects. -fn utf16le_bytes(path: &str) -> Vec { +/// [`WindowsVssProvider::create_snapshot`] expects. `pub(crate)` since +/// `vss_self_test` (the `--self-test-vss` implementation) also needs it. +pub(crate) fn utf16le_bytes(path: &str) -> Vec { path.encode_utf16().flat_map(u16::to_le_bytes).collect() } -/// Content written to, and verified against, the marker file -/// [`self_test_round_trip`] snapshots. -const SELF_TEST_MARKER_CONTENT: &[u8] = b"uffs-broker --self-test-vss marker"; - -/// Real, runnable, elevated end-to-end proof that the whole VSS pipeline -/// (native shim → `uffs-vss-requestor` helper process → Broker -/// lease/session bookkeeping → Job Object cleanup) actually works at -/// runtime, not just compiles and links. -/// -/// Creates a marker file under `test_dir`, snapshots that file's volume, -/// reads the marker back through the resulting snapshot device path, -/// verifies the content matches, then deletes the snapshot and the -/// marker file. Backs `uffs-broker --self-test-vss ` (see -/// `broker::run`); also exercised directly by this module's own -/// `#[ignore]`d test so the CLI path and the test path can never drift -/// apart. -/// -/// `test_dir` must be an absolute, plain (non `\\?\`-prefixed) path — -/// e.g. `C:\Users\me\AppData\Local\Temp\uffs-vss-self-test` — since its -/// drive root is passed directly to `IVssBackupComponents:: -/// AddToSnapshotSet`, which expects that exact form. -/// -/// # Errors -/// Returns an error if `test_dir` can't be created, has no root -/// component, the marker file can't be written, snapshot creation -/// fails, the marker can't be read back from the snapshot device path, -/// its content doesn't match, or snapshot deletion fails. -pub(crate) fn self_test_round_trip(test_dir: &Path) -> anyhow::Result<()> { - std::fs::create_dir_all(test_dir) - .map_err(|err| anyhow::anyhow!("failed to create {}: {err}", test_dir.display()))?; - // `Path::ancestors()` walks from the path itself up to the root, so - // the last ancestor is the drive root (e.g. `C:\`) — exactly the - // form `AddToSnapshotSet` requires. - let drive_root = test_dir - .ancestors() - .last() - .ok_or_else(|| anyhow::anyhow!("{} has no root component", test_dir.display()))? - .to_path_buf(); - - let marker_path = test_dir.join("uffs-vss-self-test-marker.txt"); - std::fs::write(&marker_path, SELF_TEST_MARKER_CONTENT) - .map_err(|err| anyhow::anyhow!("failed to write {}: {err}", marker_path.display()))?; - - let test_result = run_self_test_round_trip(&marker_path, &drive_root); - - if let Err(err) = std::fs::remove_file(&marker_path) { - tracing::warn!( - error = %err, - path = %marker_path.display(), - "self-test: failed to remove marker file" - ); - } - test_result -} - -/// Create the real snapshot, verify `marker_path` round-trips through -/// it, and delete it — the body of [`self_test_round_trip`], split out -/// so the marker-file cleanup above always runs regardless of outcome. -fn run_self_test_round_trip(marker_path: &Path, drive_root: &Path) -> anyhow::Result<()> { - let provider = WindowsVssProvider::new(); - let volume = VolumeIdentity { - volume_serial: 0, - volume_guid: Vec::new(), - }; - let requested_root = utf16le_bytes(&drive_root.to_string_lossy()); - - let handle = provider - .create_snapshot(&volume, &requested_root) - .map_err(|err| anyhow::anyhow!("create_snapshot failed: {err}"))?; - - let verify_result = verify_marker_round_trip(marker_path, drive_root, &handle); - - if let Err(err) = provider.delete_snapshot(&handle.snapshot_id) { - tracing::warn!(error = %err, "self-test: delete_snapshot failed"); - if verify_result.is_ok() { - return Err(anyhow::anyhow!("delete_snapshot failed: {err}")); - } - } - verify_result -} - -/// Read `marker_path` back through `handle`'s snapshot device path and -/// confirm it matches [`SELF_TEST_MARKER_CONTENT`]. -fn verify_marker_round_trip( - marker_path: &Path, - drive_root: &Path, - handle: &SnapshotHandle, -) -> anyhow::Result<()> { - if handle.device_identity.is_empty() { - anyhow::bail!("helper reported no snapshot device path"); - } - let relative_path = marker_path.strip_prefix(drive_root).map_err(|err| { - anyhow::anyhow!( - "{} is not under {}: {err}", - marker_path.display(), - drive_root.display() - ) - })?; - let snapshot_path = Path::new(&handle.device_identity).join(relative_path); - - let read_back = std::fs::read(&snapshot_path) - .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", snapshot_path.display()))?; - if read_back != SELF_TEST_MARKER_CONTENT { - anyhow::bail!( - "content mismatch: snapshot read back {} bytes, expected {} bytes matching the marker", - read_back.len(), - SELF_TEST_MARKER_CONTENT.len() - ); - } - Ok(()) -} - #[cfg(test)] mod tests { - /// Real, runnable, elevated end-to-end proof that the whole VSS - /// pipeline actually works at runtime, not just compiles and links. - /// Everything built for the Snapshot Manager across Phases 4-6 of - /// `uffs-ingest-implementation-plan.md` had, until this test, never - /// been executed. Delegates directly to [`super::self_test_round_trip`] - /// — the exact same function `uffs-broker --self-test-vss` runs — - /// so this test and the CLI path can never drift apart. - /// - /// Requires a real Windows host, Administrator elevation (creating a - /// `VSS_CTX_FILE_SHARE_BACKUP` snapshot needs it, and reading a - /// shadow-copy device path back needs it too), and - /// `uffs-vss-requestor.exe` already built in the same profile - /// directory `super::helper_exe_path` searches (it cannot be a - /// Cargo dependency of any kind — see that function's doc comment — - /// so nothing builds it automatically here): run - /// `cargo build -p uffs-vss-requestor` once, then run this test - /// elevated with `cargo test -p uffs-broker -- --ignored`. - #[test] - #[ignore = "requires a real Windows host, Administrator elevation, and live VSS"] - fn create_read_delete_snapshot_round_trip() { - let test_dir = std::env::temp_dir().join("uffs-vss-self-test"); - super::self_test_round_trip(&test_dir).expect("VSS round trip self-test failed"); - } - /// A drive root's trailing backslash must be doubled, not passed /// through as-is — a single backslash immediately before the /// closing quote escapes the quote instead of terminating the diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs new file mode 100644 index 000000000..f04ec6996 --- /dev/null +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Real, runnable, elevated end-to-end proof that the whole VSS +//! snapshot pipeline (native shim → `uffs-vss-requestor` helper process +//! → [`WindowsVssProvider`] lease/session bookkeeping → Job Object +//! cleanup) actually works at runtime, not just compiles and links. +//! +//! Split out of `vss_helper.rs` purely to stay under the workspace's +//! 800-LOC file-size policy once this self-test's tracing +//! instrumentation pushed that file over the limit — this module has no +//! independent design rationale beyond that split. +//! +//! Backs `uffs-broker --self-test-vss ` (see `broker::run`) and +//! this module's own `#[ignore]`d test, both calling +//! [`self_test_round_trip`] directly so the CLI path and the test path +//! can never drift apart. + +use std::path::Path; + +use uffs_broker_protocol::snapshot_manager::VolumeIdentity; + +use super::vss_helper::{WindowsVssProvider, utf16le_bytes}; +use crate::snapshot_lease::{SnapshotHandle, VssProvider as _}; + +/// Content written to, and verified against, the marker file +/// [`self_test_round_trip`] snapshots. +const SELF_TEST_MARKER_CONTENT: &[u8] = b"uffs-broker --self-test-vss marker"; + +/// Creates a marker file under `test_dir`, snapshots that file's volume, +/// reads the marker back through the resulting snapshot device path, +/// verifies the content matches, then deletes the snapshot and the +/// marker file. +/// +/// `test_dir` must be an absolute, plain (non `\\?\`-prefixed) path — +/// e.g. `C:\Users\me\AppData\Local\Temp\uffs-vss-self-test` — since its +/// drive root is passed directly to `IVssBackupComponents:: +/// AddToSnapshotSet`, which expects that exact form. +/// +/// # Errors +/// Returns an error if `test_dir` can't be created, has no root +/// component, the marker file can't be written, snapshot creation +/// fails, the marker can't be read back from the snapshot device path, +/// its content doesn't match, or snapshot deletion fails. +pub(crate) fn self_test_round_trip(test_dir: &Path) -> anyhow::Result<()> { + tracing::info!(test_dir = %test_dir.display(), "self-test: starting VSS round trip"); + std::fs::create_dir_all(test_dir) + .map_err(|err| anyhow::anyhow!("failed to create {}: {err}", test_dir.display()))?; + // `Path::ancestors()` walks from the path itself up to the root, so + // the last ancestor is the drive root (e.g. `C:\`) — exactly the + // form `AddToSnapshotSet` requires. + let drive_root = test_dir + .ancestors() + .last() + .ok_or_else(|| anyhow::anyhow!("{} has no root component", test_dir.display()))? + .to_path_buf(); + + let marker_path = test_dir.join("uffs-vss-self-test-marker.txt"); + std::fs::write(&marker_path, SELF_TEST_MARKER_CONTENT) + .map_err(|err| anyhow::anyhow!("failed to write {}: {err}", marker_path.display()))?; + tracing::info!( + marker = %marker_path.display(), + drive_root = %drive_root.display(), + "self-test: wrote marker file" + ); + + let test_result = run_self_test_round_trip(&marker_path, &drive_root); + + if let Err(err) = std::fs::remove_file(&marker_path) { + tracing::warn!( + error = %err, + path = %marker_path.display(), + "self-test: failed to remove marker file" + ); + } + test_result +} + +/// Create the real snapshot, verify `marker_path` round-trips through +/// it, and delete it — the body of [`self_test_round_trip`], split out +/// so the marker-file cleanup above always runs regardless of outcome. +fn run_self_test_round_trip(marker_path: &Path, drive_root: &Path) -> anyhow::Result<()> { + let provider = WindowsVssProvider::new(); + let volume = VolumeIdentity { + volume_serial: 0, + volume_guid: Vec::new(), + }; + let requested_root = utf16le_bytes(&drive_root.to_string_lossy()); + + let handle = provider + .create_snapshot(&volume, &requested_root) + .map_err(|err| anyhow::anyhow!("create_snapshot failed: {err}"))?; + + tracing::info!("self-test: verifying marker round-trips through the snapshot device path"); + let verify_result = verify_marker_round_trip(marker_path, drive_root, &handle); + if verify_result.is_ok() { + tracing::info!("self-test: marker verified"); + } + + if let Err(err) = provider.delete_snapshot(&handle.snapshot_id) { + tracing::warn!(error = %err, "self-test: delete_snapshot failed"); + if verify_result.is_ok() { + return Err(anyhow::anyhow!("delete_snapshot failed: {err}")); + } + } + verify_result +} + +/// Read `marker_path` back through `handle`'s snapshot device path and +/// confirm it matches [`SELF_TEST_MARKER_CONTENT`]. +fn verify_marker_round_trip( + marker_path: &Path, + drive_root: &Path, + handle: &SnapshotHandle, +) -> anyhow::Result<()> { + if handle.device_identity.is_empty() { + anyhow::bail!("helper reported no snapshot device path"); + } + let relative_path = marker_path.strip_prefix(drive_root).map_err(|err| { + anyhow::anyhow!( + "{} is not under {}: {err}", + marker_path.display(), + drive_root.display() + ) + })?; + let snapshot_path = Path::new(&handle.device_identity).join(relative_path); + + let read_back = std::fs::read(&snapshot_path) + .map_err(|err| anyhow::anyhow!("failed to read {}: {err}", snapshot_path.display()))?; + if read_back != SELF_TEST_MARKER_CONTENT { + anyhow::bail!( + "content mismatch: snapshot read back {} bytes, expected {} bytes matching the marker", + read_back.len(), + SELF_TEST_MARKER_CONTENT.len() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + /// Real, runnable, elevated end-to-end proof that the whole VSS + /// pipeline actually works at runtime, not just compiles and links. + /// Everything built for the Snapshot Manager across Phases 4-6 of + /// `uffs-ingest-implementation-plan.md` had, until this test, never + /// been executed. Delegates directly to [`super::self_test_round_trip`] + /// — the exact same function `uffs-broker --self-test-vss` runs — + /// so this test and the CLI path can never drift apart. + /// + /// Requires a real Windows host, Administrator elevation (creating a + /// `VSS_CTX_FILE_SHARE_BACKUP` snapshot needs it, and reading a + /// shadow-copy device path back needs it too), and + /// `uffs-vss-requestor.exe` already built in the same profile + /// directory `super::vss_helper::helper_exe_path` searches (it + /// cannot be a Cargo dependency of any kind — see that function's + /// doc comment — so nothing builds it automatically here): run + /// `cargo build -p uffs-vss-requestor` once, then run this test + /// elevated with `cargo test -p uffs-broker -- --ignored`. + #[test] + #[ignore = "requires a real Windows host, Administrator elevation, and live VSS"] + fn create_read_delete_snapshot_round_trip() { + let test_dir = std::env::temp_dir().join("uffs-vss-self-test"); + super::self_test_round_trip(&test_dir).expect("VSS round trip self-test failed"); + } +} From 784ec602a8d66d83ddedb242d70b041282450bcd Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:20:02 -0700 Subject: [PATCH 22/98] fix(scripts): stream the broker's live output instead of buffering it Command::output() waits for the child to exit before returning anything, which silently swallowed every tracing::info! progress line the Broker's self-test now emits -- exactly the visibility the logging was added for. Switched to Stdio::inherit() + .status() so the Broker's stdout/stderr stream straight to the terminal in real time, letting a hang show which step it's actually stuck on. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/vss-snapshot-validation.rs | 29 +++++++++++++--------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs index 9ce900d81..6f9427d04 100644 --- a/scripts/windows/vss-snapshot-validation.rs +++ b/scripts/windows/vss-snapshot-validation.rs @@ -40,7 +40,7 @@ // scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; use std::time::Instant; use colored::Colorize; @@ -205,15 +205,25 @@ fn main() { eprintln!(" Running: {} --self-test-vss {}", args.bin, args.test_dir); eprintln!(" ─────────────────────────────────────────────────────────────────"); - let output = Command::new(&args.bin) + // `Stdio::inherit()` + `.status()` — deliberately NOT `.output()`. + // `.output()` buffers the child's entire stdout/stderr and only + // hands it back once the process exits, which silently swallowed + // every `tracing::info!` progress line the Broker prints while a + // snapshot is being created: the whole point of that instrumentation + // is to show which step a hang is stuck on *while it's stuck*, not + // after the fact. Inheriting stdio streams the Broker's own output + // straight to this terminal in real time instead. + let status = Command::new(&args.bin) .arg("--self-test-vss") .arg(&args.test_dir) - .output(); + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status(); let elapsed_ms = script_start.elapsed().as_millis(); - let output = match output { - Ok(output) => output, + let status = match status { + Ok(status) => status, Err(err) => { eprintln!(" {} failed to spawn {}: {err}", "✗".red(), args.bin); eprintln!( @@ -223,13 +233,8 @@ fn main() { } }; - // Relay the broker's own PASS/FAIL line and any tracing output - // verbatim — it already carries the diagnosis on failure. - print!("{}", String::from_utf8_lossy(&output.stdout)); - eprint!("{}", String::from_utf8_lossy(&output.stderr)); - eprintln!(" ─────────────────────────────────────────────────────────────────"); - if output.status.success() { + if status.success() { eprintln!( " {} VSS create/read/delete round trip passed ({elapsed_ms}ms)", "✓".green() @@ -242,5 +247,5 @@ fn main() { } eprintln!(); - std::process::exit(output.status.code().unwrap_or(1)); + std::process::exit(status.code().unwrap_or(1)); } From fa4d67e638391f2ceba99e61dda2bfc2b58af7bf Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:22:08 -0700 Subject: [PATCH 23/98] feat(scripts): add a bounded timeout to the VSS smoke-test wrapper The wrapper had no timeout at all -- a hung helper connection blocked it indefinitely with no way to tell it apart from "still working". Switched to spawn() + a poll loop (default 120s, --timeout-secs override) that kills the child and reports a clear timeout instead of hanging forever; the last "vss: ..."/"self-test: ..." line streamed to the terminal before the kill now pinpoints exactly which step it was stuck on. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/vss-snapshot-validation.rs | 119 ++++++++++++++++----- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs index 6f9427d04..aac32d6c6 100644 --- a/scripts/windows/vss-snapshot-validation.rs +++ b/scripts/windows/vss-snapshot-validation.rs @@ -35,31 +35,43 @@ // // Usage: // rust-script scripts/windows/vss-snapshot-validation.rs -// rust-script scripts/windows/vss-snapshot-validation.rs -// C:\Temp\uffs-vss-test rust-script -// scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe +// rust-script scripts/windows/vss-snapshot-validation.rs C:\Temp\uffs-vss-test +// rust-script scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe +// rust-script scripts/windows/vss-snapshot-validation.rs --timeout-secs 300 use std::path::PathBuf; use std::process::{Command, Stdio}; -use std::time::Instant; +use std::time::{Duration, Instant}; use colored::Colorize; +/// How long to wait for `--self-test-vss` before killing it and +/// reporting a timeout, absent a `--timeout-secs` override. VSS_CTX_ +/// FILE_SHARE_BACKUP snapshot creation with no writer participation is +/// normally a few seconds; 120s gives real disk activity plenty of +/// room without hanging forever the way a stuck helper connection did +/// before this timeout existed. +const DEFAULT_TIMEOUT_SECS: u64 = 120; + /// Parsed script arguments. struct ScriptArgs { /// Path to the `uffs-broker` binary to exercise. bin: String, /// Directory the self-test creates its marker file under. test_dir: String, + /// How long to wait before killing the child and reporting a timeout. + timeout: Duration, } /// Parse CLI args. /// -/// Usage: `rust-script vss-snapshot-validation [test-dir] [--bin ]` +/// Usage: `rust-script vss-snapshot-validation [test-dir] [--bin ] +/// [--timeout-secs ]` fn parse_script_args() -> ScriptArgs { let args: Vec = std::env::args().collect(); let mut test_dir: Option = None; let mut bin_override: Option = None; + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; let mut i = 1; while i < args.len() { @@ -68,6 +80,12 @@ fn parse_script_args() -> ScriptArgs { bin_override = args.get(i + 1).cloned(); i += 2; } + "--timeout-secs" => { + if let Some(value) = args.get(i + 1).and_then(|value| value.parse().ok()) { + timeout_secs = value; + } + i += 2; + } other if !other.starts_with('-') && test_dir.is_none() => { test_dir = Some(other.to_string()); i += 1; @@ -81,6 +99,7 @@ fn parse_script_args() -> ScriptArgs { ScriptArgs { bin: bin_override.unwrap_or_else(default_binary), test_dir: test_dir.unwrap_or_else(default_test_dir), + timeout: Duration::from_secs(timeout_secs), } } @@ -202,28 +221,32 @@ fn main() { print_binary_version("uffs-vss-requestor:", &helper_binary_path(&args.bin)); eprintln!(); - eprintln!(" Running: {} --self-test-vss {}", args.bin, args.test_dir); + eprintln!( + " Running: {} --self-test-vss {} (timeout: {}s)", + args.bin, + args.test_dir, + args.timeout.as_secs() + ); eprintln!(" ─────────────────────────────────────────────────────────────────"); - // `Stdio::inherit()` + `.status()` — deliberately NOT `.output()`. + // `Stdio::inherit()` + `.spawn()` — deliberately NOT `.output()`. // `.output()` buffers the child's entire stdout/stderr and only // hands it back once the process exits, which silently swallowed // every `tracing::info!` progress line the Broker prints while a // snapshot is being created: the whole point of that instrumentation // is to show which step a hang is stuck on *while it's stuck*, not // after the fact. Inheriting stdio streams the Broker's own output - // straight to this terminal in real time instead. - let status = Command::new(&args.bin) + // straight to this terminal in real time instead. `.spawn()` (not + // `.status()`) so the loop below can poll and kill on timeout — a + // hung helper connection previously blocked this script forever. + let mut child = match Command::new(&args.bin) .arg("--self-test-vss") .arg(&args.test_dir) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) - .status(); - - let elapsed_ms = script_start.elapsed().as_millis(); - - let status = match status { - Ok(status) => status, + .spawn() + { + Ok(child) => child, Err(err) => { eprintln!(" {} failed to spawn {}: {err}", "✗".red(), args.bin); eprintln!( @@ -233,19 +256,59 @@ fn main() { } }; + let deadline = Instant::now() + args.timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => {} + Err(err) => { + eprintln!(" {} failed to poll child process: {err}", "✗".red()); + std::process::exit(1); + } + } + if Instant::now() >= deadline { + eprintln!( + " {} timed out after {}s — killing the Broker process", + "✗".red(), + args.timeout.as_secs() + ); + if let Err(err) = child.kill() { + eprintln!(" {} failed to kill timed-out process: {err}", "✗".red()); + } + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(200)); + }; + + let elapsed_ms = script_start.elapsed().as_millis(); + eprintln!(" ─────────────────────────────────────────────────────────────────"); - if status.success() { - eprintln!( - " {} VSS create/read/delete round trip passed ({elapsed_ms}ms)", - "✓".green() - ); - } else { - eprintln!( - " {} VSS create/read/delete round trip failed ({elapsed_ms}ms)", - "✗".red() - ); + match status { + Some(status) if status.success() => { + eprintln!( + " {} VSS create/read/delete round trip passed ({elapsed_ms}ms)", + "✓".green() + ); + eprintln!(); + std::process::exit(0); + } + Some(status) => { + eprintln!( + " {} VSS create/read/delete round trip failed ({elapsed_ms}ms)", + "✗".red() + ); + eprintln!(); + std::process::exit(status.code().unwrap_or(1)); + } + None => { + eprintln!( + " {} VSS create/read/delete round trip timed out ({elapsed_ms}ms) — the last \ + \"vss: ...\"/\"self-test: ...\" line printed above is where it was stuck", + "✗".red() + ); + eprintln!(); + std::process::exit(124); + } } - eprintln!(); - - std::process::exit(status.code().unwrap_or(1)); } From 57b687c65695c5f06d1dd2d26c1e718657367e49 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:40:47 -0700 Subject: [PATCH 24/98] feat(scripts): add a live helper-process watchdog, drop timeout to 30s Folds the manual "open a second terminal and run tasklist" check we kept doing by hand into the script itself: a background thread polls for uffs-vss-requestor.exe every second and logs RUNNING/NOT RUNNING whenever its liveness changes, so a hang shows immediately whether the helper is still alive or already gone -- no more asking the operator to check by hand. Also drops the default timeout from 120s to 30s per observed round-trip timings (snapshot creation and deletion both take low single-digit seconds in practice), so a genuine hang is reported much sooner. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/vss-snapshot-validation.rs | 76 +++++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs index aac32d6c6..e8ce2c9ca 100644 --- a/scripts/windows/vss-snapshot-validation.rs +++ b/scripts/windows/vss-snapshot-validation.rs @@ -34,24 +34,34 @@ // both land in target/release/) // // Usage: + +// rust-script scripts/windows/vss-snapshot-validation.rs + // rust-script scripts/windows/vss-snapshot-validation.rs -// rust-script scripts/windows/vss-snapshot-validation.rs C:\Temp\uffs-vss-test -// rust-script scripts/windows/vss-snapshot-validation.rs --bin path\to\uffs-broker.exe -// rust-script scripts/windows/vss-snapshot-validation.rs --timeout-secs 300 +// C:\Temp\uffs-vss-test + +// rust-script scripts/windows/vss-snapshot-validation.rs --bin +// path\to\uffs-broker.exe + +// rust-script scripts/windows/vss-snapshot-validation.rs --timeout-secs 10 use std::path::PathBuf; use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use colored::Colorize; /// How long to wait for `--self-test-vss` before killing it and -/// reporting a timeout, absent a `--timeout-secs` override. VSS_CTX_ -/// FILE_SHARE_BACKUP snapshot creation with no writer participation is -/// normally a few seconds; 120s gives real disk activity plenty of -/// room without hanging forever the way a stuck helper connection did -/// before this timeout existed. -const DEFAULT_TIMEOUT_SECS: u64 = 120; +/// reporting a timeout, absent a `--timeout-secs` override. Both halves +/// of the round trip observed so far (snapshot creation, deletion) take +/// low single-digit seconds; 30s is generous headroom without leaving +/// a genuine hang sitting unreported for minutes. +const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// How often the helper-process watchdog re-checks `tasklist`. +const WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); /// Parsed script arguments. struct ScriptArgs { @@ -197,6 +207,48 @@ fn print_binary_version(label: &str, path: &std::path::Path) { } } +/// Whether any `uffs-vss-requestor.exe` process currently exists, +/// checked via `tasklist` (the same manual check we kept asking for by +/// hand while diagnosing a hang) — folded into the script itself so a +/// hang shows *live* whether the helper is still alive or already gone, +/// without a second terminal. +fn helper_process_running() -> bool { + Command::new("tasklist") + .args(["/FI", "IMAGENAME eq uffs-vss-requestor.exe", "/NH"]) + .output() + .is_ok_and(|output| { + String::from_utf8_lossy(&output.stdout) + .to_lowercase() + .contains("uffs-vss-requestor.exe") + }) +} + +/// Spawn a background thread that logs `uffs-vss-requestor.exe: RUNNING` +/// / `NOT RUNNING` to the terminal every time its liveness changes, +/// until `stop` is set. Returns the thread's `JoinHandle` so the caller +/// can `stop` then `join` it once the round trip finishes. +fn spawn_helper_watchdog(stop: &Arc) -> std::thread::JoinHandle<()> { + let stop = Arc::clone(stop); + std::thread::spawn(move || { + let mut last_seen_running: Option = None; + while !stop.load(Ordering::Relaxed) { + let running = helper_process_running(); + if last_seen_running != Some(running) { + if running { + eprintln!(" [watchdog] {} uffs-vss-requestor.exe", "RUNNING".green()); + } else { + eprintln!( + " [watchdog] {} uffs-vss-requestor.exe", + "NOT RUNNING".yellow() + ); + } + last_seen_running = Some(running); + } + std::thread::sleep(WATCHDOG_POLL_INTERVAL); + } + }) +} + fn main() { let script_start = Instant::now(); let args = parse_script_args(); @@ -256,6 +308,9 @@ fn main() { } }; + let watchdog_stop = Arc::new(AtomicBool::new(false)); + let watchdog = spawn_helper_watchdog(&watchdog_stop); + let deadline = Instant::now() + args.timeout; let status = loop { match child.try_wait() { @@ -281,6 +336,9 @@ fn main() { std::thread::sleep(Duration::from_millis(200)); }; + watchdog_stop.store(true, Ordering::Relaxed); + let _ = watchdog.join(); + let elapsed_ms = script_start.elapsed().as_millis(); eprintln!(" ─────────────────────────────────────────────────────────────────"); From fe7de693414517f6714ead3557db062a60e608e5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:52:54 -0700 Subject: [PATCH 25/98] fix(vss-requestor): drop DeleteSnapshots, use auto-release for delete too Real hardware testing (via the new self-test-vss watchdog) proved create/read fully work end to end, but every Release command hung indefinitely with uffs-vss-requestor.exe confirmed still alive the whole time -- IVssBackupComponents::DeleteSnapshots was the one call that never returned. VSS_CTX_FILE_SHARE_BACKUP is documented as an auto-release context; the intended teardown is releasing the last IVssBackupComponents reference, which this shim already did correctly on every other exit path (Cancel/PipeClosed/ParentDied) and which the successful create path already proved COM itself is responsive for. Removed uffs_vss_delete_snapshot_set (native + FFI + Rust wrapper) entirely rather than leave a call known to hang, and Release now uses the same drop-based teardown as the other three paths. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/native/vss_shim.cpp | 32 +++++----------- crates/uffs-vss-requestor/native/vss_shim.h | 31 +++++++-------- crates/uffs-vss-requestor/src/ffi.rs | 5 --- crates/uffs-vss-requestor/src/run.rs | 38 ++++++++----------- crates/uffs-vss-requestor/src/snapshot.rs | 22 ----------- 5 files changed, 38 insertions(+), 90 deletions(-) diff --git a/crates/uffs-vss-requestor/native/vss_shim.cpp b/crates/uffs-vss-requestor/native/vss_shim.cpp index ea8a7c1e9..4bb11f89c 100644 --- a/crates/uffs-vss-requestor/native/vss_shim.cpp +++ b/crates/uffs-vss-requestor/native/vss_shim.cpp @@ -13,6 +13,15 @@ // an application-consistent backup, so no writer coordination // (`GatherWriterMetadata`, `PrepareForBackup`, `BackupComplete`) is // performed; the guide notes these are not valid in the no-writer flow. +// +// Deletion is release-only (`uffs_vss_session_release`), never +// `IVssBackupComponents::DeleteSnapshots`: an earlier version of this +// shim called `DeleteSnapshots` explicitly for deterministic +// normal-completion cleanup, but that call was observed to hang +// indefinitely on real Windows hardware when used on a +// `VSS_CTX_FILE_SHARE_BACKUP` snapshot set. Releasing the last +// `IVssBackupComponents` reference is this context's documented +// auto-release mechanism and is the only deletion path left. #include "vss_shim.h" @@ -28,7 +37,6 @@ // backup/restore/query operation. struct UffsVssSession { IVssBackupComponents *backup_components = nullptr; - GUID snapshot_set_id = GUID_NULL; bool com_initialized = false; }; @@ -173,7 +181,6 @@ int32_t uffs_vss_create_file_share_snapshot( destroy_session(session); return hr; } - session->snapshot_set_id = snapshot_set_id; VSS_ID snapshot_id = GUID_NULL; hr = backup_components->AddToSnapshotSet(const_cast(volume_path), GUID_NULL, &snapshot_id); @@ -236,27 +243,6 @@ int32_t uffs_vss_create_file_share_snapshot( return S_OK; } -int32_t uffs_vss_delete_snapshot_set(UffsVssSession *session, UffsVssError *out_error) { - if (session == nullptr || session->backup_components == nullptr) { - set_error(out_error, E_INVALIDARG, UFFS_VSS_STAGE_INVALID_ARGUMENT, L"null or already-torn-down session"); - return E_INVALIDARG; - } - - LONG deleted_count = 0; - VSS_ID first_non_deleted = GUID_NULL; - HRESULT hr = session->backup_components->DeleteSnapshots( - session->snapshot_set_id, - VSS_OBJECT_SNAPSHOT_SET, - TRUE, - &deleted_count, - &first_non_deleted); - if (FAILED(hr)) { - set_error(out_error, hr, UFFS_VSS_STAGE_DELETE_SET, L"DeleteSnapshots failed"); - return hr; - } - return S_OK; -} - void uffs_vss_session_release(UffsVssSession *session) { destroy_session(session); } diff --git a/crates/uffs-vss-requestor/native/vss_shim.h b/crates/uffs-vss-requestor/native/vss_shim.h index f49475201..142b51eb6 100644 --- a/crates/uffs-vss-requestor/native/vss_shim.h +++ b/crates/uffs-vss-requestor/native/vss_shim.h @@ -37,8 +37,7 @@ typedef enum UffsVssStage { UFFS_VSS_STAGE_DO_SET_WAIT = 8, UFFS_VSS_STAGE_DO_SET_STATUS = 9, UFFS_VSS_STAGE_GET_PROPERTIES = 10, - UFFS_VSS_STAGE_DELETE_SET = 11, - UFFS_VSS_STAGE_INVALID_ARGUMENT = 12, + UFFS_VSS_STAGE_INVALID_ARGUMENT = 11, } UffsVssStage; typedef struct UffsVssSnapshotInfo { @@ -68,11 +67,15 @@ typedef struct UffsVssError { // `IVssBackupComponents`, kept alive until `uffs_vss_session_release`; // `*out_info` is populated and must be released via // `uffs_vss_snapshot_info_free`. Because the context is auto-release, -// the underlying snapshot is deleted the moment the session is released -// without an explicit `uffs_vss_delete_snapshot_set` call — that is the -// crash-safety net; normal completion should still call -// `uffs_vss_delete_snapshot_set` first for deterministic, observable -// cleanup. +// the underlying snapshot is deleted the moment `uffs_vss_session_release` +// drops the last reference to `IVssBackupComponents` — that is the +// *only* deletion path this shim exposes. There used to be a separate +// `uffs_vss_delete_snapshot_set` (calling `IVssBackupComponents:: +// DeleteSnapshots`) for deterministic cleanup on normal completion, but +// that call was observed to hang indefinitely on real hardware when +// used on a `VSS_CTX_FILE_SHARE_BACKUP` snapshot set, so it was removed +// entirely rather than left as a landmine — `uffs_vss_session_release` +// is both the crash-safety net and the normal-completion path now. // // On failure: `*out_session` and `*out_info` are zeroed; `*out_error` is // populated and must be released via `uffs_vss_error_free`. @@ -82,17 +85,9 @@ int32_t uffs_vss_create_file_share_snapshot( UffsVssSnapshotInfo *out_info, UffsVssError *out_error); -// Explicitly delete the snapshot set owned by `session`. Does not -// release the session itself — call `uffs_vss_session_release` -// afterward regardless of the outcome here. -int32_t uffs_vss_delete_snapshot_set( - UffsVssSession *session, - UffsVssError *out_error); - -// Release `session` (a no-op if `session` is `NULL`). If the snapshot -// set was never explicitly deleted, this is where the auto-release -// context's actual cleanup happens (releasing the last reference to -// `IVssBackupComponents`). +// Release `session` (a no-op if `session` is `NULL`) — the only +// deletion path; see `uffs_vss_create_file_share_snapshot`'s doc +// comment above for why. void uffs_vss_session_release(UffsVssSession *session); void uffs_vss_snapshot_info_free(UffsVssSnapshotInfo *info); diff --git a/crates/uffs-vss-requestor/src/ffi.rs b/crates/uffs-vss-requestor/src/ffi.rs index 72a8923ee..eb4d586f9 100644 --- a/crates/uffs-vss-requestor/src/ffi.rs +++ b/crates/uffs-vss-requestor/src/ffi.rs @@ -141,11 +141,6 @@ unsafe extern "C" { out_error: *mut VssError, ) -> i32; - pub(crate) fn uffs_vss_delete_snapshot_set( - session: *mut Session, - out_error: *mut VssError, - ) -> i32; - pub(crate) fn uffs_vss_session_release(session: *mut Session); pub(crate) fn uffs_vss_snapshot_info_free(info: *mut SnapshotInfo); pub(crate) fn uffs_vss_error_free(error: *mut VssError); diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index 039e12a7b..c1e695105 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -96,7 +96,7 @@ pub(crate) fn run() -> anyhow::Result<()> { .try_clone() .map_err(|err| anyhow::anyhow!("failed to clone pipe handle for reading: {err}"))?; - let mut session = match VssSnapshotSession::create(&args.volume_path) { + let session = match VssSnapshotSession::create(&args.volume_path) { Ok((session, descriptor)) => { protocol::write_event(&mut writer, &HelperEvent::Ready { snapshot_set_id: descriptor.snapshot_set_id, @@ -150,30 +150,24 @@ pub(crate) fn run() -> anyhow::Result<()> { MainEvent::Command(BrokerCommand::Ping) => { drop(protocol::write_event(&mut writer, &HelperEvent::Pong)); } - MainEvent::Command(BrokerCommand::Release) => { - match session.delete_snapshot_set() { - Ok(()) => { - drop(protocol::write_event(&mut writer, &HelperEvent::Released)); - } - Err(err) => { - drop(protocol::write_event(&mut writer, &HelperEvent::Failed { - stage: err.stage, - hresult: err.hresult, - message: err.message, - })); - } - } - drop(session); - return Ok(()); - } - MainEvent::Command(BrokerCommand::Cancel) + MainEvent::Command(BrokerCommand::Release | BrokerCommand::Cancel) | MainEvent::PipeClosed | MainEvent::ParentDied => { - // Auto-release path: dropping `session` releases the - // last `IVssBackupComponents` reference, which is where - // `VSS_CTX_FILE_SHARE_BACKUP`'s auto-delete happens if - // the snapshot set was never explicitly deleted. + // `VSS_CTX_FILE_SHARE_BACKUP` is an auto-release context: + // dropping `session` releases the last + // `IVssBackupComponents` reference, which is where the + // actual deletion happens. This used to be a separate + // path (explicit `DeleteSnapshots` on `Release`, drop-only + // on every other exit) — `DeleteSnapshots` was observed + // to hang indefinitely on real hardware when called on a + // `VSS_CTX_FILE_SHARE_BACKUP` snapshot set, so `Release` + // now uses the exact same drop-based teardown the other + // three paths already relied on. + let is_release = matches!(event, MainEvent::Command(BrokerCommand::Release)); drop(session); + if is_release { + drop(protocol::write_event(&mut writer, &HelperEvent::Released)); + } return Ok(()); } } diff --git a/crates/uffs-vss-requestor/src/snapshot.rs b/crates/uffs-vss-requestor/src/snapshot.rs index 23229249a..c27054c57 100644 --- a/crates/uffs-vss-requestor/src/snapshot.rs +++ b/crates/uffs-vss-requestor/src/snapshot.rs @@ -97,28 +97,6 @@ impl VssSnapshotSession { Ok((Self { raw: raw_session }, descriptor)) } - - /// Explicitly delete this session's snapshot set (deterministic - /// cleanup on normal completion — see [`Drop`] for the crash-safety - /// net). - /// - /// # Errors - /// Returns [`VssRequestError`] if `DeleteSnapshots` fails. - #[expect( - unsafe_code, - reason = "calls into the native VSS shim; see the inline SAFETY comment" - )] - pub(crate) fn delete_snapshot_set(&mut self) -> Result<(), VssRequestError> { - let mut error = ffi::VssError::zeroed(); - // SAFETY: `self.raw` is a valid session handle for `self`'s - // entire lifetime (released exactly once, in `Drop`); `error` is - // a stack-owned out-parameter. - let hresult = unsafe { ffi::uffs_vss_delete_snapshot_set(self.raw, &raw mut error) }; - if hresult < 0_i32 { - return Err(take_error(&mut error)); - } - Ok(()) - } } impl Drop for VssSnapshotSession { From a2298d39566dad190308fbfc4a49d99ca2d853c8 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:25:12 -0700 Subject: [PATCH 26/98] feat(vss-requestor): add file-based debug log for the delete-path hang The DeleteSnapshots removal didn't fix the hang -- it persisted identically with a plain IVssBackupComponents::Release() call, and restarting the machine's stuck VSS writers (System/MSSearch/WMI, found via vssadmin list writers) didn't clear it either. The Broker's own tracing has zero visibility past "waiting for Released confirmation"; we don't know if the helper is stuck in Rust dispatch, the mpsc channel, or inside the native Release()/CoUninitialize() call itself. Adds a best-effort append-only log at %TEMP%\uffs-vss-requestor-debug.log bracketing every step: pipe connect, snapshot creation, entering the main loop, each received event, and explicitly before/after drop(session) and the Released write. If "session dropped" never appears, that pins the hang inside destroy_session's native COM calls; if it does appear but "wrote Released event" doesn't, the hang is in the pipe write instead. Diagnostic-only -- not a permanent feature. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/src/run.rs | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index c1e695105..a7125a29b 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -74,6 +74,7 @@ fn next_value(args: &mut impl Iterator, flag: &str) -> anyhow::Re /// Broker, the pipe closing, or the parent process dying (a second, /// independent safety net alongside the Job Object the Broker assigns /// this process to). +#[derive(Debug)] enum MainEvent { /// A decoded command arrived from the Broker. Command(BrokerCommand), @@ -83,6 +84,30 @@ enum MainEvent { ParentDied, } +/// Append a timestamped line to `%TEMP%\uffs-vss-requestor-debug.log`, +/// silently doing nothing on failure. +/// +/// Exists purely for troubleshooting: the Broker's own tracing has +/// visibility only up to "helper connected"/"waiting for Released +/// confirmation" — nothing inside this process. Never load-bearing; +/// this is diagnostic infrastructure for a hang that survived multiple +/// hypotheses (command-line quoting, `DeleteSnapshots` vs. auto-release, +/// stuck VSS writers), not a permanent feature. +fn debug_log(message: &str) { + use std::io::Write as _; + let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(std::env::temp_dir().join("uffs-vss-requestor-debug.log")) + else { + return; + }; + let millis_since_epoch = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()); + let _write_result = writeln!(file, "[{millis_since_epoch}] {message}"); +} + /// Run the helper end to end. /// /// # Errors @@ -90,14 +115,17 @@ enum MainEvent { /// connected, or the initial snapshot creation fails (after reporting /// [`HelperEvent::Failed`] to the Broker). pub(crate) fn run() -> anyhow::Result<()> { + debug_log("run() started"); let args = Args::parse()?; let mut writer = pipe::connect(&args.pipe_name)?; + debug_log("connected to control pipe"); let reader_file = writer .try_clone() .map_err(|err| anyhow::anyhow!("failed to clone pipe handle for reading: {err}"))?; let session = match VssSnapshotSession::create(&args.volume_path) { Ok((session, descriptor)) => { + debug_log("snapshot created; writing Ready event"); protocol::write_event(&mut writer, &HelperEvent::Ready { snapshot_set_id: descriptor.snapshot_set_id, snapshot_id: descriptor.snapshot_id, @@ -145,7 +173,9 @@ pub(crate) fn run() -> anyhow::Result<()> { }); drop(event_tx); + debug_log("entering main event loop"); for event in event_rx { + debug_log(&format!("received event: {event:?}")); match event { MainEvent::Command(BrokerCommand::Ping) => { drop(protocol::write_event(&mut writer, &HelperEvent::Pong)); @@ -164,10 +194,15 @@ pub(crate) fn run() -> anyhow::Result<()> { // now uses the exact same drop-based teardown the other // three paths already relied on. let is_release = matches!(event, MainEvent::Command(BrokerCommand::Release)); + debug_log("dropping session (releases IVssBackupComponents)"); drop(session); + debug_log("session dropped"); if is_release { + debug_log("writing Released event"); drop(protocol::write_event(&mut writer, &HelperEvent::Released)); + debug_log("wrote Released event"); } + debug_log("run() returning Ok"); return Ok(()); } } From f592aaea37d0d394d2ca34bcf05fd63df409893b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:38:40 -0700 Subject: [PATCH 27/98] feat(vss-requestor): bracket writeln! and flush separately in write_event The debug log proved drop(session) (native VSS release) completes in ~11ms -- the hang is entirely in the final pipe write, not VSS/COM. All writers already confirmed Stable (post-reboot) with the hang still identical, ruling that out too. Splits write_event's single "writing Released event" checkpoint into three: about to writeln!, writeln! returned, and flush returned, so the log pinpoints whether the raw WriteFile call itself never returns or whether it's the flush. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/src/protocol.rs | 6 +++++- crates/uffs-vss-requestor/src/run.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/uffs-vss-requestor/src/protocol.rs b/crates/uffs-vss-requestor/src/protocol.rs index de4c4d29c..83608d708 100644 --- a/crates/uffs-vss-requestor/src/protocol.rs +++ b/crates/uffs-vss-requestor/src/protocol.rs @@ -69,8 +69,12 @@ pub(crate) fn write_event( ) -> std::io::Result<()> { let line = serde_json::to_string(event) .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + crate::run::debug_log(&format!("write_event: about to writeln! {line:?}")); writeln!(writer, "{line}")?; - writer.flush() + crate::run::debug_log("write_event: writeln! returned; about to flush"); + let flush_result = writer.flush(); + crate::run::debug_log("write_event: flush returned"); + flush_result } /// Read one [`BrokerCommand`] line, or `Ok(None)` at EOF — the pipe diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index a7125a29b..d1c806e9b 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -93,7 +93,7 @@ enum MainEvent { /// this is diagnostic infrastructure for a hang that survived multiple /// hypotheses (command-line quoting, `DeleteSnapshots` vs. auto-release, /// stuck VSS writers), not a permanent feature. -fn debug_log(message: &str) { +pub(crate) fn debug_log(message: &str) { use std::io::Write as _; let Ok(mut file) = std::fs::OpenOptions::new() .create(true) From 7469c76b58cac2e9cdcbc65d2dae7217a868b22d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:12:24 -0700 Subject: [PATCH 28/98] fix(vss-requestor): fix the actual named-pipe deadlock (Ping too, not just Release) Root cause found via the debug log: it's neither VSS/COM (drop(session) took ~11ms), AV (disabled, hang identical), nor stuck VSS writers (rebooted, all Stable, hang identical). It's Windows serializing synchronous I/O across duplicate handles of one file object from different threads: the reader thread's next blocking read (waiting for a message that will never arrive) sat pending on a clone of the same non-overlapped pipe handle the main thread tried to write the final reply on, deadlocking that write forever. The prior commit's fix only covered Release/Cancel; Ping needed the identical fix, since it's meant to support repeated liveness checks over a lease's lifetime and would have hit the exact same deadlock on its first Pong reply once a real Broker used it. Ping/Pong is now handled entirely inside the reader thread (no session access needed, no cross-thread handle interleaving at all); only Release/Cancel are forwarded to the main thread, which stops the reader immediately after so its read is never concurrently pending with the main thread's teardown-and-final-write. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/src/run.rs | 34 ++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index d1c806e9b..c70a480c8 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -150,18 +150,42 @@ pub(crate) fn run() -> anyhow::Result<()> { let (event_tx, event_rx) = mpsc::channel::(); + let mut ping_writer = writer + .try_clone() + .map_err(|err| anyhow::anyhow!("failed to clone pipe handle for Ping replies: {err}"))?; let reader_tx = event_tx.clone(); std::thread::spawn(move || { let mut reader = BufReader::new(reader_file); loop { - if let Ok(Some(command)) = protocol::read_command(&mut reader) { - if reader_tx.send(MainEvent::Command(command)).is_err() { - return; - } - } else { + let Ok(Some(command)) = protocol::read_command(&mut reader) else { drop(reader_tx.send(MainEvent::PipeClosed)); return; + }; + if matches!(command, BrokerCommand::Ping) { + // Handled entirely on this thread — Ping/Pong needs no + // session access, and replying here (rather than + // routing through the main thread, which would write on + // a *different* clone of this same non-overlapped pipe + // handle) avoids ever having two threads perform I/O on + // it at once. See below for why that matters: it was a + // real, 100%-reproducible hang on real hardware. + drop(protocol::write_event(&mut ping_writer, &HelperEvent::Pong)); + continue; } + // Only `Release`/`Cancel` reach here, and both are + // terminal: the main thread is about to tear down the + // session and write a final reply on its own clone of this + // same pipe handle. Windows serializes synchronous I/O + // across duplicate handles of one file object from + // different threads, so leaving this thread's read pending + // past this point would deadlock that final write against + // a read that will never be satisfied — the Broker never + // sends anything after Release/Cancel. Proved via + // `debug_log`: the write's `writeln!` call never returned + // while this thread's next read sat pending, independent of + // VSS/COM, AV, or VSS writer health, all ruled out first. + drop(reader_tx.send(MainEvent::Command(command))); + return; } }); From 2fb094c28ac291a1b0efc17c2ecba9b8140ec340 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:21:41 -0700 Subject: [PATCH 29/98] feat(broker): make Ping/Pong a real, tested wire round trip Ping had no caller anywhere -- the deadlock fix for it was reviewed but never actually exercised end to end. Adds WindowsVssProvider::ping_lease, sending a real BrokerCommand::Ping to the live helper session and waiting for HelperEvent::Pong (not part of the VssProvider trait -- FakeVssProvider has no use for it yet, so it stays a WindowsVssProvider-only capability until a real feature needs it). --self-test-vss now calls it between marker verification and deletion, so the create/ping/delete round trip is proven for real, not just reviewed. A ping_lease failure surfaces as an overall self-test failure without skipping the cleanup delete. Co-Authored-By: Claude Sonnet 5 --- .../src/broker/snapshot_manager/vss_helper.rs | 67 +++++++++++++++++++ .../broker/snapshot_manager/vss_self_test.rs | 25 ++++++- crates/uffs-vss-requestor/src/run.rs | 2 + 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index 99800a123..fd4777a5c 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -80,6 +80,8 @@ enum HelperEvent { enum BrokerCommand { /// Delete the snapshot set and exit. Release, + /// Liveness check; expects a [`HelperEvent::Pong`] reply. + Ping, } /// One live helper-process session. @@ -184,6 +186,71 @@ impl WindowsVssProvider { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } + + /// Send [`BrokerCommand::Ping`] to the live helper session for + /// `snapshot_id` and wait for its [`HelperEvent::Pong`] reply — a + /// real, wire-level round trip, not a bookkeeping-only check. + /// + /// `Ping` has no production caller yet (no periodic liveness check + /// is wired up to a lease's lifetime); this exists purely so + /// `--self-test-vss` can prove the Ping/Pong path actually works end + /// to end the same way it already proves Ready/Release do — + /// catching the exact class of named-pipe deadlock bug that hit + /// `Release` before this path existed to exercise `Ping` too. Not + /// part of the [`VssProvider`] trait: it has no cross-platform + /// meaning for the `FakeVssProvider` unit tests, so it stays a + /// `WindowsVssProvider`-only capability until something real needs + /// it on the trait. + /// + /// # Errors + /// Returns an error if there's no live session for `snapshot_id`, + /// the write fails, or the helper doesn't reply with `Pong`. + pub(crate) fn ping_lease(&self, snapshot_id: &[u8]) -> anyhow::Result<()> { + let mut session = self + .lock_sessions() + .remove(snapshot_id) + .ok_or_else(|| anyhow::anyhow!("no live session for this snapshot"))?; + + tracing::info!("vss: sending Ping to helper"); + let ping_result = send_ping_and_await_pong(&mut session); + if ping_result.is_ok() { + tracing::info!("vss: received Pong"); + } + + self.lock_sessions().insert(snapshot_id.to_vec(), session); + ping_result + } +} + +/// Write [`BrokerCommand::Ping`] on `session`'s writer and block until +/// its [`HelperEvent::Pong`] reply — the body of +/// [`WindowsVssProvider::ping_lease`], split out so that function can +/// always reinsert `session` regardless of outcome. +fn send_ping_and_await_pong(session: &mut HelperSession) -> anyhow::Result<()> { + let line = serde_json::to_string(&BrokerCommand::Ping) + .map_err(|err| anyhow::anyhow!("failed to encode Ping: {err}"))?; + writeln!(session.writer, "{line}") + .and_then(|()| session.writer.flush()) + .map_err(|err| anyhow::anyhow!("failed to send Ping: {err}"))?; + + let event = read_helper_event(&mut session.reader) + .map_err(|err| anyhow::anyhow!("failed to read helper response: {err}"))?; + match event { + Some(HelperEvent::Pong) => Ok(()), + Some(HelperEvent::Failed { + stage, + hresult, + message, + }) => Err(anyhow::anyhow!( + "stage={stage} hresult={hresult:#x}: {message}" + )), + Some(HelperEvent::Ready { .. } | HelperEvent::Released) => { + Err(anyhow::anyhow!("unexpected event from helper after Ping")) + } + None => Err(anyhow::anyhow!( + "helper closed the control pipe before replying to Ping" + )), + } } impl WindowsVssProvider { diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs index f04ec6996..cb6ab00df 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs @@ -97,13 +97,34 @@ fn run_self_test_round_trip(marker_path: &Path, drive_root: &Path) -> anyhow::Re tracing::info!("self-test: marker verified"); } + let ping_result = ping_lease_and_log(&provider, &handle.snapshot_id); + if let Err(err) = provider.delete_snapshot(&handle.snapshot_id) { tracing::warn!(error = %err, "self-test: delete_snapshot failed"); - if verify_result.is_ok() { + if verify_result.is_ok() && ping_result.is_ok() { return Err(anyhow::anyhow!("delete_snapshot failed: {err}")); } } - verify_result + verify_result.and(ping_result) +} + +/// Real, wire-level proof the Ping/Pong path works — `Ping` has no +/// production caller yet, so this is the only place it's ever +/// exercised. Split out of [`run_self_test_round_trip`] to keep that +/// function's cognitive complexity down. +/// +/// Failure here doesn't abort the round trip (Release still needs to +/// run to clean up the snapshot either way), but does turn the overall +/// self-test result into an error so a regression can't hide behind an +/// otherwise-green create/delete run. +fn ping_lease_and_log(provider: &WindowsVssProvider, snapshot_id: &[u8]) -> anyhow::Result<()> { + let ping_result = provider + .ping_lease(snapshot_id) + .map_err(|err| anyhow::anyhow!("ping_lease failed: {err}")); + if let Err(err) = &ping_result { + tracing::warn!(error = %err, "self-test: ping_lease failed"); + } + ping_result } /// Read `marker_path` back through `handle`'s snapshot device path and diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index c70a480c8..23ce4675e 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -169,7 +169,9 @@ pub(crate) fn run() -> anyhow::Result<()> { // handle) avoids ever having two threads perform I/O on // it at once. See below for why that matters: it was a // real, 100%-reproducible hang on real hardware. + debug_log("received Ping; writing Pong"); drop(protocol::write_event(&mut ping_writer, &HelperEvent::Pong)); + debug_log("wrote Pong"); continue; } // Only `Release`/`Cancel` reach here, and both are From 8f1568ab96490b0160850daa057910a9bea6a511 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:38:12 -0700 Subject: [PATCH 30/98] fix(vss-requestor): gate the debug log off by default, cap its size A real deployment spawns one helper per content-scan job; unconditional logging would grow an unbounded file over millions of runs. debug_log is now opt-in via UFFS_VSS_DEBUG_LOG (unset = production-safe no-op) with a 10MB truncate-before-append cap as a safety net for an extended troubleshooting session. The validation script sets the env var automatically for the Broker it spawns, since that script only ever runs for diagnostics -- no manual step needed to get the log back. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/src/run.rs | 30 +++++++++++++++++++--- scripts/windows/vss-snapshot-validation.rs | 5 ++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index 23ce4675e..896f37b8f 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -84,21 +84,45 @@ enum MainEvent { ParentDied, } +/// Env var gating [`debug_log`] — unset by default. A real deployment +/// spawns one helper per content-scan job; without this gate, millions +/// of runs over time would grow an unbounded log file. Set to any value +/// (e.g. `UFFS_VSS_DEBUG_LOG=1`) while troubleshooting; the Broker +/// inherits its environment to this helper automatically (`spawn_helper` +/// passes `None` for `CreateProcessW`'s environment block), so setting +/// it on the Broker process before it spawns a helper is enough. +const DEBUG_LOG_ENV_VAR: &str = "UFFS_VSS_DEBUG_LOG"; + +/// Once the debug log exceeds this size, it's truncated before the next +/// append — a safety net in case [`DEBUG_LOG_ENV_VAR`] is left set for +/// an extended troubleshooting session rather than a single repro. +const DEBUG_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024; + /// Append a timestamped line to `%TEMP%\uffs-vss-requestor-debug.log`, -/// silently doing nothing on failure. +/// silently doing nothing on failure or when [`DEBUG_LOG_ENV_VAR`] +/// isn't set. /// /// Exists purely for troubleshooting: the Broker's own tracing has /// visibility only up to "helper connected"/"waiting for Released /// confirmation" — nothing inside this process. Never load-bearing; /// this is diagnostic infrastructure for a hang that survived multiple /// hypotheses (command-line quoting, `DeleteSnapshots` vs. auto-release, -/// stuck VSS writers), not a permanent feature. +/// stuck VSS writers) before finding the real one, not a permanent +/// feature that runs unconditionally. pub(crate) fn debug_log(message: &str) { use std::io::Write as _; + + if std::env::var_os(DEBUG_LOG_ENV_VAR).is_none() { + return; + } + let path = std::env::temp_dir().join("uffs-vss-requestor-debug.log"); + if std::fs::metadata(&path).is_ok_and(|metadata| metadata.len() > DEBUG_LOG_MAX_BYTES) { + drop(std::fs::remove_file(&path)); + } let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) - .open(std::env::temp_dir().join("uffs-vss-requestor-debug.log")) + .open(&path) else { return; }; diff --git a/scripts/windows/vss-snapshot-validation.rs b/scripts/windows/vss-snapshot-validation.rs index e8ce2c9ca..598e37d10 100644 --- a/scripts/windows/vss-snapshot-validation.rs +++ b/scripts/windows/vss-snapshot-validation.rs @@ -291,9 +291,14 @@ fn main() { // straight to this terminal in real time instead. `.spawn()` (not // `.status()`) so the loop below can poll and kill on timeout — a // hung helper connection previously blocked this script forever. + // This script only ever runs for diagnostics — the Broker inherits + // this env var to the uffs-vss-requestor.exe it spawns, enabling its + // otherwise-off-by-default debug log (see run.rs's DEBUG_LOG_ENV_VAR + // doc comment) automatically, with no manual step needed. let mut child = match Command::new(&args.bin) .arg("--self-test-vss") .arg(&args.test_dir) + .env("UFFS_VSS_DEBUG_LOG", "1") .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .spawn() From 2a90507ba238094db82fee06b22092a7f74ead7e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:42:52 -0700 Subject: [PATCH 31/98] feat(broker): add a real Failed-path check to --self-test-vss Ready, Pong, and Released are now proven working on real hardware, but Failed -- the one wire event with an actual production trigger (any real VSS creation failure: disk full, service down, a bad volume) -- had zero coverage. Adds self_test_invalid_volume_reports_failure, run at the start of every --self-test-vss invocation: passes an unmistakably invalid volume path to create_snapshot and confirms the helper reports Failed (rather than hanging or somehow succeeding) and the Broker surfaces it as a diagnosable error. An unexpected success cleans up the accidental snapshot and fails loudly, since that would itself be a real bug. Cancel intentionally isn't given the same treatment: it shares Release's already-proven drop-based teardown and never writes a wire reply, so there's no deadlock risk left to prove there, and it has no production caller either. Co-Authored-By: Claude Sonnet 5 --- .../broker/snapshot_manager/vss_self_test.rs | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs index cb6ab00df..687055aab 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_self_test.rs @@ -38,12 +38,18 @@ const SELF_TEST_MARKER_CONTENT: &[u8] = b"uffs-broker --self-test-vss marker"; /// AddToSnapshotSet`, which expects that exact form. /// /// # Errors -/// Returns an error if `test_dir` can't be created, has no root -/// component, the marker file can't be written, snapshot creation -/// fails, the marker can't be read back from the snapshot device path, -/// its content doesn't match, or snapshot deletion fails. +/// Returns an error if the deliberately-invalid-volume `Failed`-path +/// check doesn't behave as expected (see +/// [`self_test_invalid_volume_reports_failure`]), if `test_dir` can't +/// be created, has no root component, the marker file can't be +/// written, snapshot creation fails, the marker can't be read back +/// from the snapshot device path, its content doesn't match, or +/// snapshot deletion fails. pub(crate) fn self_test_round_trip(test_dir: &Path) -> anyhow::Result<()> { tracing::info!(test_dir = %test_dir.display(), "self-test: starting VSS round trip"); + + self_test_invalid_volume_reports_failure()?; + std::fs::create_dir_all(test_dir) .map_err(|err| anyhow::anyhow!("failed to create {}: {err}", test_dir.display()))?; // `Path::ancestors()` walks from the path itself up to the root, so @@ -76,6 +82,55 @@ pub(crate) fn self_test_round_trip(test_dir: &Path) -> anyhow::Result<()> { test_result } +/// Prove the `Failed` wire event actually works: pass an unmistakably +/// invalid volume path to `create_snapshot` and confirm the helper +/// reports failure (rather than hanging, crashing, or somehow +/// succeeding) and the Broker surfaces it as a diagnosable error. +/// `Failed` is the one wire event `Ready`/`Pong`/`Released` didn't +/// already prove works, and unlike those three it has a real production +/// trigger (any actual VSS creation failure — disk full, service down, +/// a bad volume), so it's worth covering even though nothing else in +/// this self-test exercises it. +/// +/// Spawns its own throwaway `uffs-vss-requestor` helper — a fresh +/// [`WindowsVssProvider`], unrelated to the main round trip's snapshot — +/// since this only needs to prove the failure-reporting path. +/// +/// # Errors +/// Returns an error if `create_snapshot` unexpectedly *succeeds* for an +/// obviously-invalid volume path (which would itself be a bug worth +/// knowing about, not silently ignoring). +fn self_test_invalid_volume_reports_failure() -> anyhow::Result<()> { + tracing::info!("self-test: verifying the Failed event path with a deliberately invalid volume"); + let provider = WindowsVssProvider::new(); + let volume = VolumeIdentity { + volume_serial: 0, + volume_guid: Vec::new(), + }; + let bogus_root = utf16le_bytes("not-a-real-volume-path"); + + match provider.create_snapshot(&volume, &bogus_root) { + Ok(handle) => { + // Unexpected success: clean up so a bug here doesn't also + // leak a real snapshot, then fail loudly -- an obviously + // invalid path succeeding is itself the actual problem. + if let Err(err) = provider.delete_snapshot(&handle.snapshot_id) { + tracing::warn!( + error = %err, + "self-test: failed to clean up the unexpectedly-created snapshot" + ); + } + anyhow::bail!( + "expected create_snapshot to fail for an invalid volume path, but it succeeded" + ); + } + Err(err) => { + tracing::info!(error = %err, "self-test: Failed event correctly reported"); + Ok(()) + } + } +} + /// Create the real snapshot, verify `marker_path` round-trips through /// it, and delete it — the body of [`self_test_round_trip`], split out /// so the marker-file cleanup above always runs regardless of outcome. From 5b1756c50139dd57ab16476387cbd32bdb861275 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:40:25 -0700 Subject: [PATCH 32/98] feat(content): real VSS snapshot + privileged Reader content pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the end-to-end UFI.1/UFI.2 ingest flow: uffs-content leases a VSS snapshot per drive via the Broker, spins up one combined ephemeral uffsd instance over all leased device sources for target selection (metadata only), and streams file bytes through a new privileged uffs-content-reader process that opens files by NTFS file reference (OpenFileById) directly against the snapshot device — no MFT/path resolution in the coordinator, no file-capture step, and the resident daemon's drive-letter-keyed caches are never touched by ephemeral device reads. Supporting changes: - uffs-mft: VolumeHandle/MftReader::open_device_path constructor. - uffs-core: MftSource::Device variant (always-fresh, uncached load); DisplayRow gains file_reference so the daemon can hand it back to content-tool queries without touching the hot path. - uffs-client: SearchRow::file_reference; ephemeral_lifecycle_dir/ ephemeral_endpoint as the single source of truth for a job-scoped daemon's PID-file dir and IPC endpoint; UffsClientSync::connect_at. - uffs-daemon: --device/--ephemeral-id, device-source drive loading, ephemeral instances skip live USN journal loops (frozen snapshot data). Moved the broker-warmup helpers into broker_client.rs to keep lib.rs under the file-size policy after this feature's additions. - uffs-content-reader (new crate): pipe_server + read_plan (VDL/EOF zero- synthesis, proptested) + logical (real OpenFileById reads). - uffs-content: VssCandidateSource/VssContentSource, ephemeral_daemon, vss_orchestrator, reader_client, vss_job orchestration, and a real self_test_vss_playback (writes a unique sample file, runs it through the real pipeline, verifies the played-back bytes match) reused by the --self-test-vss-playback CLI flag, an #[ignore] cargo test, and scripts/windows/content-reader-validation.rs. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 22 ++ Cargo.toml | 13 +- .../src/commands/output/output_tests.rs | 1 + crates/uffs-client/src/connect_sync.rs | 24 ++ .../uffs-client/src/connect_sync_platform.rs | 32 +- crates/uffs-client/src/daemon_ctl.rs | 41 +- crates/uffs-client/src/protocol/response.rs | 9 + crates/uffs-client/src/protocol/tests.rs | 2 + crates/uffs-client/src/shmem.rs | 3 + crates/uffs-client/src/shmem_tests.rs | 1 + crates/uffs-content-reader/Cargo.toml | 82 ++++ crates/uffs-content-reader/build.rs | 48 +++ crates/uffs-content-reader/src/main.rs | 99 +++++ crates/uffs-content-reader/src/reader.rs | 94 +++++ .../uffs-content-reader/src/reader/logical.rs | 225 +++++++++++ .../src/reader/pipe_server.rs | 144 +++++++ .../src/reader/read_plan.rs | 314 +++++++++++++++ crates/uffs-content/Cargo.toml | 37 ++ .../uffs-content/src/job/candidate_source.rs | 120 ++++++ crates/uffs-content/src/job/content_source.rs | 70 +++- .../uffs-content/src/job/ephemeral_daemon.rs | 156 ++++++++ crates/uffs-content/src/job/intake.rs | 15 +- crates/uffs-content/src/job/mod.rs | 34 ++ crates/uffs-content/src/job/reader_client.rs | 236 ++++++++++++ crates/uffs-content/src/job/self_test.rs | 145 +++++++ .../uffs-content/src/job/snapshot_client.rs | 170 ++++++++ crates/uffs-content/src/job/tests.rs | 7 +- crates/uffs-content/src/job/vss_job.rs | 81 ++++ .../uffs-content/src/job/vss_orchestrator.rs | 201 ++++++++++ crates/uffs-content/src/job/workflow.rs | 2 +- crates/uffs-content/src/lib.rs | 6 + crates/uffs-content/src/main.rs | 80 +++- .../tests/e2e_dir_walk_parity_fake_reader.rs | 23 +- .../tests/e2e_real_vss_content_reader.rs | 83 ++++ crates/uffs-core/src/compact_loader.rs | 74 +++- crates/uffs-core/src/search/display_row.rs | 19 + crates/uffs-core/src/search/query/mod.rs | 1 + crates/uffs-daemon/src/broker_client.rs | 66 ++++ .../uffs-daemon/src/handler_csv_blob_tests.rs | 1 + .../src/handler_paths_blob_tests.rs | 1 + crates/uffs-daemon/src/index/loading.rs | 106 +++++ crates/uffs-daemon/src/index/projection.rs | 1 + crates/uffs-daemon/src/ipc.rs | 31 +- crates/uffs-daemon/src/lib.rs | 184 +++++---- crates/uffs-daemon/src/main.rs | 50 ++- crates/uffs-daemon/src/startup.rs | 16 +- crates/uffs-mcp/src/lib_tests.rs | 1 + crates/uffs-mft/src/platform/volume.rs | 42 +- crates/uffs-mft/src/reader.rs | 52 +++ scripts/windows/content-reader-validation.rs | 363 ++++++++++++++++++ 50 files changed, 3505 insertions(+), 123 deletions(-) create mode 100644 crates/uffs-content-reader/Cargo.toml create mode 100644 crates/uffs-content-reader/build.rs create mode 100644 crates/uffs-content-reader/src/main.rs create mode 100644 crates/uffs-content-reader/src/reader.rs create mode 100644 crates/uffs-content-reader/src/reader/logical.rs create mode 100644 crates/uffs-content-reader/src/reader/pipe_server.rs create mode 100644 crates/uffs-content-reader/src/reader/read_plan.rs create mode 100644 crates/uffs-content/src/job/ephemeral_daemon.rs create mode 100644 crates/uffs-content/src/job/reader_client.rs create mode 100644 crates/uffs-content/src/job/self_test.rs create mode 100644 crates/uffs-content/src/job/snapshot_client.rs create mode 100644 crates/uffs-content/src/job/vss_job.rs create mode 100644 crates/uffs-content/src/job/vss_orchestrator.rs create mode 100644 crates/uffs-content/tests/e2e_real_vss_content_reader.rs create mode 100644 scripts/windows/content-reader-validation.rs diff --git a/Cargo.lock b/Cargo.lock index a8d8971f4..f7f0c282c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4484,11 +4484,16 @@ dependencies = [ name = "uffs-content" version = "0.6.27" dependencies = [ + "anyhow", "blake3", "serde", "serde_json", "tempfile", + "tracing", + "uffs-broker-protocol", + "uffs-client", "uffs-content-protocol", + "uffs-content-reader-protocol", "uffs-version", "uuid", ] @@ -4503,6 +4508,23 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "uffs-content-reader" +version = "0.6.27" +dependencies = [ + "anyhow", + "proptest", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uffs-content-reader-protocol", + "uffs-security", + "uffs-version", + "windows 0.62.2", + "winresource", +] + [[package]] name = "uffs-content-reader-protocol" version = "0.6.27" diff --git a/Cargo.toml b/Cargo.toml index d2db2b6ef..60f0c791e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,12 +38,13 @@ members = [ "crates/uffs-format", # 🧾 Shared CSV formatter (daemon + thin CLI) "crates/uffs-core", # 🎯 Query engine + compact search engine # ── Daemon Architecture ── - "crates/uffs-daemon", # 🛡️ Background service process - "crates/uffs-client", # 📡 Thin client library - "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents - "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) - "crates/uffs-vss-requestor", # 🩹 Per-run native VSS snapshot helper, spawned by uffs-broker (optional) - "crates/uffs-content", # 📦 Content Service — VSS-snapshot-scoped file content export (optional) + "crates/uffs-daemon", # 🛡️ Background service process + "crates/uffs-client", # 📡 Thin client library + "crates/uffs-mcp", # 🤖 MCP stdio adapter for AI agents + "crates/uffs-broker", # 🔑 Windows elevated handle broker (optional) + "crates/uffs-vss-requestor", # 🩹 Per-run native VSS snapshot helper, spawned by uffs-broker (optional) + "crates/uffs-content", # 📦 Content Service — VSS-snapshot-scoped file content export (optional) + "crates/uffs-content-reader", # 📖 Privileged narrow Snapshot Reader, spawned by uffs-content (optional) # ── Surfaces ── "crates/uffs-cli", # 🖥️ Command-line interface "crates/uffs-update", # ⬆️ Self-update acquire helper (HTTP/TLS isolated from the CLI) diff --git a/crates/uffs-cli/src/commands/output/output_tests.rs b/crates/uffs-cli/src/commands/output/output_tests.rs index 07128c6f0..84f10f758 100644 --- a/crates/uffs-cli/src/commands/output/output_tests.rs +++ b/crates/uffs-cli/src/commands/output/output_tests.rs @@ -449,6 +449,7 @@ fn parity_row( malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } diff --git a/crates/uffs-client/src/connect_sync.rs b/crates/uffs-client/src/connect_sync.rs index 6d1ce3cb2..d8a78ec26 100644 --- a/crates/uffs-client/src/connect_sync.rs +++ b/crates/uffs-client/src/connect_sync.rs @@ -176,6 +176,30 @@ impl UffsClientSync { .map_err(|err| ClientError::ConnectionFailed(format!("No daemon is running: {err}"))) } + /// Connect to a daemon at an explicit, caller-supplied `endpoint` + /// (a Unix socket path, or on Windows a named-pipe path) — + /// bypassing the well-known per-user socket/pipe resolution, + /// autostart, and PID-file identity verification entirely. + /// + /// For talking to an ephemeral, job-scoped `uffsd` instance the + /// caller spawned itself with `--ephemeral-id ` (and therefore + /// already trusts) — see [`crate::daemon_ctl::ephemeral_endpoint`] + /// for computing the matching `endpoint` string. Not for the + /// resident daemon: use [`Self::connect`] for that. + /// + /// # Errors + /// Returns `ConnectionFailed` if the endpoint can't be opened. + pub fn connect_at(endpoint: &str) -> Result { + #[cfg(unix)] + { + Self::platform_connect_at(std::path::Path::new(endpoint)) + } + #[cfg(windows)] + { + Self::platform_connect_at(endpoint) + } + } + /// Connect to a running daemon, or auto-start one with extra CLI args. /// /// Auto-start uses the default diff --git a/crates/uffs-client/src/connect_sync_platform.rs b/crates/uffs-client/src/connect_sync_platform.rs index 32beced34..d0996cb5a 100644 --- a/crates/uffs-client/src/connect_sync_platform.rs +++ b/crates/uffs-client/src/connect_sync_platform.rs @@ -55,8 +55,16 @@ impl UffsClientSync { /// deadline on every subsequent blocking read/write with zero /// additional cost in the client. pub(crate) fn platform_connect() -> Result { - let sock_path = socket_path(); - let stream = std::os::unix::net::UnixStream::connect(&sock_path) + Self::platform_connect_at(&socket_path()) + } + + /// Connect via Unix domain socket at an explicit path, bypassing the + /// well-known per-user [`socket_path`] resolution — used by + /// [`UffsClientSync::connect_at`] to reach an ephemeral daemon + /// instance. Shares every other behavior (deadline, buffering) with + /// [`Self::platform_connect`]. + pub(crate) fn platform_connect_at(sock_path: &std::path::Path) -> Result { + let stream = std::os::unix::net::UnixStream::connect(sock_path) .map_err(|err| ClientError::ConnectionFailed(err.to_string()))?; // Set both read and write deadlines. A hung daemon could stall @@ -142,23 +150,27 @@ impl UffsClientSync { /// disarm, nanoseconds); per-client overhead is one long-lived /// thread with a 50 ms poll period. pub(crate) fn platform_connect() -> Result { + let name = crate::daemon_ctl::pipe_name() + .map_err(|err| ClientError::ConnectionFailed(err.to_string()))?; + Self::platform_connect_at(name.as_str()) + } + + /// Connect via named pipe at an explicit path, bypassing the + /// well-known per-user [`crate::daemon_ctl::pipe_name`] resolution — + /// used by [`UffsClientSync::connect_at`] to reach an ephemeral + /// daemon instance. Shares every other behavior (busy-retry, + /// deadline guard) with [`Self::platform_connect`]. + pub(crate) fn platform_connect_at(pipe_path: &str) -> Result { use std::fs::{File, OpenOptions}; /// `ERROR_PIPE_BUSY` — transient, retry with backoff. const ERROR_PIPE_BUSY: i32 = 231; - let name = crate::daemon_ctl::pipe_name() - .map_err(|err| ClientError::ConnectionFailed(err.to_string()))?; - let pipe: File = { let mut last_err: Option = None; let mut pipe_file: Option = None; for attempt in 0..5_u32 { - match OpenOptions::new() - .read(true) - .write(true) - .open(name.as_str()) - { + match OpenOptions::new().read(true).write(true).open(pipe_path) { Ok(file) => { pipe_file = Some(file); break; diff --git a/crates/uffs-client/src/daemon_ctl.rs b/crates/uffs-client/src/daemon_ctl.rs index 86a8eea4f..0b27a5a34 100644 --- a/crates/uffs-client/src/daemon_ctl.rs +++ b/crates/uffs-client/src/daemon_ctl.rs @@ -45,6 +45,45 @@ pub fn socket_path() -> PathBuf { } } +/// Isolated lifecycle directory for an ephemeral daemon instance. +/// +/// Identified by `ephemeral_id` — distinct from the resident daemon's +/// well-known `uffs/` directory, so the PID file, shutdown nonce, and +/// IPC endpoint (see [`ephemeral_endpoint`]) never collide with a +/// resident daemon running on the same machine. +/// +/// `ephemeral_id` should be a short, filesystem- and +/// pipe-name-safe token (e.g. hex of a random `u64`); on Windows an +/// unsafe token is rejected when [`ephemeral_endpoint`]'s result is +/// parsed via `PipeName::parse`. +#[must_use] +pub fn ephemeral_lifecycle_dir(ephemeral_id: &str) -> PathBuf { + let base = dirs_next::data_local_dir().unwrap_or_else(|| PathBuf::from("/tmp")); + base.join("uffs").join("ephemeral").join(ephemeral_id) +} + +/// IPC endpoint string for an ephemeral daemon instance. +/// +/// A Unix socket path, or (on Windows) a named-pipe path — matching what +/// the daemon's own bind logic and +/// [`crate::connect_sync::UffsClientSync::connect_at`] must agree on. +/// Single source of truth for both sides so they can never +/// independently drift. +#[must_use] +pub fn ephemeral_endpoint(ephemeral_id: &str) -> String { + #[cfg(unix)] + { + ephemeral_lifecycle_dir(ephemeral_id) + .join("daemon.sock") + .to_string_lossy() + .into_owned() + } + #[cfg(windows)] + { + format!(r"\\.\pipe\uffs-ephemeral-{ephemeral_id}") + } +} + /// Windows named-pipe path (`\\.\pipe\uffs-`). /// /// This is the preferred IPC transport on Windows — replaces `AF_UNIX` @@ -334,7 +373,7 @@ pub fn find_uffs_exe() -> PathBuf { /// always `.exe`-qualified on Windows so a bare `uffsd` can never resolve to /// a legacy `.com` via PATHEXT if handed to a shell. #[must_use] -pub(crate) fn find_daemon_exe() -> PathBuf { +pub fn find_daemon_exe() -> PathBuf { if let Ok(exe) = std::env::current_exe() { let name = exe.file_stem().and_then(|stem| stem.to_str()).unwrap_or(""); if name == "uffsd" { diff --git a/crates/uffs-client/src/protocol/response.rs b/crates/uffs-client/src/protocol/response.rs index 454935ed8..ccdcbc2f8 100644 --- a/crates/uffs-client/src/protocol/response.rs +++ b/crates/uffs-client/src/protocol/response.rs @@ -516,6 +516,15 @@ pub struct SearchRow { /// column (it is not in `BASELINE_COLUMN_ORDER`). #[serde(default, skip_serializing_if = "Option::is_none")] pub name_hex: Option, + /// NTFS File Reference (`(sequence_number << 48) | frs`) — see + /// `uffs_core::compact::CompactRecord::file_ref`. `0` for rows that + /// don't carry it (e.g. reconstructed from a `ShmemRows` blob, which + /// doesn't include this field — the CLI's large-result-set path has + /// no consumer that needs it). `#[serde(default)]` keeps the wire + /// format backward/forward compatible, matching the other + /// additive fields above. + #[serde(default)] + pub file_reference: u64, } /// Feed `SearchRow` directly into the shared `uffs-format` writer. diff --git a/crates/uffs-client/src/protocol/tests.rs b/crates/uffs-client/src/protocol/tests.rs index d40106240..f53e601fa 100644 --- a/crates/uffs-client/src/protocol/tests.rs +++ b/crates/uffs-client/src/protocol/tests.rs @@ -241,6 +241,7 @@ fn search_response_inline_rows_round_trip() { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, }]), total_count: 1, records_scanned: 1_000_000, @@ -309,6 +310,7 @@ fn search_row_default_json_carries_name_hex_for_malformed_only() { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, }; // Well-formed row: no hex evidence, so the key is dropped entirely. diff --git a/crates/uffs-client/src/shmem.rs b/crates/uffs-client/src/shmem.rs index 1e0ffe837..5bd43abd1 100644 --- a/crates/uffs-client/src/shmem.rs +++ b/crates/uffs-client/src/shmem.rs @@ -410,6 +410,9 @@ pub fn read_search_results(path: &Path) -> io::Result { malformed: rec.malformed != 0, malformed_path: rec.malformed_path != 0, name_hex: None, + // Not carried by the compact shmem record, same rationale as + // `name_hex` above — no shmem-path consumer needs it today. + file_reference: 0, }); } diff --git a/crates/uffs-client/src/shmem_tests.rs b/crates/uffs-client/src/shmem_tests.rs index 74324e5a2..f2f6d03c9 100644 --- a/crates/uffs-client/src/shmem_tests.rs +++ b/crates/uffs-client/src/shmem_tests.rs @@ -81,6 +81,7 @@ fn sample_row(name: &str) -> SearchRow { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } diff --git a/crates/uffs-content-reader/Cargo.toml b/crates/uffs-content-reader/Cargo.toml new file mode 100644 index 000000000..ed0acd218 --- /dev/null +++ b/crates/uffs-content-reader/Cargo.toml @@ -0,0 +1,82 @@ +# ============================================================================ +# uffs-content-reader: Privileged narrow Snapshot Reader +# ============================================================================ +# Layer 1 bin-only crate. The privileged half of the Coordinator/Reader +# split (uffs-ingest-implementation-plan.md §5): opens files directly +# against a leased VSS snapshot device by NTFS file reference +# (`OpenFileById`), re-resolves EOF from the opened handle, and returns +# bounded logical bytes to `uffs-content` (the unprivileged Coordinator) +# over a private named-pipe protocol (`uffs-content-reader-protocol`). +# +# Depends only on `uffs-content-reader-protocol` for its wire types — NOT +# `uffs-core` (no query-engine surface belongs in a privileged reader) and +# NOT `uffs-mft` for v1 (no MFT parsing needed yet: `OpenFileById` locates +# the file directly by its 64-bit file reference, no path resolution or +# identity revalidation against the snapshot's own MFT required — that's +# a deferred hardening step, see `src/reader/logical.rs`). +# ============================================================================ + +[package] +name = "uffs-content-reader" +description = "Privileged narrow Snapshot Reader for uffs-content — opens VSS-snapshot files by NTFS file reference (Windows-only; other platforms build a stub)" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +# Intentionally NOT published. Privileged, job-scoped companion process +# spawned only by uffs-content — no standalone-useful library API outside +# the UFFS architecture. Reserve the name on crates.io to prevent +# squatting, but never carry content. Mirrors `uffs-broker`'s rationale. +publish.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] +targets = ["x86_64-pc-windows-msvc"] +default-target = "x86_64-pc-windows-msvc" + +[[bin]] +name = "uffs-content-reader" +path = "src/main.rs" + +# Cross-platform: `main.rs` calls `uffs_version::handle_version!` on every +# platform, and the pure `read_plan` VDL/EOF function (the plan's own +# "highest-value unit-test target") is deliberately platform-independent +# so it runs on every CI lane, not just Windows. +[dependencies] +uffs-version.workspace = true +uffs-content-reader-protocol.workspace = true +# `ReadPlanError` (`src/reader/read_plan.rs`) — the pure VDL/EOF function +# runs on every platform, so its error type's dependency does too. +thiserror.workspace = true + +# Windows-only deps: the actual privileged logic (`reader.rs` and +# `reader/`) is fully `#[cfg(windows)]` — scoped so the non-Windows stub +# build (a tiny `eprintln!`-only `main.rs` path) doesn't pull these in +# unused. Mirrors `uffs-broker`'s own split (F5 / issue #205). +[target.'cfg(windows)'.dependencies] +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +windows.workspace = true +# Owner-only pipe DACL + `PipeName` validation — the same primitives +# `uffs-daemon`'s named-pipe server uses. +uffs-security.workspace = true +# Safe named-pipe server wrapper (`tokio::net::windows::named_pipe`) — +# avoids hand-rolling unsafe `CreateNamedPipeW` FFI for the pipe-server +# half; only a "current_thread" runtime is used, no multi-threaded pool. +tokio = { workspace = true, features = ["rt", "net", "io-util"] } + +[build-dependencies] +uffs-version = { workspace = true, features = ["build"] } +winresource.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/uffs-content-reader/build.rs b/crates/uffs-content-reader/build.rs new file mode 100644 index 000000000..f9efe4622 --- /dev/null +++ b/crates/uffs-content-reader/build.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-content-reader`. +//! +//! Embeds Windows PE resources — the UFFS icon, version info (company, product, +//! description), and the shared `app.manifest` — into `uffs-content-reader.exe` +//! via [`winresource`](https://crates.io/crates/winresource), so the shipped +//! binary carries proper metadata instead of shipping bare. A bare binary is +//! both unbranded and a mild antivirus false-positive signal. MSVC-Windows +//! only; a no-op on every other build target. Mirrors `uffs-broker`'s build +//! script. + +fn main() { + uffs_version::emit_build_env(); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set( + "FileDescription", + "UFFS Content Reader (privileged VSS snapshot content reader)", + ) + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffs-content-reader.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-content-reader resources"); +} diff --git a/crates/uffs-content-reader/src/main.rs b/crates/uffs-content-reader/src/main.rs new file mode 100644 index 000000000..92bb85b4b --- /dev/null +++ b/crates/uffs-content-reader/src/main.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! UFFS Content Reader — privileged narrow Snapshot Reader. +//! +//! Spawned once per job by `uffs-content` (the Coordinator), while the +//! Coordinator is itself elevated. Opens files directly against one or +//! more leased VSS snapshot devices by NTFS file reference +//! (`OpenFileById`) and streams bounded logical byte ranges back over a +//! private named pipe (`uffs-content-reader-protocol::READER_PIPE_NAME`). +//! Not meant to be run manually outside of debugging. +//! +//! # Usage +//! +//! ```bash +//! uffs-content-reader --version +//! uffs-content-reader --device = [--device ...] +//! ``` + +// `reader::read_plan` is cross-platform (pure logic, no I/O — see its +// own doc comment); the rest of `reader` (`logical`, `pipe_server`, +// dispatch) is `#[cfg(windows)]`-gated within the module itself, so +// this declaration stays unconditional. +mod reader; + +// Used only by the Windows-only pieces of the `reader` module tree +// (`reader.rs`'s dispatch, `reader/pipe_server.rs`'s framing) — on +// non-Windows platforms this bin is a tiny `eprintln!`-only stub for +// everything except `reader::read_plan`, so this would otherwise be +// visible-but-unused. Mirrors `uffs-broker`'s / `uffs-vss-requestor`'s +// own convention (F5 / issue #205). +#[cfg(not(windows))] +use uffs_content_reader_protocol as _; + +/// Parse repeatable `--device DEVICE_PATH=LEASE_ID` arguments into a +/// `snapshot_lease_id -> device_path` lookup table. +/// +/// # Errors +/// Returns an error on an unknown argument, a malformed `--device` +/// value, or zero `--device` arguments (at least one is required). +#[cfg(windows)] +fn parse_device_args() -> anyhow::Result> { + let mut devices = std::collections::HashMap::new(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--device" { + let spec = args + .next() + .ok_or_else(|| anyhow::anyhow!("--device requires a DEVICE_PATH=LEASE_ID value"))?; + let (path, lease_id_str) = spec + .rsplit_once('=') + .ok_or_else(|| anyhow::anyhow!("--device value '{spec}' is missing '='"))?; + anyhow::ensure!( + !path.is_empty(), + "--device value '{spec}' has an empty path" + ); + let lease_id: u64 = lease_id_str.parse().map_err(|err| { + anyhow::anyhow!("--device value '{spec}' has an invalid lease id: {err}") + })?; + devices.insert(lease_id, path.to_owned()); + } else { + anyhow::bail!("unknown argument '{arg}'"); + } + } + anyhow::ensure!( + !devices.is_empty(), + "at least one --device = is required" + ); + Ok(devices) +} + +#[expect( + clippy::print_stderr, + reason = "no tracing subscriber exists yet at this point in startup; stderr is the \ + only diagnostic channel and is captured by the spawning Coordinator" +)] +fn main() { + uffs_version::handle_version!("uffs-content-reader"); + + #[cfg(windows)] + { + let _guard = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .try_init(); + let result = parse_device_args().and_then(|devices| reader::run(&devices)); + if let Err(err) = result { + eprintln!("uffs-content-reader: {err:#}"); + std::process::exit(1); + } + } + + #[cfg(not(windows))] + { + eprintln!( + "uffs-content-reader reads VSS snapshot content and requires Windows (elevated)." + ); + std::process::exit(1); + } +} diff --git a/crates/uffs-content-reader/src/reader.rs b/crates/uffs-content-reader/src/reader.rs new file mode 100644 index 000000000..b17b93707 --- /dev/null +++ b/crates/uffs-content-reader/src/reader.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Reader logic: a cross-platform pure core ([`read_plan`]) plus +//! Windows-only I/O (`logical`, `pipe_server`). +//! +//! [`read_plan`] is deliberately NOT gated behind `#[cfg(windows)]` — +//! see its own module doc for why it runs (and is exhaustively tested) +//! on every platform, unlike the rest of this module. +//! +//! # Trust model (v1, Windows-only pieces below) +//! +//! This process is spawned directly by `uffs-content` (the Coordinator) +//! while it is itself elevated — there is no Broker-mediated handle +//! duplication or Authenticode identity check on the connecting client +//! for this pipe (unlike the Broker's Snapshot Manager pipe). The named +//! pipe's owner-only DACL ([`uffs_security::pipe::OwnerOnlySd`]) is the +//! security boundary: only the current elevated user's linked/primary +//! token can open it at all. `FIRST_PIPE_INSTANCE` protects against +//! another process squatting the well-known name first. +//! +//! # Resolving a lease id to a snapshot device path +//! +//! `ReadRequest` carries `snapshot_lease_id` + `volume_identity`, but +//! (by design — addendum §2.1-§2.4: the Coordinator's requests never +//! carry the snapshot handle or a raw device path over this wire +//! protocol) no device path field. This process instead receives every +//! `(device_path, snapshot_lease_id)` pair it will ever need as +//! `--device PATH=LEASE_ID` startup arguments — mirroring `uffsd`'s own +//! `--device PATH=LETTER` flag for the same reason (multi-drive jobs +//! lease more than one snapshot) — and looks up `snapshot_lease_id` in +//! that table per request. + +pub(crate) mod read_plan; + +#[cfg(windows)] +mod logical; +#[cfg(windows)] +pub(crate) mod pipe_server; + +#[cfg(windows)] +use std::collections::HashMap; + +#[cfg(windows)] +use uffs_content_reader_protocol::{ReadRequest, ReadResponse, ReaderErrorCode}; + +/// Dispatch one decoded `ReadRequest` into a `ReadResponse`. +/// +/// Every failure mode (unknown lease, open failure, read failure, +/// invalid VDL/EOF metadata) is caught here and turned into a typed +/// `ReadResponse::Error` — this function never panics or propagates an +/// `Err` up to its caller, since a malformed *single* request must not +/// tear down the whole connection. +#[cfg(windows)] +fn dispatch_request(request: &ReadRequest, devices: &HashMap) -> ReadResponse { + let Some(device_path) = devices.get(&request.snapshot_lease_id) else { + return ReadResponse::Error { + code: ReaderErrorCode::LeaseInvalid, + message: format!( + "snapshot_lease_id {} is not one of this process's --device leases", + request.snapshot_lease_id + ), + }; + }; + + match logical::read_logical( + device_path, + request.full_file_reference, + request.logical_offset, + request.maximum_logical_length, + ) { + Ok((payload, actual_mode)) => ReadResponse::Bytes { + logical_offset: request.logical_offset, + actual_mode, + payload, + }, + Err(err) => ReadResponse::Error { + code: ReaderErrorCode::ReadIoTransient, + message: format!("{err:#}"), + }, + } +} + +/// Run the Reader for the process's whole lifetime, resolving each +/// request's `snapshot_lease_id` against `devices`. +/// +/// # Errors +/// Returns an error only if the pipe itself cannot be created at all; +/// per-request failures are turned into `ReadResponse::Error` and never +/// propagate here. +#[cfg(windows)] +pub(crate) fn run(devices: &HashMap) -> anyhow::Result<()> { + pipe_server::run(devices) +} diff --git a/crates/uffs-content-reader/src/reader/logical.rs b/crates/uffs-content-reader/src/reader/logical.rs new file mode 100644 index 000000000..2ecd6e98c --- /dev/null +++ b/crates/uffs-content-reader/src/reader/logical.rs @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Stage A: the logical reader (`uffs-ingest-implementation-plan.md` +//! §5.1). +//! +//! Opens the target file directly against the snapshot device by its +//! NTFS file reference (`OpenFileById`) — no path resolution needed, no +//! `uffs-mft` dependency. Re-resolves `EOF` from the freshly opened +//! handle, applies the VDL/EOF zero-synthesis rule +//! ([`super::read_plan::read_plan`]), and reads real bytes via +//! `ReadFile` at the requested offset. +//! +//! # v1 simplifications (documented, not silent) +//! +//! - **VDL is treated as equal to EOF.** Getting the true NTFS valid data +//! length requires undocumented/internal APIs; treating it as EOF is correct +//! for the overwhelming majority of files — only a sparse-tail file extended +//! via `SetFileValidData`/`SetEndOfFile` without writing has `vdl < eof`. +//! Revisit if that edge case matters in practice. +//! - **No identity revalidation against the snapshot's own MFT.** +//! `OpenFileById` inherently defends against the classic "FRS reused by a +//! different file" attack — the encoded sequence number (high 16 bits of +//! `full_file_reference`) must match the live file's, or the open fails +//! outright. The deeper `identity.rs` piece from the design doc (re-parsing +//! the MFT record to cross-check size/ attributes after open) is deferred. +//! - **No Broker-mediated lease/volume cross-validation.** The Reader trusts +//! the Coordinator's `snapshot_device_identity` string as-is; only the +//! Coordinator's own successful VSS lease (validated by the Broker) gated +//! whether that string was ever handed out at all. + +use core::mem::size_of; +use std::os::windows::ffi::OsStrExt as _; + +use uffs_content_reader_protocol::ActualReadMode; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_BEGIN, FILE_FLAG_BACKUP_SEMANTICS, FILE_GENERIC_READ, FILE_ID_DESCRIPTOR, + FILE_ID_DESCRIPTOR_0, FILE_ID_TYPE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + GetFileSizeEx, OPEN_EXISTING, OpenFileById, ReadFile, SetFilePointerEx, +}; +use windows::core::PCWSTR; + +use super::read_plan::read_plan; + +/// `FileIdType` (the classic 64-bit NTFS file ID variant) — matches +/// `uffs-core::compact::CompactRecord::file_ref`'s +/// `(sequence_number << 48) | frs` encoding, which is exactly the shape +/// `OpenFileById` expects for this discriminant. +const FILE_ID_TYPE_CLASSIC: FILE_ID_TYPE = FILE_ID_TYPE(0); + +/// RAII wrapper closing a raw `HANDLE` on drop. +struct OwnedHandle(HANDLE); + +impl Drop for OwnedHandle { + fn drop(&mut self) { + #[expect( + unsafe_code, + reason = "CloseHandle is an FFI call; `self.0` is always a valid, \ + owned handle this module opened" + )] + // SAFETY: `self.0` was returned by a successful `CreateFileW` or + // `OpenFileById` call in this module and is closed at most once + // (ownership is exclusive to this struct). + unsafe { + drop(CloseHandle(self.0)); + } + } +} + +/// Encode `text` as a NUL-terminated UTF-16 buffer for `PCWSTR` FFI calls. +fn to_wide_null(text: &str) -> Vec { + std::ffi::OsStr::new(text) + .encode_wide() + .chain(core::iter::once(0)) + .collect() +} + +/// Open a handle to the snapshot device's volume root — used only as +/// `OpenFileById`'s volume hint, then dropped immediately after that +/// call returns (the returned file handle is independent of it). +fn open_volume_hint(device_path: &str) -> anyhow::Result { + let wide = to_wide_null(device_path); + #[expect( + unsafe_code, + reason = "CreateFileW is an FFI call opening the snapshot volume root" + )] + // SAFETY: `wide` is a NUL-terminated UTF-16 buffer kept alive for the + // duration of the call; no other pointer arguments are passed. + let result = unsafe { + CreateFileW( + PCWSTR::from_raw(wide.as_ptr()), + FILE_GENERIC_READ.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + None, + ) + }; + let handle = result.map_err(|err| { + anyhow::anyhow!("failed to open snapshot volume root {device_path}: {err}") + })?; + Ok(OwnedHandle(handle)) +} + +/// Open the target file directly by its 64-bit NTFS file reference, +/// using `volume_hint` to identify which volume it lives on. +fn open_file_by_id( + volume_hint: &OwnedHandle, + full_file_reference: u64, +) -> anyhow::Result { + let descriptor = FILE_ID_DESCRIPTOR { + dwSize: u32::try_from(size_of::()).unwrap_or(u32::MAX), + Type: FILE_ID_TYPE_CLASSIC, + Anonymous: FILE_ID_DESCRIPTOR_0 { + // NTFS classic file IDs are a bit-pattern reinterpretation, + // not a numeric value — `cast_signed` preserves every bit. + FileId: full_file_reference.cast_signed(), + }, + }; + #[expect( + unsafe_code, + reason = "OpenFileById is an FFI call; `descriptor` is a valid, \ + fully-initialized FILE_ID_DESCRIPTOR for its lifetime" + )] + // SAFETY: `volume_hint.0` is a valid open handle on the target + // volume; `descriptor` is `#[repr(C)]`, matches the classic 64-bit + // `FileId` union arm the `Type` field selects, and lives until the + // call returns. + let result = unsafe { + OpenFileById( + volume_hint.0, + &raw const descriptor, + FILE_GENERIC_READ.0, + FILE_SHARE_READ, + None, + FILE_FLAG_BACKUP_SEMANTICS, + ) + }; + let handle = result.map_err(|err| { + anyhow::anyhow!("failed to open file by id {full_file_reference:#018x}: {err}") + })?; + Ok(OwnedHandle(handle)) +} + +/// Query a handle's current size (this call's own re-resolution of +/// `EOF` — never trusted from manifest metadata, per §5.1 point 2). +fn file_size(handle: &OwnedHandle) -> anyhow::Result { + let mut size: i64 = 0; + #[expect(unsafe_code, reason = "GetFileSizeEx is an FFI call")] + // SAFETY: `handle.0` is a valid open file handle; `size` is a valid + // `&mut i64` for the duration of the call. + let result = unsafe { GetFileSizeEx(handle.0, &raw mut size) }; + result.map_err(|err| anyhow::anyhow!("GetFileSizeEx failed: {err}"))?; + Ok(size.cast_unsigned()) +} + +/// Move the handle's file pointer to `offset` from the start of the file. +fn seek(handle: &OwnedHandle, offset: u64) -> anyhow::Result<()> { + #[expect(unsafe_code, reason = "SetFilePointerEx is an FFI call")] + // SAFETY: `handle.0` is a valid open file handle; no output pointer + // is requested. + let result = unsafe { SetFilePointerEx(handle.0, offset.cast_signed(), None, FILE_BEGIN) }; + result.map_err(|err| anyhow::anyhow!("SetFilePointerEx failed: {err}")) +} + +/// Read exactly `buf.len()` bytes from the handle's current position. +fn read_exact(handle: &OwnedHandle, buf: &mut [u8]) -> anyhow::Result<()> { + let mut total_read: u32 = 0; + while (total_read as usize) < buf.len() { + let mut bytes_read: u32 = 0; + let dest = buf + .get_mut(total_read as usize..) + .ok_or_else(|| anyhow::anyhow!("read_exact: buffer index out of bounds"))?; + #[expect(unsafe_code, reason = "ReadFile is an FFI call")] + // SAFETY: `handle.0` is a valid open file handle; `dest` is a + // valid, exclusively-borrowed byte slice for the call's + // duration; no overlapped I/O is requested. + let result = unsafe { ReadFile(handle.0, Some(dest), Some(&raw mut bytes_read), None) }; + result.map_err(|err| anyhow::anyhow!("ReadFile failed: {err}"))?; + if bytes_read == 0 { + anyhow::bail!("ReadFile returned 0 bytes before the requested range was satisfied"); + } + total_read += bytes_read; + } + Ok(()) +} + +/// Perform one logical read: open by file reference, re-resolve `EOF`, +/// apply the VDL/EOF rule, and read the resulting real-byte range +/// (zero-extending per the plan). +/// +/// # Errors +/// Returns an error if the device/file can't be opened, the read fails, +/// or the resolved metadata is invalid (`vdl > eof` — never possible +/// here since VDL is derived from EOF, but `read_plan`'s contract keeps +/// that check in one place regardless of caller). +pub(crate) fn read_logical( + device_path: &str, + full_file_reference: u64, + logical_offset: u64, + maximum_logical_length: u32, +) -> anyhow::Result<(Vec, ActualReadMode)> { + let volume_hint = open_volume_hint(device_path)?; + let file_handle = open_file_by_id(&volume_hint, full_file_reference)?; + drop(volume_hint); + + let eof = file_size(&file_handle)?; + let vdl = eof; // v1 simplification — see module doc. + + let plan = read_plan(vdl, eof, logical_offset, maximum_logical_length) + .map_err(|err| anyhow::anyhow!("{err}"))?; + + let mut payload = Vec::with_capacity(plan.total_len() as usize); + if plan.real_bytes > 0 { + seek(&file_handle, logical_offset)?; + let mut real_buf = vec![0_u8; plan.real_bytes as usize]; + read_exact(&file_handle, &mut real_buf)?; + payload.extend_from_slice(&real_buf); + } + payload.resize(payload.len() + plan.zero_bytes as usize, 0); + + Ok((payload, ActualReadMode::Logical)) +} diff --git a/crates/uffs-content-reader/src/reader/pipe_server.rs b/crates/uffs-content-reader/src/reader/pipe_server.rs new file mode 100644 index 000000000..696bba9ef --- /dev/null +++ b/crates/uffs-content-reader/src/reader/pipe_server.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Named-pipe server for [`READER_PIPE_NAME`]. +//! +//! Accepts exactly one client connection (the Coordinator that spawned +//! this process) and serves framed `ReadRequest`/`ReadResponse` +//! messages on it until the Coordinator disconnects, then this process +//! exits — mirrors the one-Reader-per-job lifecycle +//! `uffs-ingest-implementation-plan.md` describes. + +use std::collections::HashMap; + +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}; +use uffs_content_reader_protocol::{READER_PIPE_NAME, ReadRequest, ReadResponse}; + +/// Matches the Coordinator-side `MAX_REQUEST_BYTES`-style bound used +/// for the Broker's Snapshot Manager pipe — a generous ceiling for this +/// small, narrow API. +const MAX_REQUEST_BYTES: u32 = 64 * 1024; + +/// Run the Reader's pipe server for the process's whole lifetime. +/// +/// # Errors +/// Returns an error only if the pipe itself cannot be created at all. +pub(crate) fn run(devices: &HashMap) -> anyhow::Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(serve(devices)) +} + +/// Bind, accept one connection, and serve requests on it until the +/// Coordinator disconnects — the async body of [`run`]. +async fn serve(devices: &HashMap) -> anyhow::Result<()> { + let pipe_name = uffs_security::pipe::PipeName::parse(READER_PIPE_NAME) + .map_err(|err| anyhow::anyhow!("invalid READER_PIPE_NAME: {err}"))?; + let sd = uffs_security::pipe::OwnerOnlySd::for_current_user() + .map_err(|err| anyhow::anyhow!("owner-only DACL build failed: {err}"))?; + + let mut server = create_server(&pipe_name, &sd, /* first= */ true)?; + tracing::info!(pipe = READER_PIPE_NAME, "Reader pipe listening"); + server.connect().await?; + tracing::info!("Coordinator connected"); + + serve_requests(&mut server, devices).await +} + +/// Drain requests off `server` until the Coordinator disconnects (or +/// sends a malformed request, which also ends the connection — see +/// [`read_one_request`]). Extracted from [`serve`] to keep it under +/// clippy's cognitive-complexity budget. +async fn serve_requests( + server: &mut NamedPipeServer, + devices: &HashMap, +) -> anyhow::Result<()> { + loop { + match read_one_request(server).await { + Ok(Some(request)) => { + let response = super::dispatch_request(&request, devices); + write_one_response(server, &response).await?; + } + Ok(None) => { + tracing::info!("Coordinator disconnected — exiting"); + return Ok(()); + } + Err(err) => { + tracing::warn!(error = %err, "malformed request; closing connection"); + return Ok(()); + } + } + } +} + +/// Read one `[u32 LE length][payload]`-framed [`ReadRequest`], or `Ok(None)` +/// on a clean EOF (the Coordinator disconnected between requests). +async fn read_one_request(server: &mut NamedPipeServer) -> anyhow::Result> { + let mut length_bytes = [0_u8; 4]; + match server.read_exact(&mut length_bytes).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err.into()), + } + let length = u32::from_le_bytes(length_bytes); + anyhow::ensure!( + length <= MAX_REQUEST_BYTES, + "request length {length} exceeds maximum {MAX_REQUEST_BYTES}" + ); + let mut payload = vec![0_u8; length as usize]; + server.read_exact(&mut payload).await?; + let mut reader = uffs_content_reader_protocol::codec::Reader::new(&payload); + let request = ReadRequest::decode(&mut reader)?; + Ok(Some(request)) +} + +/// Write one `[u32 LE length][payload]`-framed [`ReadResponse`]. +async fn write_one_response( + server: &mut NamedPipeServer, + response: &ReadResponse, +) -> anyhow::Result<()> { + let payload = response.encode(); + let length = u32::try_from(payload.len()) + .map_err(|err| anyhow::anyhow!("response payload too large to frame: {err}"))?; + server.write_all(&length.to_le_bytes()).await?; + server.write_all(&payload).await?; + server.flush().await?; + Ok(()) +} + +/// Build a single named-pipe server instance bound to `pipe_name` with +/// the owner-only `sd`. Set `first = true` ONLY for the initial +/// instance (enables `FIRST_PIPE_INSTANCE` squat protection) — mirrors +/// `uffs-daemon`'s own `create_pipe_server` exactly. +fn create_server( + pipe_name: &uffs_security::pipe::PipeName, + sd: &uffs_security::pipe::OwnerOnlySd, + first: bool, +) -> anyhow::Result { + let mut sa = sd.as_security_attributes(); + + let mut opts = ServerOptions::new(); + opts.access_inbound(true) + .access_outbound(true) + .pipe_mode(PipeMode::Byte) + .in_buffer_size(65_536) + .out_buffer_size(65_536) + .reject_remote_clients(true); + if first { + opts.first_pipe_instance(true); + } + + #[expect(unsafe_code, reason = "Win32 FFI — create named-pipe server")] + // SAFETY: `sa` is a valid `SECURITY_ATTRIBUTES` borrowing a + // `SECURITY_DESCRIPTOR` owned by `sd`, which outlives this call. + let server = unsafe { + opts.create_with_security_attributes_raw( + pipe_name.as_str(), + core::ptr::from_mut(&mut sa).cast(), + ) + }?; + + Ok(server) +} diff --git a/crates/uffs-content-reader/src/reader/read_plan.rs b/crates/uffs-content-reader/src/reader/read_plan.rs new file mode 100644 index 000000000..770078d69 --- /dev/null +++ b/crates/uffs-content-reader/src/reader/read_plan.rs @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! The VDL/EOF zero-synthesis rule as a pure, allocation-free function. +//! +//! Per `uffs-ingest-implementation-plan.md` §5.1 point 4 (design-doc +//! §6.2): +//! +//! ```text +//! 0 <= offset < min(VDL, EOF) -> real bytes +//! VDL <= offset < EOF -> zeros +//! offset >= EOF -> nothing +//! ``` +//! +//! Deliberately platform-independent — no file handle, no I/O — so it +//! runs on every CI lane and is exhaustively unit-testable. This is +//! "the single highest-value unit-test target in the whole Reader" +//! per the implementation plan. +//! +//! Its only real (non-test) caller, [`super::logical`], is +//! `#[cfg(windows)]` — so on every other platform this module is +//! genuinely unused outside its own tests, permanently (not "deferred +//! until wired up" the way other dead-code states in this workspace +//! are). The `expect(dead_code)` attributes below reflect that on +//! purpose rather than silently suppressing an oversight. + +/// A validated request range rejected before any read is attempted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only real (non-test) caller is #[cfg(windows)]; see module doc" + ) +)] +pub(crate) enum ReadPlanError { + /// `vdl > eof` is never valid NTFS metadata — valid data length can + /// never exceed the file's own end-of-file. Reject rather than + /// silently clamp: this signals a Reader-side bug (or a stale/ + /// mismatched handle) worth surfacing, not papering over. + #[error("invalid metadata: valid data length {vdl} exceeds end of file {eof}")] + InvalidMetadata { + /// The (invalid) valid data length. + vdl: u64, + /// The (invalid, smaller-than-`vdl`) end of file. + eof: u64, + }, +} + +/// How to satisfy one `(vdl, eof, offset, requested_len)` read request: +/// read `real_bytes` from the file at `offset`, then synthesize +/// `zero_bytes` immediately after. Both may be zero. `real_bytes + +/// zero_bytes <= requested_len` always holds (see +/// [`read_plan`]'s invariant tests). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only real (non-test) caller is #[cfg(windows)]; see module doc" + ) +)] +pub(crate) struct ReadPlan { + /// Bytes to read from the real file, starting at `offset`. + pub real_bytes: u32, + /// Zero bytes to synthesize immediately after `real_bytes`. + pub zero_bytes: u32, +} + +impl ReadPlan { + /// Total bytes this plan produces (`real_bytes + zero_bytes`). + #[must_use] + #[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only real (non-test) caller is #[cfg(windows)]; see module doc" + ) + )] + pub(crate) const fn total_len(self) -> u32 { + self.real_bytes + self.zero_bytes + } +} + +/// Resolve a logical read request against a file's `vdl` (valid data +/// length) and `eof` (end of file) into a [`ReadPlan`]. +/// +/// # Errors +/// Returns [`ReadPlanError::InvalidMetadata`] if `vdl > eof` — this +/// combination can never legitimately occur and must never be trusted +/// enough to compute a plan from. +#[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only real (non-test) caller is #[cfg(windows)]; see module doc" + ) +)] +pub(crate) const fn read_plan( + vdl: u64, + eof: u64, + offset: u64, + requested_len: u32, +) -> Result { + if vdl > eof { + return Err(ReadPlanError::InvalidMetadata { vdl, eof }); + } + + if offset >= eof || requested_len == 0 { + return Ok(ReadPlan { + real_bytes: 0, + zero_bytes: 0, + }); + } + + // `offset < eof` here, so this subtraction never underflows. + let available_to_eof = eof - offset; + let requested_len_u64 = requested_len as u64; + let capped_len = if available_to_eof < requested_len_u64 { + available_to_eof + } else { + requested_len_u64 + }; + // `capped_len <= requested_len_u64 <= u32::MAX`, so this cast is + // always exact — no `as` truncation risk despite the lack of a + // `try_into` (this function is `const fn`, which `TryFrom` doesn't + // support as of this edition). + #[expect( + clippy::cast_possible_truncation, + reason = "capped_len <= requested_len (u32) by construction above" + )] + let capped_len_u32 = capped_len as u32; + + if offset >= vdl { + // Entirely within the zero region `[vdl, eof)`. + return Ok(ReadPlan { + real_bytes: 0, + zero_bytes: capped_len_u32, + }); + } + + // `offset < vdl` here, so this subtraction never underflows. + let real_available = vdl - offset; + let real_len = if real_available < capped_len { + real_available + } else { + capped_len + }; + #[expect( + clippy::cast_possible_truncation, + reason = "real_len <= capped_len_u32 by construction above" + )] + let real_len_u32 = real_len as u32; + + Ok(ReadPlan { + real_bytes: real_len_u32, + zero_bytes: capped_len_u32 - real_len_u32, + }) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::{ReadPlan, ReadPlanError, read_plan}; + + #[test] + fn offset_zero_within_vdl_is_all_real_bytes() { + let plan = read_plan(100, 100, 0, 50).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 50, + zero_bytes: 0 + }); + } + + #[test] + fn offset_at_eof_is_empty() { + let plan = read_plan(100, 100, 100, 50).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 0 + }); + } + + #[test] + fn offset_past_eof_is_empty() { + let plan = read_plan(100, 100, 500, 50).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 0 + }); + } + + #[test] + fn offset_exactly_at_vdl_is_all_zeros() { + // vdl=60, eof=100: offset==vdl starts the zero region exactly. + let plan = read_plan(60, 100, 60, 20).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 20 + }); + } + + #[test] + fn offset_in_zero_region_is_all_zeros_capped_at_eof() { + // vdl=60, eof=100, offset=80, requested 50 -> only 20 bytes + // remain before eof, all zeros. + let plan = read_plan(60, 100, 80, 50).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 20 + }); + } + + #[test] + fn range_spanning_vdl_eof_boundary_splits_real_then_zero() { + // vdl=60, eof=100, offset=50, requested 40 -> 10 real bytes + // (50..60) then 30 zero bytes (60..90). + let plan = read_plan(60, 100, 50, 40).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 10, + zero_bytes: 30 + }); + } + + #[test] + fn range_spanning_vdl_and_eof_is_capped_at_eof() { + // vdl=60, eof=100, offset=50, requested 1000 -> capped at eof: + // 10 real (50..60) + 40 zero (60..100). + let plan = read_plan(60, 100, 50, 1000).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 10, + zero_bytes: 40 + }); + } + + #[test] + fn zero_length_file_is_always_empty() { + let plan = read_plan(0, 0, 0, 100).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 0 + }); + } + + #[test] + fn zero_requested_len_is_always_empty() { + let plan = read_plan(100, 100, 0, 0).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 0, + zero_bytes: 0 + }); + } + + #[test] + fn vdl_equal_eof_never_produces_zero_bytes() { + // No sparse tail at all: every in-bounds read is pure real bytes. + let plan = read_plan(100, 100, 10, 50).expect("valid metadata"); + assert_eq!(plan, ReadPlan { + real_bytes: 50, + zero_bytes: 0 + }); + } + + #[test] + fn vdl_greater_than_eof_is_rejected() { + let err = read_plan(100, 50, 0, 10).expect_err("vdl > eof must be rejected"); + assert_eq!(err, ReadPlanError::InvalidMetadata { vdl: 100, eof: 50 }); + } + + #[test] + fn total_len_never_exceeds_requested_len() { + for (vdl, eof, offset, requested_len) in [ + (0_u64, 0_u64, 0_u64, 10_u32), + (0, 100, 0, 10), + (50, 100, 0, 200), + (50, 100, 49, 5), + (50, 100, 50, 5), + (50, 100, 99, 5), + (50, 100, 100, 5), + (50, 100, 1000, 5), + ] { + let plan = read_plan(vdl, eof, offset, requested_len).expect("valid metadata"); + assert!( + plan.total_len() <= requested_len, + "plan {plan:?} exceeds requested_len {requested_len} for \ + (vdl={vdl}, eof={eof}, offset={offset})" + ); + } + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(1000))] + + /// Core invariant, fuzzed: whenever metadata is valid (`vdl <= + /// eof`), the plan's total length never exceeds either the + /// caller's requested length or the bytes actually remaining + /// before `eof`. + #[test] + fn total_len_is_bounded_for_arbitrary_valid_inputs( + vdl in 0_u64..1_000_000, + extra_to_eof in 0_u64..1_000_000, + offset in 0_u64..2_000_000, + requested_len in 0_u32..1_000_000, + ) { + let eof = vdl + extra_to_eof; + let plan = read_plan(vdl, eof, offset, requested_len) + .expect("vdl <= eof by construction"); + let remaining_to_eof = eof.saturating_sub(offset); + prop_assert!(u64::from(plan.total_len()) <= remaining_to_eof); + prop_assert!(plan.total_len() <= requested_len); + } + } +} diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 2c0d1c14c..109cceb2b 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -66,6 +66,43 @@ serde_json.workspace = true # Job/run identifiers (`ManifestHeader::job_id`, etc.) — see `src/job/`. uuid.workspace = true +# Windows-only deps: the real Snapshot Manager pipe client +# (`src/job/snapshot_client.rs`) and the real VSS+MFT-query +# `CandidateSource` it backs are meaningful only on Windows (no VSS, no +# live MFT snapshot device, no Broker to talk to elsewhere) — the fake +# `DirWalkCandidateSource`/`FsContentSource` backends remain +# cross-platform. Scoped so a non-Windows build doesn't pull in +# `uffs-broker-protocol`/`uffs-client`, matching `uffs-broker`'s own +# rationale for this split (F5 / issue #205). +# +# Deliberately NOT depending on `uffs-mft`/`uffs-core` here: this crate +# never reads or queries an MFT itself. It leases a VSS snapshot from +# the Broker, spawns an ephemeral `uffsd --device =` +# instance to do the actual MFT read + query evaluation (the daemon +# already owns all `uffs-mft`/`uffs-core` usage), and talks to it over +# the same RPC protocol `uffs-client` already implements for every other +# UFFS client. +[target.'cfg(windows)'.dependencies] +# Error handling for the pipe client and real VSS-backed CandidateSource +# (`?`/`anyhow::bail!`/`anyhow::anyhow!`) — not used anywhere else in +# this crate today, so scoped alongside the rest of this Windows-only +# group rather than added unconditionally. +anyhow.workspace = true +# `SnapshotManagerRequest`/`SnapshotManagerResponse` wire types and +# `SNAPSHOT_PIPE_NAME` for the Broker's Snapshot Manager pipe. +uffs-broker-protocol.workspace = true +# Spawns the ephemeral `uffsd` instance and queries it over the same +# daemon RPC protocol every other UFFS client uses. +uffs-client.workspace = true +# Wire types for the private Coordinator<->Snapshot Reader protocol +# (`src/job/reader_client.rs`, `src/job/content_source.rs`'s +# `VssContentSource`). +uffs-content-reader-protocol.workspace = true +# Warn-logs best-effort lease-release failures during ephemeral-daemon +# teardown (`src/job/vss_orchestrator.rs`) — not used anywhere else in +# this crate today. +tracing.workspace = true + [dev-dependencies] tempfile.workspace = true # Independent oracle digest for the E2E dir-walk parity harness diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index ad3c9f2c1..b69f86650 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -24,6 +24,12 @@ pub struct CandidateEntry { /// source uses the OS's native per-volume file identifier, which is /// stable across hard links the same way an NTFS file reference is. pub file_reference: u64, + /// Which VSS snapshot lease (see [`super::snapshot_client::SnapshotLease`]) + /// this candidate's device path/file reference resolve against — a + /// job may lease more than one drive. `0` (never a real lease id, + /// which the Broker assigns starting from 1) for + /// [`DirWalkCandidateSource`], which has no snapshot at all. + pub snapshot_lease_id: u64, } /// Produces the candidate list for a job. @@ -75,6 +81,7 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec) -> io::Result<() logical_size: metadata.len(), mtime_unix_ms: mtime_unix_ms(&metadata), file_reference: file_identity(&metadata), + snapshot_lease_id: 0, }); } } @@ -114,3 +121,116 @@ fn file_identity(metadata: &fs::Metadata) -> u64 { const fn file_identity(_metadata: &fs::Metadata) -> u64 { 0 } + +/// Evaluates a job's query against the ephemeral, VSS-snapshot-backed +/// `uffsd` instance +/// [`super::vss_orchestrator::prepare_ephemeral_daemon_for_roots`] +/// spawned — the real production `CandidateSource`. +/// +/// Windows-only: VSS snapshots, and the ephemeral daemon that queries +/// them, don't exist on any other platform — matching +/// [`super::ephemeral_daemon`]'s own scoping. +#[cfg(windows)] +pub struct VssCandidateSource<'a> { + /// UFFS query expression (`JobRequest::query`), forwarded verbatim + /// to the daemon as `SearchParams::pattern`. + query: String, + /// The already-spawned, already-`Ready` ephemeral daemon covering + /// every drive this job leased. + daemon: &'a super::ephemeral_daemon::EphemeralDaemon, + /// Drive letter -> lease id, so each result row (which only carries + /// a drive letter) can be tagged with the lease + /// [`super::content_source::VssContentSource`] will need to read it + /// back afterward. + drive_to_lease: std::collections::HashMap, +} + +#[cfg(windows)] +impl<'a> VssCandidateSource<'a> { + /// Wrap an already-spawned, already-`Ready` ephemeral daemon. + #[must_use] + pub(crate) const fn new( + query: String, + daemon: &'a super::ephemeral_daemon::EphemeralDaemon, + drive_to_lease: std::collections::HashMap, + ) -> Self { + Self { + query, + daemon, + drive_to_lease, + } + } +} + +#[cfg(windows)] +impl CandidateSource for VssCandidateSource<'_> { + fn enumerate(&self, root: &Path) -> io::Result> { + let mut client = self + .daemon + .connect() + .map_err(|err| io::Error::other(err.to_string()))?; + + // Scope the search to this job's root: `path_contains` is a + // directory-path glob matched against each record's directory + // portion — `*` restricts results to the subtree without + // needing this crate to walk anything itself. + let root_glob = format!("{}*", root.display()); + let params = uffs_client::protocol::SearchParams { + pattern: self.query.clone(), + filter_mode: Some(uffs_client::protocol::SearchFilterMode::Files), + path_contains: Some(root_glob), + limit: None, + ..Default::default() + }; + let response = client + .search(¶ms) + .map_err(|err| io::Error::other(err.to_string()))?; + + let rows = resolve_rows(response.payload)?; + let mut entries = Vec::with_capacity(rows.len()); + for row in rows { + let letter = row.drive.as_char(); + let Some(&lease_id) = self.drive_to_lease.get(&letter) else { + return Err(io::Error::other(format!( + "search result on drive {letter} has no matching lease for this job" + ))); + }; + let path = PathBuf::from(&row.path); + let relative_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf(); + entries.push(CandidateEntry { + relative_path, + absolute_path: path, + logical_size: row.size, + // `SearchRow::modified` is Unix *microseconds*; + // `CandidateEntry::mtime_unix_ms` is Unix milliseconds. + mtime_unix_ms: row.modified / 1000, + file_reference: row.file_reference, + snapshot_lease_id: lease_id, + }); + } + Ok(entries) + } +} + +/// Resolve a `SearchPayload` into its `SearchRow` list, reading a +/// shmem-backed result set from disk if the daemon chose that delivery +/// channel — a job-scoped query is usually small enough to stay inline, +/// but a job matching many files could still cross the daemon's shmem +/// threshold. +#[cfg(windows)] +fn resolve_rows( + payload: uffs_client::protocol::response::SearchPayload, +) -> io::Result> { + use uffs_client::protocol::response::SearchPayload; + match payload { + SearchPayload::Empty => Ok(Vec::new()), + SearchPayload::InlineRows(rows) => Ok(rows), + SearchPayload::ShmemRows { path, .. } => { + uffs_client::shmem::read_search_results(Path::new(&path)) + .map(|response| response.payload.into_inline_rows().unwrap_or_default()) + } + SearchPayload::InlineBlob(_) | SearchPayload::ShmemBlob(_) => Err(io::Error::other( + "daemon returned a pre-formatted text blob instead of structured rows", + )), + } +} diff --git a/crates/uffs-content/src/job/content_source.rs b/crates/uffs-content/src/job/content_source.rs index f3b94b7c9..1659abdeb 100644 --- a/crates/uffs-content/src/job/content_source.rs +++ b/crates/uffs-content/src/job/content_source.rs @@ -20,6 +20,13 @@ use super::candidate_source::CandidateEntry; pub trait ContentSource { /// Read up to `max_len` bytes starting at `offset` from `candidate`. /// + /// `candidate_id` is the same id `manifest_builder::build_manifest` + /// assigned this candidate (the caller already has it — see + /// `workflow::run_job`'s `entries.iter().zip(&built.candidate_ids)`) + /// — the production implementation needs it to correlate this read + /// against the finalized manifest over the Reader's wire protocol; + /// [`FsContentSource`] ignores it entirely. + /// /// Returns fewer than `max_len` bytes only at EOF (matching a normal /// [`std::io::Read::read`] short-read contract at end of file); an /// empty result means `offset` was at or past EOF. @@ -27,8 +34,13 @@ pub trait ContentSource { /// # Errors /// Propagates the underlying [`io::Error`] from opening/seeking/ /// reading the file. - fn read_at(&self, candidate: &CandidateEntry, offset: u64, max_len: u32) - -> io::Result>; + fn read_at( + &self, + candidate: &CandidateEntry, + candidate_id: u64, + offset: u64, + max_len: u32, + ) -> io::Result>; } /// Reads content directly from the live filesystem. @@ -39,6 +51,7 @@ impl ContentSource for FsContentSource { fn read_at( &self, candidate: &CandidateEntry, + _candidate_id: u64, offset: u64, max_len: u32, ) -> io::Result> { @@ -60,3 +73,56 @@ impl ContentSource for FsContentSource { Ok(buffer) } } + +/// Reads content from a VSS snapshot via the privileged +/// `uffs-content-reader` process (see [`super::reader_client`]). +/// +/// Windows-only: VSS snapshots, and the Reader that reads them, don't +/// exist on any other platform — matching [`super::ephemeral_daemon`]'s +/// and [`super::vss_orchestrator`]'s own scoping. +#[cfg(windows)] +pub struct VssContentSource { + /// The spawned Reader process + its live connection for this job. + reader: super::reader_client::ContentReader, +} + +#[cfg(windows)] +impl VssContentSource { + /// Wrap an already-spawned [`super::reader_client::ContentReader`]. + #[must_use] + pub(crate) const fn new(reader: super::reader_client::ContentReader) -> Self { + Self { reader } + } + + /// Tear down the wrapped Reader process. Explicit (rather than + /// relying on `Drop`) so a failed teardown is observable, mirroring + /// how [`super::vss_orchestrator::EphemeralJobResources::teardown`] + /// handles the ephemeral daemon. + /// + /// # Errors + /// Returns an error if the Reader process couldn't be killed. + pub(crate) fn shutdown(self) -> anyhow::Result<()> { + self.reader.shutdown() + } +} + +#[cfg(windows)] +impl ContentSource for VssContentSource { + fn read_at( + &self, + candidate: &CandidateEntry, + candidate_id: u64, + offset: u64, + max_len: u32, + ) -> io::Result> { + self.reader + .read_at( + candidate.snapshot_lease_id, + candidate_id, + candidate.file_reference, + offset, + max_len, + ) + .map_err(|err| io::Error::other(err.to_string())) + } +} diff --git a/crates/uffs-content/src/job/ephemeral_daemon.rs b/crates/uffs-content/src/job/ephemeral_daemon.rs new file mode 100644 index 000000000..5d6dfa7a2 --- /dev/null +++ b/crates/uffs-content/src/job/ephemeral_daemon.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Spawns a single ephemeral `uffsd` instance covering every VSS +//! snapshot device this job's drives were leased for, connects to it +//! over the standard daemon RPC protocol, and tears it down when done. +//! +//! This is the revised design behind +//! `docs/dev/architecture/uffs-ingest-implementation-plan.md` §6.2 +//! (supersedes a literal reading of that section, per direct user +//! clarification): target selection is answered by an ephemeral daemon +//! instance loaded from the leased snapshot device(s) via `uffsd +//! --device =`, not by this crate reading or querying the +//! MFT directly — the daemon already owns all `uffs-mft`/`uffs-core` +//! usage (see [`super::snapshot_client`]'s doc comment for the parallel +//! rationale on the lease side). + +use core::time::Duration; +use std::process::{Child, Command, Stdio}; +use std::time::Instant; + +use anyhow::{Context as _, Result}; +use uffs_client::connect_sync::UffsClientSync; +use uffs_client::daemon_ctl::{ephemeral_endpoint, find_daemon_exe}; + +/// How long [`EphemeralDaemon::spawn`] waits for the daemon to finish +/// loading every device source before giving up. +const READY_TIMEOUT: Duration = Duration::from_secs(120); + +/// How long [`EphemeralDaemon::spawn`] retries connecting to the +/// freshly spawned daemon's pipe/socket before treating it as dead on +/// arrival. +const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(10); + +/// Delay between connect retries while the daemon finishes binding its +/// endpoint. +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(50); + +/// A running ephemeral `uffsd` instance covering one or more VSS +/// snapshot devices. +pub(crate) struct EphemeralDaemon { + /// The spawned `uffsd` child process. Killed directly on + /// [`Self::shutdown`]/[`Drop`] rather than via the RPC `shutdown` + /// method — see [`Self::shutdown`]'s doc comment for why. + child: Child, + /// This instance's IPC endpoint (Unix socket path, or Windows named + /// pipe path), from [`uffs_client::daemon_ctl::ephemeral_endpoint`]. + endpoint: String, +} + +impl EphemeralDaemon { + /// Spawn `uffsd --ephemeral-id --device + /// = ...` for every `(device_path, + /// drive_letter)` pair in `devices`, and wait until it reports + /// every device loaded. + /// + /// # Errors + /// Returns an error if `devices` is empty, the `uffsd` binary can't + /// be spawned, the pipe/socket never comes up, or the daemon + /// doesn't reach `Ready` within [`READY_TIMEOUT`]. + pub(crate) fn spawn(ephemeral_id: &str, devices: &[(String, char)]) -> Result { + anyhow::ensure!( + !devices.is_empty(), + "at least one device source is required to spawn an ephemeral daemon" + ); + + let exe = find_daemon_exe(); + let mut command = Command::new(&exe); + command + .arg("--ephemeral-id") + .arg(ephemeral_id) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (device_path, letter) in devices { + command + .arg("--device") + .arg(format!("{device_path}={letter}")); + } + + let child = command + .spawn() + .with_context(|| format!("failed to spawn {}", exe.display()))?; + + let instance = Self { + child, + endpoint: ephemeral_endpoint(ephemeral_id), + }; + instance.await_ready()?; + Ok(instance) + } + + /// Connect to this instance's pipe/socket, retrying briefly while + /// the daemon finishes binding it, then block until it reports + /// `Ready` (every device source loaded). + fn await_ready(&self) -> Result<()> { + let connect_deadline = Instant::now() + CONNECT_RETRY_BUDGET; + let last_err = loop { + match UffsClientSync::connect_at(&self.endpoint) { + Ok(mut client) => { + return client + .await_ready(READY_TIMEOUT) + .context("ephemeral daemon did not become ready"); + } + Err(err) => { + if Instant::now() >= connect_deadline { + break err; + } + std::thread::sleep(CONNECT_RETRY_INTERVAL); + } + } + }; + Err(anyhow::anyhow!( + "could not connect to ephemeral daemon at {}: {last_err}", + self.endpoint + )) + } + + /// Open a fresh RPC connection to this running instance. + /// + /// # Errors + /// Returns an error if the connection can't be established. + pub(crate) fn connect(&self) -> Result { + UffsClientSync::connect_at(&self.endpoint) + .with_context(|| format!("failed to connect to ephemeral daemon at {}", self.endpoint)) + } + + /// Tear down this instance. + /// + /// Kills the process directly rather than using the RPC `shutdown` + /// method: that method reads the *resident* daemon's well-known PID + /// file for its shutdown nonce, which is meaningless (and unsafe to + /// reuse) for an ephemeral instance's own, differently located PID + /// file. Since this process spawned the child itself, a direct kill + /// is simpler and correct. + /// + /// # Errors + /// Returns an error if the process couldn't be killed. The process + /// is still waited on best-effort even on error. + pub(crate) fn shutdown(mut self) -> Result<()> { + self.child + .kill() + .context("failed to kill ephemeral daemon process")?; + drop(self.child.wait()); + Ok(()) + } +} + +impl Drop for EphemeralDaemon { + /// Best-effort safety net: if [`Self::shutdown`] was never called + /// explicitly (e.g. an earlier step returned an error), don't leak + /// the child process. A no-op if it was already reaped. + fn drop(&mut self) { + drop(self.child.kill()); + } +} diff --git a/crates/uffs-content/src/job/intake.rs b/crates/uffs-content/src/job/intake.rs index fe69d1926..076dd6745 100644 --- a/crates/uffs-content/src/job/intake.rs +++ b/crates/uffs-content/src/job/intake.rs @@ -9,9 +9,15 @@ use std::path::PathBuf; /// /// This is the local job-submission format — ordinary JSON, unlike the /// Docenta-facing frame protocol, which uses the explicit binary codec -/// (addendum §5.4). Query filtering (extension/date/size) is not wired up -/// yet: every job currently matches every regular file under `root` -/// (equivalent to a `"*"` query) — see [`super::candidate_source`]. +/// (addendum §5.4). +/// +/// `query` carries the UFFS query expression (e.g. `"*.txt"`, or `"*"` +/// to match everything), matching the daemon's own query grammar so the +/// real, VSS+MFT-query-backed `CandidateSource` can forward it verbatim +/// to an ephemeral `uffsd` instance rather than re-implementing query +/// parsing in this crate. [`super::candidate_source::DirWalkCandidateSource`] +/// (the fake backend) ignores this field entirely — it always matches +/// every regular file under `root`, equivalent to `query: "*"`. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] pub struct JobRequest { /// Identifier for the source this job's candidates came from. @@ -20,4 +26,7 @@ pub struct JobRequest { pub source_id: String, /// Root directory to enumerate candidates under. pub root: PathBuf, + /// UFFS query expression to evaluate against the snapshot's MFT + /// (e.g. `"*.txt"`); `"*"` matches every regular file. + pub query: String, } diff --git a/crates/uffs-content/src/job/mod.rs b/crates/uffs-content/src/job/mod.rs index 90f2b8486..1364fde34 100644 --- a/crates/uffs-content/src/job/mod.rs +++ b/crates/uffs-content/src/job/mod.rs @@ -17,7 +17,41 @@ pub mod candidate_source; pub mod content_source; pub mod intake; pub mod manifest_builder; +// Coordinator-side client for the Broker's Snapshot Manager pipe — the +// real VSS lease backend `candidate_source`'s VSS-backed implementation +// calls into. Windows-only: no VSS, no Broker to talk to elsewhere, +// matching the `[target.'cfg(windows)'.dependencies]` scoping in +// Cargo.toml this module's own dependency (`uffs-broker-protocol`) +// requires. +#[cfg(windows)] +pub mod snapshot_client; +// Spawns/connects/tears down the ephemeral `uffsd` instance that +// answers target-selection queries against a leased VSS snapshot. +// Windows-only for the same reason as `snapshot_client`. +#[cfg(windows)] +pub mod ephemeral_daemon; +// Ties `snapshot_client` and `ephemeral_daemon` together: one lease per +// distinct drive, one combined daemon. Windows-only for the same reason +// as its two dependents. +#[cfg(windows)] +pub mod vss_orchestrator; +// Coordinator-side client for `uffs-content-reader-protocol` — spawns +// and talks to the privileged `uffs-content-reader` process +// `content_source::VssContentSource` reads through. Windows-only for +// the same reason as its siblings. +#[cfg(windows)] +pub mod reader_client; pub mod workflow; +// End-to-end VSS-backed job execution — ties every piece above together +// into the real production entry point. Windows-only for the same +// reason as its dependencies. +#[cfg(windows)] +pub mod vss_job; +// Elevated smoke test: real VSS + real Reader playback, reused by the +// `--self-test-vss-playback` CLI flag and the `#[ignore]` cargo test. +// Windows-only for the same reason as `vss_job`. +#[cfg(windows)] +pub mod self_test; #[cfg(test)] mod tests; diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs new file mode 100644 index 000000000..ae1eed0d0 --- /dev/null +++ b/crates/uffs-content/src/job/reader_client.rs @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Coordinator-side client for `uffs-content-reader-protocol`. +//! +//! Spawns `uffs-content-reader --device = ...` +//! once per job — mirrors [`super::ephemeral_daemon`]'s spawn model, but +//! for the content-reading phase rather than target selection — connects +//! to its fixed `READER_PIPE_NAME`, and sends framed +//! `ReadRequest`/`ReadResponse` messages over that one persistent +//! connection for the whole job. +//! +//! Mirrors [`super::snapshot_client`]'s connect style (plain +//! `std::fs::OpenOptions` + `Read`/`Write`) and wire framing +//! (`[u32 LE length][payload]`) exactly — see that module's doc comment +//! for the rationale. + +use core::sync::atomic::{AtomicU64, Ordering}; +use core::time::Duration; +use std::io::{Read as _, Write as _}; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; +use std::time::Instant; + +use anyhow::{Context as _, Result}; +use uffs_content_reader_protocol::codec::Reader as WireReader; +use uffs_content_reader_protocol::{ + MAX_RESPONSE_PAYLOAD_BYTES, READER_PIPE_NAME, ReadRequest, ReadResponse, RequestedReadMode, + StreamKind, VolumeIdentity, +}; + +/// How long to retry connecting to the freshly spawned Reader's pipe +/// while it finishes binding it. +const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(10); + +/// Delay between connect retries. +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(50); + +/// A running `uffs-content-reader` process + its live pipe connection, +/// held for the whole job's content-reading phase. +pub(crate) struct ContentReader { + /// The spawned `uffs-content-reader` child process. Killed on + /// [`Self::shutdown`]/[`Drop`] — this process spawned it, so a + /// direct kill is simplest and correct (mirrors + /// [`super::ephemeral_daemon::EphemeralDaemon::shutdown`]). + child: Child, + /// The one persistent pipe connection this job's whole + /// content-reading phase shares. `Mutex`-guarded so `read_at` can + /// take `&self` (the `ContentSource` trait's shape) while still + /// mutating the connection. + pipe: Mutex, + /// This job's id, echoed into every `ReadRequest`. + job_id: [u8; 16], + /// Monotonically increasing nonce for request/response correlation. + next_nonce: AtomicU64, +} + +impl ContentReader { + /// Spawn `uffs-content-reader --device = + /// ...` for every pair in `devices`, and connect to it. + /// + /// # Errors + /// Returns an error if `devices` is empty, the binary can't be + /// spawned, or the pipe never comes up within + /// [`CONNECT_RETRY_BUDGET`]. + pub(crate) fn spawn(job_id: [u8; 16], devices: &[(String, u64)]) -> Result { + anyhow::ensure!( + !devices.is_empty(), + "at least one device is required to spawn a content reader" + ); + + let exe = find_reader_exe(); + let mut command = Command::new(&exe); + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + for (device_path, lease_id) in devices { + command + .arg("--device") + .arg(format!("{device_path}={lease_id}")); + } + let child = command + .spawn() + .with_context(|| format!("failed to spawn {}", exe.display()))?; + + let pipe = connect_with_retry()?; + + Ok(Self { + child, + pipe: Mutex::new(pipe), + job_id, + next_nonce: AtomicU64::new(1), + }) + } + + /// Read up to `maximum_logical_length` bytes at `logical_offset` + /// from the file identified by `full_file_reference`, scoped to + /// `snapshot_lease_id`. + /// + /// # Errors + /// Returns an error if the round trip fails or the Reader reports + /// failure. + pub(crate) fn read_at( + &self, + snapshot_lease_id: u64, + candidate_id: u64, + full_file_reference: u64, + logical_offset: u64, + maximum_logical_length: u32, + ) -> Result> { + let request = ReadRequest { + job_id: self.job_id, + snapshot_lease_id, + candidate_id, + // Presently inert on the Reader side — v1's `OpenFileById` + // locates the file by `full_file_reference` alone, no + // volume cross-check. See + // `uffs-content-reader/src/reader/logical.rs`'s module doc. + volume_identity: VolumeIdentity { + volume_serial: 0, + volume_guid: Vec::new(), + }, + full_file_reference, + stream_kind: StreamKind::UnnamedData, + logical_offset, + maximum_logical_length, + requested_mode: RequestedReadMode::Logical, + request_nonce: self.next_nonce.fetch_add(1, Ordering::Relaxed), + }; + + match self.round_trip(&request)? { + ReadResponse::Bytes { payload, .. } => Ok(payload), + ReadResponse::Error { code, message } => { + anyhow::bail!("Reader rejected read: {code:?}: {message}") + } + } + } + + /// Send one framed [`ReadRequest`] and read back one framed + /// [`ReadResponse`], over this job's one persistent connection. + fn round_trip(&self, request: &ReadRequest) -> Result { + let Ok(mut pipe) = self.pipe.lock() else { + anyhow::bail!("content reader pipe mutex poisoned"); + }; + write_framed_message(&mut pipe, &request.encode())?; + let response_bytes = read_framed_message(&mut pipe)?; + let mut wire_reader = WireReader::new(&response_bytes); + ReadResponse::decode(&mut wire_reader, MAX_RESPONSE_PAYLOAD_BYTES) + .map_err(|err| anyhow::anyhow!("malformed Reader response: {err}")) + } + + /// Tear down this instance: kill the spawned process. The pipe + /// connection is closed when `self` drops. + /// + /// # Errors + /// Returns an error if the process couldn't be killed. + pub(crate) fn shutdown(mut self) -> Result<()> { + self.child + .kill() + .context("failed to kill content reader process")?; + drop(self.child.wait()); + Ok(()) + } +} + +impl Drop for ContentReader { + /// Best-effort safety net: if [`Self::shutdown`] was never called + /// explicitly, don't leak the child process. + fn drop(&mut self) { + drop(self.child.kill()); + } +} + +/// Open [`READER_PIPE_NAME`], retrying briefly while the freshly +/// spawned process finishes binding it. +fn connect_with_retry() -> Result { + let deadline = Instant::now() + CONNECT_RETRY_BUDGET; + let last_err = loop { + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(READER_PIPE_NAME) + { + Ok(pipe) => return Ok(pipe), + Err(err) => { + if Instant::now() >= deadline { + break err; + } + std::thread::sleep(CONNECT_RETRY_INTERVAL); + } + } + }; + Err(anyhow::anyhow!( + "could not connect to content reader at {READER_PIPE_NAME}: {last_err}" + )) +} + +/// Find the `uffs-content-reader` executable: prefer a sibling of the +/// current binary, falling back to the platform binary name on `$PATH`. +fn find_reader_exe() -> std::path::PathBuf { + if let Ok(exe) = std::env::current_exe() + && let Some(parent) = exe.parent() + { + let sibling = parent.join("uffs-content-reader.exe"); + if sibling.exists() { + return sibling; + } + } + std::path::PathBuf::from("uffs-content-reader.exe") +} + +/// Write `payload` as `[u32 LE length][payload]`, flushing immediately. +fn write_framed_message(pipe: &mut std::fs::File, payload: &[u8]) -> Result<()> { + let length = u32::try_from(payload.len()) + .map_err(|err| anyhow::anyhow!("request payload too large to frame: {err}"))?; + pipe.write_all(&length.to_le_bytes())?; + pipe.write_all(payload)?; + pipe.flush()?; + Ok(()) +} + +/// Read one `[u32 LE length][payload]`-framed message. +fn read_framed_message(pipe: &mut std::fs::File) -> Result> { + let mut length_bytes = [0_u8; 4]; + pipe.read_exact(&mut length_bytes)?; + let length = u32::from_le_bytes(length_bytes); + anyhow::ensure!( + length <= MAX_RESPONSE_PAYLOAD_BYTES, + "response length {length} exceeds maximum {MAX_RESPONSE_PAYLOAD_BYTES}" + ); + + let mut payload = vec![0_u8; length as usize]; + pipe.read_exact(&mut payload)?; + Ok(payload) +} diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs new file mode 100644 index 000000000..9d473e5d7 --- /dev/null +++ b/crates/uffs-content/src/job/self_test.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Elevated smoke test: real VSS snapshot + real privileged Reader, +//! creating a unique sample file and proving playback through +//! [`super::vss_job::run_vss_job`] reproduces its content exactly. +//! +//! Mirrors `uffs-broker`'s own `--self-test-vss` design +//! (`crates/uffs-broker/src/broker.rs`/`broker/snapshot_manager/ +//! vss_self_test.rs`): the round-trip logic lives once, here, in +//! production code — reused by both the `--self-test-vss-playback` CLI +//! flag (`main.rs`) and `cargo test -p uffs-content -- --ignored` +//! (`tests/e2e_real_vss_content_reader.rs`), so none of the three ever +//! drift apart. + +use std::path::Path; + +use anyhow::{Context as _, Result}; +use uffs_content_protocol::codec::Reader as WireReader; +use uffs_content_protocol::frame::{ContentChunk, FileEnd, FrameEnvelope, FrameType}; +use uffs_content_protocol::manifest::ManifestHeader; + +use super::intake::JobRequest; +use super::vss_job::run_vss_job; + +/// Run the real create-snapshot -> select-target -> read-content round trip. +/// +/// Uses a freshly created, uniquely-named sample file under `test_dir`, +/// and verifies the streamed bytes exactly match what was written. +/// +/// # Errors +/// Returns an error if the sample file can't be created, `run_vss_job` +/// fails, the job doesn't find exactly the one sample file, or the +/// played-back content doesn't match what was written. +pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { + std::fs::create_dir_all(test_dir) + .with_context(|| format!("failed to create test dir {}", test_dir.display()))?; + + let unique_name = format!( + "uffs-content-self-test-{}.txt", + uuid::Uuid::new_v4().simple() + ); + let content = + b"UFFS content-reader self-test: real VSS snapshot + real Reader playback.\n".as_slice(); + let sample_path = test_dir.join(&unique_name); + std::fs::write(&sample_path, content) + .with_context(|| format!("failed to write sample file {}", sample_path.display()))?; + + let run_dir = test_dir.join("run"); + std::fs::create_dir_all(&run_dir) + .with_context(|| format!("failed to create run dir {}", run_dir.display()))?; + + let request = JobRequest { + source_id: "uffs-content-self-test".to_owned(), + root: test_dir.to_path_buf(), + query: unique_name, + }; + + let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; + + anyhow::ensure!( + outcome.run_summary.candidate_count == 1, + "expected exactly 1 candidate (the unique sample file), found {}", + outcome.run_summary.candidate_count + ); + anyhow::ensure!( + outcome.run_summary.succeeded_count == 1, + "expected the sample file to succeed, got {} succeeded / {} failed-retryable / {} \ + failed-terminal / {} deferred", + outcome.run_summary.succeeded_count, + outcome.run_summary.failed_retryable_count, + outcome.run_summary.failed_terminal_count, + outcome.run_summary.deferred_manual_count + ); + + let played_back = decode_single_file_content(&outcome.manifest_bytes, &outcome.frames) + .context("failed to decode the job's own manifest/frame output")?; + anyhow::ensure!( + played_back == content, + "playback content does not match the original sample file (got {} bytes, expected {})", + played_back.len(), + content.len() + ); + + Ok(()) +} + +/// Decode a manifest + frame stream that is known to describe exactly +/// one candidate, returning the bytes its `CONTENT_CHUNK` frames +/// carried. +/// +/// A narrow, self-test-only decoder — see +/// `tests/support/test_consumer.rs` for the fuller, general-purpose +/// version the parity harness uses; duplicated here (not shared) +/// because this one is production code (compiled into the shipped +/// binary), matching `uffs-content-reader-protocol`'s own "small, +/// independent duplicate" precedent for the same reason. +fn decode_single_file_content(manifest_bytes: &[u8], frames: &[Vec]) -> Result> { + let mut manifest_reader = WireReader::new(manifest_bytes); + let header = ManifestHeader::decode(&mut manifest_reader) + .map_err(|err| anyhow::anyhow!("decode manifest header: {err}"))?; + anyhow::ensure!( + header.candidate_count == 1, + "expected exactly 1 candidate in the manifest, found {}", + header.candidate_count + ); + + let mut buffered = Vec::new(); + let mut saw_file_end = false; + for frame_bytes in frames { + let mut frame_reader = WireReader::new(frame_bytes); + let (envelope, payload) = FrameEnvelope::decode(&mut frame_reader, u64::MAX) + .map_err(|err| anyhow::anyhow!("decode frame envelope: {err}"))?; + let mut payload_reader = WireReader::new(&payload); + match envelope.frame_type { + FrameType::ContentChunk => { + let chunk = ContentChunk::decode(&mut payload_reader, u32::MAX) + .map_err(|err| anyhow::anyhow!("decode CONTENT_CHUNK: {err}"))?; + buffered.extend_from_slice(&chunk.payload); + } + FrameType::FileEnd => { + FileEnd::decode(&mut payload_reader) + .map_err(|err| anyhow::anyhow!("decode FILE_END: {err}"))?; + saw_file_end = true; + } + FrameType::FileFailed | FrameType::FileDeferred => { + anyhow::bail!("candidate did not succeed (saw {:?})", envelope.frame_type); + } + FrameType::JobBegin + | FrameType::FileBegin + | FrameType::FileAck + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobEnd + | FrameType::JobCancel + | FrameType::WindowUpdate => {} + } + } + anyhow::ensure!( + saw_file_end, + "never saw a FILE_END frame for the sample file" + ); + + Ok(buffered) +} diff --git a/crates/uffs-content/src/job/snapshot_client.rs b/crates/uffs-content/src/job/snapshot_client.rs new file mode 100644 index 000000000..4bdb799b9 --- /dev/null +++ b/crates/uffs-content/src/job/snapshot_client.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Coordinator-side client for the Broker's Snapshot Manager pipe +//! (`uffs_broker_protocol::snapshot_manager::SNAPSHOT_PIPE_NAME`). +//! +//! Mirrors `uffs-daemon::broker_client`'s connect style (plain +//! `std::fs::OpenOptions` + `Read`/`Write`, no raw Win32 FFI, no +//! pipe-existence probe before the real request — see that module's +//! doc comment for why a probe would starve the real request). The +//! wire shape here is different, though: the MFT-handle protocol is a +//! fixed-length exchange, but this one is a variable-length +//! `[u32 LE length][payload]`-framed request/response, one per +//! connection — this module implements the client side of exactly the +//! framing `uffs-broker`'s `read_framed_message`/`write_framed_message` +//! implement server-side (`crates/uffs-broker/src/broker/ +//! snapshot_manager/mod.rs`). +//! +//! The Broker only replies at all if this process's own image passes +//! its Coordinator identity check (`uffs-content*.exe` + Authenticode — +//! see `verify_coordinator_identity` in the Broker module above): a +//! connection from any other binary gets no response and the Broker +//! closes the pipe. + +use std::io::{Read as _, Write as _}; + +use uffs_broker_protocol::snapshot_manager::{ + CreateSnapshotLease, CreateSnapshotLeaseResult, ReleaseSnapshotLease, SNAPSHOT_PIPE_NAME, + SnapshotManagerRequest, SnapshotManagerResponse, VolumeIdentity, +}; + +/// Matches the Broker's own `MAX_REQUEST_BYTES` — a response this large +/// would indicate a protocol desync, not a legitimate reply. +const MAX_RESPONSE_BYTES: u32 = 64 * 1024; + +/// A live snapshot lease this process holds — the Coordinator-side +/// counterpart of the Broker's `CreateSnapshotLeaseResult`. +#[expect( + clippy::struct_field_names, + reason = "field names deliberately mirror CreateSnapshotLeaseResult's own \ + wire field names for clarity when converting between the two" +)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SnapshotLease { + /// Lease identifier, used in every subsequent call for this lease. + pub(crate) snapshot_lease_id: u64, + /// Opaque VSS snapshot identifier. + pub(crate) snapshot_id: Vec, + /// Device path the snapshot is reachable at (e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`). + pub(crate) snapshot_device_identity: String, + /// Snapshot creation time, Unix milliseconds. + pub(crate) snapshot_created_at_unix_ms: i64, + /// Lease expiry, Unix milliseconds. + pub(crate) expires_at_unix_ms: i64, +} + +/// Request a new snapshot lease from the Broker. +/// +/// # Errors +/// Returns an error if the pipe can't be opened (e.g. the Broker isn't +/// running, or this process fails its identity check), the request +/// can't be sent, the response can't be read/decoded, or the Broker +/// reports failure (`SnapshotManagerResponse::Error`). +pub(crate) fn create_lease( + authenticated_job_id: [u8; 16], + source_volume_identity: VolumeIdentity, + requested_root: Vec, + maximum_lifetime_secs: u64, + policy_id: u32, +) -> anyhow::Result { + let request = SnapshotManagerRequest::Create(CreateSnapshotLease { + authenticated_job_id, + source_volume_identity, + requested_root, + maximum_lifetime_secs, + policy_id, + }); + match round_trip(&request)? { + SnapshotManagerResponse::Created(CreateSnapshotLeaseResult { + snapshot_lease_id, + snapshot_id, + snapshot_device_identity, + snapshot_created_at_unix_ms, + expires_at_unix_ms, + }) => Ok(SnapshotLease { + snapshot_lease_id, + snapshot_id, + snapshot_device_identity, + snapshot_created_at_unix_ms, + expires_at_unix_ms, + }), + SnapshotManagerResponse::Error { code, message } => { + anyhow::bail!("Broker rejected Create: {code:?}: {message}") + } + other @ (SnapshotManagerResponse::Duplicated + | SnapshotManagerResponse::Renewed { .. } + | SnapshotManagerResponse::Released + | SnapshotManagerResponse::Status(_)) => { + anyhow::bail!("unexpected response to Create: {other:?}") + } + } +} + +/// Release a previously created lease. +/// +/// # Errors +/// Returns an error if the pipe round trip fails or the Broker reports +/// failure. +pub(crate) fn release_lease(snapshot_lease_id: u64) -> anyhow::Result<()> { + let request = SnapshotManagerRequest::Release(ReleaseSnapshotLease { snapshot_lease_id }); + match round_trip(&request)? { + SnapshotManagerResponse::Released => Ok(()), + SnapshotManagerResponse::Error { code, message } => { + anyhow::bail!("Broker rejected Release: {code:?}: {message}") + } + other @ (SnapshotManagerResponse::Created(_) + | SnapshotManagerResponse::Duplicated + | SnapshotManagerResponse::Renewed { .. } + | SnapshotManagerResponse::Status(_)) => { + anyhow::bail!("unexpected response to Release: {other:?}") + } + } +} + +/// Open the Snapshot Manager pipe, send one framed request, and read +/// back one framed response. +/// +/// # Errors +/// Returns an error if the pipe can't be opened, the write/read fails, +/// the response exceeds [`MAX_RESPONSE_BYTES`], or the payload doesn't +/// decode as a valid [`SnapshotManagerResponse`]. +fn round_trip(request: &SnapshotManagerRequest) -> anyhow::Result { + let mut pipe = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(std::path::Path::new(SNAPSHOT_PIPE_NAME)) + .map_err(|err| anyhow::anyhow!("opening Snapshot Manager pipe: {err}"))?; + + write_framed_message(&mut pipe, &request.encode())?; + let response_bytes = read_framed_message(&mut pipe)?; + SnapshotManagerResponse::decode(&response_bytes) + .map_err(|err| anyhow::anyhow!("malformed Snapshot Manager response: {err}")) +} + +/// Write `payload` as `[u32 LE length][payload]`, flushing immediately — +/// the client-side mirror of the Broker's `write_framed_message`. +fn write_framed_message(pipe: &mut std::fs::File, payload: &[u8]) -> anyhow::Result<()> { + let length = u32::try_from(payload.len()) + .map_err(|err| anyhow::anyhow!("request payload too large to frame: {err}"))?; + pipe.write_all(&length.to_le_bytes())?; + pipe.write_all(payload)?; + pipe.flush()?; + Ok(()) +} + +/// Read one `[u32 LE length][payload]`-framed message — the +/// client-side mirror of the Broker's `read_framed_message`. +fn read_framed_message(pipe: &mut std::fs::File) -> anyhow::Result> { + let mut length_bytes = [0_u8; 4]; + pipe.read_exact(&mut length_bytes)?; + let length = u32::from_le_bytes(length_bytes); + if length > MAX_RESPONSE_BYTES { + anyhow::bail!("response length {length} exceeds maximum {MAX_RESPONSE_BYTES}"); + } + + let mut payload = vec![0_u8; usize::try_from(length).unwrap_or(0)]; + pipe.read_exact(&mut payload)?; + Ok(payload) +} diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index c27a1240d..2a956b15a 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -77,17 +77,17 @@ fn fs_content_source_reads_bounded_ranges_and_reports_eof() { let entry = entries.first().expect("one entry expected"); let first_half = FsContentSource - .read_at(entry, 0, 5) + .read_at(entry, 0, 0, 5) .expect("read first half"); assert_eq!(first_half, b"01234"); let second_half = FsContentSource - .read_at(entry, 5, 5) + .read_at(entry, 0, 5, 5) .expect("read second half"); assert_eq!(second_half, b"56789"); let past_eof = FsContentSource - .read_at(entry, 10, 5) + .read_at(entry, 0, 10, 5) .expect("read past EOF must not error"); assert!(past_eof.is_empty(), "read at EOF must return no bytes"); } @@ -138,6 +138,7 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { let request = JobRequest { source_id: "test-source".to_owned(), root: source_dir.path().to_path_buf(), + query: "*".to_owned(), }; let outcome = run_job( diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs new file mode 100644 index 000000000..b4f4630ec --- /dev/null +++ b/crates/uffs-content/src/job/vss_job.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! End-to-end VSS-backed job execution — the real production path. +//! +//! Ties every piece built for UFI.1/UFI.2 together: lease the drive(s) +//! a job's root touches, spin up the ephemeral target-selection daemon, +//! enumerate candidates against it, spawn the privileged content Reader, +//! stream content through [`super::workflow::run_job`], and tear +//! everything down — in the right order (content Reader/leases outlive +//! candidate enumeration; see +//! [`super::vss_orchestrator::EphemeralJobResources`] for why daemon and leases +//! are bundled into one teardown step). +//! +//! Windows-only: every piece this wires together already is. + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{Context as _, Result}; + +use super::candidate_source::VssCandidateSource; +use super::content_source::VssContentSource; +use super::intake::JobRequest; +use super::reader_client::ContentReader; +use super::vss_orchestrator; +use super::workflow::{JobOutcome, run_job}; + +/// Run `request` end to end against a real VSS snapshot. +/// +/// # Errors +/// Returns an error if any VSS lease, ephemeral daemon spawn, or +/// content Reader spawn step fails, or if the underlying `run_job` call +/// fails. Every resource successfully acquired before a failure is +/// released best-effort before returning. +pub fn run_vss_job(request: &JobRequest, run_dir: &Path) -> Result { + let job_id = *uuid::Uuid::new_v4().as_bytes(); + let ephemeral_id = uuid::Uuid::new_v4().simple().to_string(); + + let resources = vss_orchestrator::prepare_ephemeral_daemon_for_roots( + job_id, + &[request.root.as_path()], + &ephemeral_id, + ) + .context("failed to lease VSS snapshot(s) and spawn the target-selection daemon")?; + + let drive_to_lease: HashMap = resources + .leases + .iter() + .map(|lease| (lease.drive_letter, lease.lease_id)) + .collect(); + let candidate_source = + VssCandidateSource::new(request.query.clone(), &resources.daemon, drive_to_lease); + + let devices_for_reader: Vec<(String, u64)> = resources + .leases + .iter() + .map(|lease| (lease.device_path.clone(), lease.lease_id)) + .collect(); + let content_reader = ContentReader::spawn(job_id, &devices_for_reader) + .context("failed to spawn the content reader")?; + let content_source = VssContentSource::new(content_reader); + + let result = + run_job(request, &candidate_source, &content_source, run_dir).context("run_job failed"); + + // Drop the candidate source first (releases its borrow of + // `resources.daemon`, which `resources.teardown()` below needs to + // consume by value), then tear down the Reader and the + // daemon/leases explicitly so a failed teardown is observable + // rather than silently swallowed by `Drop`. + drop(candidate_source); + if let Err(err) = content_source.shutdown() { + tracing::warn!(error = %err, "failed to fully tear down content reader"); + } + if let Err(err) = resources.teardown() { + tracing::warn!(error = %err, "failed to fully tear down VSS job resources"); + } + + result +} diff --git a/crates/uffs-content/src/job/vss_orchestrator.rs b/crates/uffs-content/src/job/vss_orchestrator.rs new file mode 100644 index 000000000..1c12c3729 --- /dev/null +++ b/crates/uffs-content/src/job/vss_orchestrator.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Ties [`super::snapshot_client`] and [`super::ephemeral_daemon`] together. +//! +//! Leases one VSS snapshot per distinct drive a job's roots touch, then +//! spawns a single ephemeral `uffsd` instance covering all of them — per +//! the user's direct design decision: "create a VSS for each drive +//! (multiple) ... then when all done have the UFFS-content tool spin up +//! one instance of the daemon covering all the VSS MFT copies." + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{Context as _, Result}; +use uffs_broker_protocol::snapshot_manager::VolumeIdentity; + +use super::ephemeral_daemon::EphemeralDaemon; +use super::snapshot_client; + +/// Default VSS snapshot lease lifetime. +/// +/// Generous relative to a single ingest job's expected wall-clock time; +/// revisit once real job-duration telemetry exists (no policy schema +/// for this yet — see [`DEFAULT_POLICY_ID`]). +const DEFAULT_LEASE_LIFETIME_SECS: u64 = 3600; + +/// Placeholder policy id — the Broker's authorization-policy schema +/// doesn't exist yet; every lease request uses this until it does. +const DEFAULT_POLICY_ID: u32 = 0; + +/// One leased drive: the snapshot device path, the drive letter it was +/// leased from, and the lease id — everything a caller needs to build +/// either the daemon's `--device =` args or the Reader's +/// `--device =` args, or to correlate a query result +/// row's drive letter back to the lease that produced it. +pub(crate) struct LeasedDrive { + /// VSS snapshot device path (e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`). + pub(crate) device_path: String, + /// Drive letter the snapshot was taken from. + pub(crate) drive_letter: char, + /// This drive's lease id. + pub(crate) lease_id: u64, +} + +/// Every live resource this orchestration step produced. +/// +/// The daemon and the leases have different intended lifetimes: the +/// daemon is only needed for target-selection queries (`enumerate`) and +/// can be torn down as soon as that's done, while the leases must stay +/// alive for the whole job (content reading happens against the same +/// snapshots afterward). This struct bundles both anyway and tears them +/// down together via [`Self::teardown`] — a v1 simplification (the +/// daemon's memory stays resident through content-reading unnecessarily) +/// documented here rather than silently accepted; revisit if daemon +/// memory footprint during long content-reading phases matters in +/// practice. +pub(crate) struct EphemeralJobResources { + /// The running ephemeral daemon covering every leased drive. + pub(crate) daemon: EphemeralDaemon, + /// Every drive this job leased, in lease order. + pub(crate) leases: Vec, +} + +impl EphemeralJobResources { + /// Tear down the daemon, then release every lease. Daemon teardown + /// runs first — releasing a lease out from under a still-running + /// daemon would pull the volume out from under its loaded index. + /// Lease release is best-effort per-lease (a single failed release + /// is logged, not fatal — see [`release_all_leases`]). + /// + /// # Errors + /// Returns an error if the daemon couldn't be killed. Lease-release + /// failures are logged, not propagated. + pub(crate) fn teardown(self) -> Result<()> { + self.daemon.shutdown()?; + let lease_ids: Vec = self.leases.iter().map(|lease| lease.lease_id).collect(); + release_all_leases(&lease_ids); + Ok(()) + } +} + +/// Lease one VSS snapshot per distinct drive letter across `roots`, +/// then spawn one combined ephemeral daemon covering all of them. +/// +/// Drive letters are read directly off each root path's own prefix +/// (`drive_letter_from_path`) — never inferred from the MFT: the +/// Coordinator already knows which drive it's snapshotting, per the +/// user's explicit correction during design. +/// +/// # Errors +/// Returns an error if any root has no drive-letter prefix, any lease +/// request fails, or the ephemeral daemon fails to spawn or become +/// ready. On error, any leases already taken out are released +/// best-effort before returning. +pub(crate) fn prepare_ephemeral_daemon_for_roots( + job_id: [u8; 16], + roots: &[&Path], + ephemeral_id: &str, +) -> Result { + let mut leases: Vec = Vec::new(); + let mut seen_letters = HashSet::new(); + + for root in roots { + let letter = drive_letter_from_path(root) + .with_context(|| format!("job root {} has no drive-letter prefix", root.display()))?; + if !seen_letters.insert(letter) { + continue; // already leased this drive for an earlier root + } + + let requested_root = utf16le_bytes(&format!("{letter}:\\")); + let lease_result = snapshot_client::create_lease( + job_id, + VolumeIdentity { + // Presently inert: the Broker's real `create_snapshot` + // path derives the volume to snapshot from + // `requested_root`, not this struct (confirmed via + // direct source read) — populate a real serial/GUID + // once the Broker actually validates against it. + volume_serial: 0, + volume_guid: Vec::new(), + }, + requested_root, + DEFAULT_LEASE_LIFETIME_SECS, + DEFAULT_POLICY_ID, + ); + + let lease = match lease_result { + Ok(lease) => lease, + Err(err) => { + release_all_leases(&lease_ids(&leases)); + return Err( + err.context(format!("failed to lease a VSS snapshot for drive {letter}")) + ); + } + }; + leases.push(LeasedDrive { + device_path: lease.snapshot_device_identity, + drive_letter: letter, + lease_id: lease.snapshot_lease_id, + }); + } + + let devices: Vec<(String, char)> = leases + .iter() + .map(|lease| (lease.device_path.clone(), lease.drive_letter)) + .collect(); + + match EphemeralDaemon::spawn(ephemeral_id, &devices) { + Ok(daemon) => Ok(EphemeralJobResources { daemon, leases }), + Err(err) => { + release_all_leases(&lease_ids(&leases)); + Err(err) + } + } +} + +/// Extract just the lease ids from `leases`, for [`release_all_leases`]. +fn lease_ids(leases: &[LeasedDrive]) -> Vec { + leases.iter().map(|lease| lease.lease_id).collect() +} + +/// Release every lease in `lease_ids`. Best-effort: a single failed +/// release is warn-logged, not propagated — teardown must proceed even +/// if one lease is already gone or the Broker is unreachable. +fn release_all_leases(lease_ids: &[u64]) { + for &lease_id in lease_ids { + if let Err(err) = snapshot_client::release_lease(lease_id) { + tracing::warn!(lease_id, error = %err, "failed to release VSS snapshot lease"); + } + } +} + +/// Extract the drive letter a Windows path is rooted on (e.g. +/// `C:\Users\x` -> `'C'`). +/// +/// Cheap and purely syntactic: reads the path's own `Prefix` component. +/// Never touches the filesystem or the MFT — the whole point is that +/// the Coordinator already knows which drive it's snapshotting from the +/// job's own root path. +fn drive_letter_from_path(path: &Path) -> Option { + use std::path::{Component, Prefix}; + + let Component::Prefix(prefix) = path.components().next()? else { + return None; + }; + let (Prefix::Disk(byte) | Prefix::VerbatimDisk(byte)) = prefix.kind() else { + return None; + }; + Some(byte.to_ascii_uppercase() as char) +} + +/// Encode `text` as raw UTF-16LE bytes (no null terminator) — the wire +/// format [`snapshot_client::create_lease`]'s `requested_root` expects, +/// mirroring the Broker's own `utf16le_bytes` helper +/// (`crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs`, +/// `pub(crate)` there so not reusable directly from this crate). +fn utf16le_bytes(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_le_bytes).collect() +} diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 2961461e1..cd7bb48a1 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -211,7 +211,7 @@ fn stream_one_candidate( let mut read_error = None; while offset < entry.logical_size { - match content_source.read_at(entry, offset, max_chunk_bytes) { + match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { Ok(bytes) if bytes.is_empty() => break, Ok(bytes) => { let read_len = len_as_u64(bytes.len()); diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 8e74bae87..c381bae4e 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -45,6 +45,12 @@ pub mod run; // unit tests. #[cfg(test)] use blake3 as _; +// Will spawn/query the ephemeral `uffsd` instance once the real, +// VSS+MFT-query-backed `CandidateSource` is wired up (not yet — the +// dead-code state on `job::snapshot_client` today is the same +// deliberately-deferred state, see that module's doc comment). +#[cfg(windows)] +use uffs_client as _; use uffs_version as _; /// Whether the production, VSS-snapshot-backed pipeline is wired up. diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 3b68966c4..b95a30535 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -16,15 +16,21 @@ //! `docs/dev/architecture/` (local-only) for the surrounding design //! review. //! -//! # Usage (planned) +//! # Usage //! //! ```bash -//! uffs-content --version # Print version (also -V) +//! uffs-content --version # Print version (also -V) +//! uffs-content --self-test-vss-playback # Elevated smoke test: real VSS +//! # snapshot + real Reader playback //! ``` // Reserved for the wire types the bin will emit once job intake is wired // up as a real CLI entry point; not yet used from this thin bin. // Dev-dependencies used by `uffs_content`'s tests, not by this bin. +// Used by `uffs_content::job::snapshot_client` (the real Snapshot +// Manager pipe client), not by this thin entry point directly. +#[cfg(windows)] +use anyhow as _; #[cfg(test)] use blake3 as _; // Used by `uffs_content::run` (failure log + summary serialization), not @@ -33,7 +39,21 @@ use serde as _; use serde_json as _; #[cfg(test)] use tempfile as _; +// Used by `uffs_content::job::vss_orchestrator` (best-effort +// lease-release warnings), not by this thin entry point directly. +#[cfg(windows)] +use tracing as _; +#[cfg(windows)] +use uffs_broker_protocol as _; +// Used to spawn/query the ephemeral `uffsd` instance (not by this thin +// entry point directly). +#[cfg(windows)] +use uffs_client as _; use uffs_content_protocol as _; +// Used by `uffs_content::job::reader_client`/`content_source::VssContentSource`, +// not by this thin entry point directly. +#[cfg(windows)] +use uffs_content_reader_protocol as _; // Used by `uffs_content::job::workflow`, not by this thin entry point // directly. use uuid as _; @@ -50,9 +70,65 @@ fn main() { // `uffsd` so the self-update version probe can parse it uniformly. uffs_version::handle_version!("uffs-content"); + let args: Vec = std::env::args().collect(); + if let Some(test_dir) = self_test_vss_playback_dir(&args) { + std::process::exit(run_self_test_vss_playback(&test_dir)); + } + if uffs_content::is_implemented() { eprintln!("uffs-content: ready."); } else { eprintln!("uffs-content: scaffold only, job intake is not yet implemented."); } } + +/// Return the directory argument following `--self-test-vss-playback`, +/// if present. +#[cfg(windows)] +fn self_test_vss_playback_dir(args: &[String]) -> Option { + let flag_index = args + .iter() + .position(|arg| arg == "--self-test-vss-playback")?; + args.get(flag_index + 1).map(std::path::PathBuf::from) +} + +/// Non-Windows stub: `--self-test-vss-playback` needs a real VSS +/// snapshot, which doesn't exist on this platform. +#[cfg(not(windows))] +const fn self_test_vss_playback_dir(_args: &[String]) -> Option { + None +} + +/// Run [`uffs_content::job::self_test::self_test_vss_playback`] and +/// print a PASS/FAIL result — a manual, elevated smoke test proving the +/// real VSS-snapshot + privileged-Reader content pipeline works at +/// runtime on this machine. Returns the process exit code (`0` pass, +/// `1` fail). +#[cfg(windows)] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn run_self_test_vss_playback(test_dir: &std::path::Path) -> i32 { + match uffs_content::job::self_test::self_test_vss_playback(test_dir) { + Ok(()) => { + eprintln!( + "PASS: VSS snapshot + Reader playback round trip succeeded ({})", + test_dir.display() + ); + 0 + } + Err(err) => { + eprintln!("FAIL: {err:#}"); + 1 + } + } +} + +/// Non-Windows stub, matching [`self_test_vss_playback_dir`] always +/// returning `None` there (so this is unreachable in practice, but kept +/// for a symmetrical `#[cfg]` shape). +#[cfg(not(windows))] +const fn run_self_test_vss_playback(_test_dir: &std::path::Path) -> i32 { + 1 +} diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index e72c846bd..276e039fe 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -16,9 +16,12 @@ //! framing, and the ephemeral run-state bookkeeping — only the "how do //! we get the candidate list and bytes" step is faked. //! -//! The real-VSS variant (§9.4, Windows-only, `#[ignore]`) is not built -//! yet — it needs `uffs-content-reader` and a real Broker Snapshot -//! Manager, neither of which exist yet. +//! The real-VSS variant (§9.4, Windows-only, `#[ignore]`) is not wired +//! up as an end-to-end test yet — `uffs-content-reader` and the Broker +//! Snapshot Manager both exist now (see `crates/uffs-content-reader/` +//! and `crates/uffs-broker/src/broker/snapshot_manager/`), but nothing +//! yet calls `job::vss_orchestrator`/`job::reader_client` end to end +//! from `workflow::run_job`. // `#[cfg(test)]` so clippy's test-code relaxations (`allow-expect-in-tests` // et al. in `clippy.toml`) apply inside `support/`'s helper files too — @@ -29,8 +32,21 @@ mod support; // This crate's own dependencies (shared across the lib, bin, and every // integration test binary), not used directly from this particular test. +// Windows-only deps, not used directly from this cross-platform fake- +// pipeline test — see `src/main.rs`'s matching markers for the same +// per-target rationale (each test binary is its own compilation unit). +#[cfg(windows)] +use anyhow as _; use serde as _; use serde_json as _; +#[cfg(windows)] +use tracing as _; +#[cfg(windows)] +use uffs_broker_protocol as _; +#[cfg(windows)] +use uffs_client as _; +#[cfg(windows)] +use uffs_content_reader_protocol as _; use uffs_version as _; use uuid as _; @@ -67,6 +83,7 @@ mod tests { let request = JobRequest { source_id: "fixture-source".to_owned(), root: source_dir.path().to_path_buf(), + query: "*".to_owned(), }; let outcome = run_job( &request, diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs new file mode 100644 index 000000000..1b2469a7a --- /dev/null +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Real-VSS, real-Reader end-to-end playback test — +//! `uffs-ingest-implementation-plan.md` §9.4's real-VSS variant, and +//! §5.4's "real snapshot, real file, assert bytes match" live test. +//! +//! Thin wrapper: calls +//! [`uffs_content::job::self_test::self_test_vss_playback`] — the exact +//! same production function `uffs-content --self-test-vss-playback` and +//! `scripts/windows/content-reader-validation.rs` both exercise, so none +//! of the three ever drift apart (mirrors `uffs-broker`'s +//! `--self-test-vss` / `cargo test -p uffs-broker -- --ignored` / +//! `scripts/windows/vss-snapshot-validation.rs` trio). +//! +//! # Requirements to actually run this test +//! +//! - Windows, and this test process itself running elevated (VSS snapshot +//! creation and the Reader's `OpenFileById` device-path open both require it +//! — see `job::vss_orchestrator`'s and +//! `uffs-content-reader/src/reader/logical.rs`'s doc comments for why +//! elevation is a deliberate v1 choice, not yet Broker-mediated). +//! - `uffs-broker --install` already run once on the host (or the Broker's +//! Snapshot Manager reachable some other way). +//! - `uffsd` and `uffs-content-reader` built and discoverable next to this test +//! binary (both are spawned as child processes). +//! +//! None of that is available in this workspace's ordinary CI lanes, so +//! this is `#[ignore]` — run explicitly on a prepared Windows box with +//! `cargo test -p uffs-content --test e2e_real_vss_content_reader -- +//! --ignored`. + +#![cfg(test)] + +// This crate's own dependencies (shared across every integration test +// binary in this crate), not used directly from this particular test +// module on every platform. +// These are Windows-only deps of `uffs-content` itself +// (`job::vss_orchestrator`/`ephemeral_daemon`/`reader_client`/ +// `self_test`), reached transitively through `uffs_content::job:: +// self_test::self_test_vss_playback` but not named directly by this +// thin test — same rationale as `src/main.rs`'s matching markers. +#[cfg(windows)] +use anyhow as _; +use blake3 as _; +use serde as _; +use serde_json as _; +// Used only inside the `#[cfg(windows)] mod windows_tests` below — the +// real test body needs Windows, so these are otherwise +// visible-but-unused on every other platform. +#[cfg(not(windows))] +use tempfile as _; +#[cfg(windows)] +use tracing as _; +#[cfg(windows)] +use uffs_broker_protocol as _; +#[cfg(windows)] +use uffs_client as _; +#[cfg(not(windows))] +use uffs_content as _; +use uffs_content_protocol as _; +#[cfg(windows)] +use uffs_content_reader_protocol as _; +use uffs_version as _; +use uuid as _; + +/// All real test code is Windows-only — see the module doc for why. +#[cfg(windows)] +mod windows_tests { + /// Playback through the real VSS + Reader pipeline must reproduce a + /// freshly created, uniquely-named sample file's content exactly. + /// + /// # Requirements + /// See this file's module doc comment. + #[test] + #[ignore = "requires Windows, elevation, an installed uffs-broker, and \ + uffsd/uffs-content-reader built alongside the test binary"] + fn real_vss_playback_matches_original_file_content() { + let test_dir = tempfile::tempdir().expect("create test dir"); + uffs_content::job::self_test::self_test_vss_playback(test_dir.path()) + .expect("real VSS snapshot + Reader playback round trip must succeed"); + } +} diff --git a/crates/uffs-core/src/compact_loader.rs b/crates/uffs-core/src/compact_loader.rs index 03cd484fa..93e54463a 100644 --- a/crates/uffs-core/src/compact_loader.rs +++ b/crates/uffs-core/src/compact_loader.rs @@ -45,6 +45,14 @@ pub enum MftSource { /// Live Windows NTFS volume (e.g., `'C'`). #[cfg(windows)] Live(uffs_mft::platform::DriveLetter), + /// A VSS snapshot device path (e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`), read via + /// [`uffs_mft::MftReader::open_device_path`]. The `DriveLetter` is + /// the drive the snapshot was taken from — used only to build the + /// compact index and for diagnostics, never as a cache key (see + /// [`Self::is_ephemeral_device`]). + #[cfg(windows)] + Device(String, uffs_mft::platform::DriveLetter), } impl MftSource { @@ -61,7 +69,29 @@ impl MftSource { match self { Self::File(path, _) => Some(path), #[cfg(windows)] - Self::Live(_) => None, + Self::Live(_) | Self::Device(..) => None, + } + } + + /// Whether this source's data must never be persisted to (or served + /// from) the drive-letter-keyed on-disk caches — `uffs-mft`'s + /// `.uffs` index cache and `uffs-core`'s compact cache. + /// + /// A VSS snapshot device is an ephemeral, point-in-time capture that + /// happens to report the same drive letter as the live volume it was + /// taken from. Both caches key purely by drive letter, so sharing + /// that key would let the resident daemon serve snapshot data as if + /// it were live, or (worse) let a snapshot read silently overwrite + /// the live drive's cache. `Device` sources are always read fresh + /// and never cached — see [`load_mft_index_from_device`]. + #[must_use] + pub const fn is_ephemeral_device(&self) -> bool { + match self { + Self::File(..) => false, + #[cfg(windows)] + Self::Live(_) => false, + #[cfg(windows)] + Self::Device(..) => true, } } } @@ -91,7 +121,7 @@ pub fn load_drive( .unwrap_or(uffs_mft::platform::DriveLetter::X) }), #[cfg(windows)] - MftSource::Live(ch) => *ch, + MftSource::Live(ch) | MftSource::Device(_, ch) => *ch, }; // ── Load MftIndex (cache + USN replay, or cold) ──────────────── @@ -118,6 +148,8 @@ pub fn load_drive( MftSource::File(path, _) => load_mft_index_from_file(path, drive_letter, no_cache)?, #[cfg(windows)] MftSource::Live(ch) => load_mft_index_live(*ch, no_cache)?, + #[cfg(windows)] + MftSource::Device(device_path, ch) => load_mft_index_from_device(device_path, *ch)?, }; let mft_elapsed = mft_start.elapsed().as_millis(); @@ -135,7 +167,11 @@ pub fn load_drive( } // ── Save compact cache (background, best-effort) ──────────────── - if !no_cache { + // + // Never persist an ephemeral VSS-snapshot-device read: it shares its + // drive letter's cache key with the live drive but is a distinct, + // point-in-time capture (see `MftSource::is_ephemeral_device`). + if !no_cache && !source.is_ephemeral_device() { let t_compact_save = Instant::now(); if let Err(err) = crate::compact_cache::save_compact_cache_background(&compact) { tracing::warn!(drive = %drive_letter, error = %err, "Failed to start compact cache save"); @@ -400,6 +436,38 @@ fn load_mft_index_live( rt.block_on(read_index) } +/// Load `MftIndex` fresh from a VSS snapshot device path. +/// +/// Always a fresh read — deliberately never consults or populates the +/// drive-letter-keyed `.uffs` index cache (unlike +/// [`load_mft_index_live`]/[`load_mft_index_from_file`]). See +/// [`MftSource::is_ephemeral_device`] for why sharing that cache key +/// would be a correctness bug, not just a missed optimization. +#[cfg(windows)] +fn load_mft_index_from_device( + device_path: &str, + drive_letter: uffs_mft::platform::DriveLetter, +) -> anyhow::Result { + use anyhow::Context as _; + + let read_index = async { + let reader = uffs_mft::MftReader::open_device_path(device_path, drive_letter) + .with_context(|| format!("Failed to open snapshot device {device_path}"))?; + reader + .read_all_index() + .await + .with_context(|| format!("Failed to read MFT from snapshot device {device_path}")) + }; + + // See `load_mft_index_live`'s matching comment: a dedicated + // current-thread runtime is always safe regardless of the calling + // context (Tokio worker thread, blocking thread, or no runtime). + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(read_index) +} + /// Statistics from in-place USN patching. #[derive(Debug, Clone, Default)] pub struct PatchStats { diff --git a/crates/uffs-core/src/search/display_row.rs b/crates/uffs-core/src/search/display_row.rs index 31b00c6f0..d9eedb4f7 100644 --- a/crates/uffs-core/src/search/display_row.rs +++ b/crates/uffs-core/src/search/display_row.rs @@ -74,6 +74,12 @@ pub struct DisplayRow { /// ill-formed names — it is keyed on name validity, never on projection. /// JSON output therefore carries it by default for malformed rows. pub name_hex: Option, + /// NTFS **File Reference** (`(sequence_number << 48) | frs`) — see + /// [`crate::compact::CompactRecord::file_ref`]. `0` by default; + /// [`Self::with_file_reference`] carries the real value from the + /// hot path's `CompactRecord`, mirroring [`Self::with_forensics`]'s + /// pattern so `new()`'s existing call sites stay untouched. + pub file_reference: u64, } impl DisplayRow { @@ -121,6 +127,7 @@ impl DisplayRow { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } @@ -142,6 +149,17 @@ impl DisplayRow { self } + /// Attach the NTFS file reference from the `CompactRecord` this row + /// was built from. Chained after [`Self::new`] for the same reason + /// as [`Self::with_forensics`] — keeps the many existing `new()` + /// call sites untouched. + #[must_use] + #[inline] + pub const fn with_file_reference(mut self, file_reference: u64) -> Self { + self.file_reference = file_reference; + self + } + /// Filename portion of the path (e.g., `file.txt`). /// /// Zero-cost: returns a `&str` slice into the owned `path`. @@ -208,6 +226,7 @@ impl Default for DisplayRow { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } } diff --git a/crates/uffs-core/src/search/query/mod.rs b/crates/uffs-core/src/search/query/mod.rs index 0547c57a9..07359a783 100644 --- a/crates/uffs-core/src/search/query/mod.rs +++ b/crates/uffs-core/src/search/query/mod.rs @@ -637,6 +637,7 @@ pub(super) fn make_display_row( forensics.malformed_path, forensics.name_hex, ) + .with_file_reference(rec.file_ref) } /// Resolve `rec_idx`'s path (with the malformed-path bit) using the supplied diff --git a/crates/uffs-daemon/src/broker_client.rs b/crates/uffs-daemon/src/broker_client.rs index 4c47d9b6c..8e5e372ba 100644 --- a/crates/uffs-daemon/src/broker_client.rs +++ b/crates/uffs-daemon/src/broker_client.rs @@ -105,3 +105,69 @@ fn interpret_handle_response( } } } + +/// Run the broker warm-up only when the daemon is **not** already elevated. +/// +/// Gate on the daemon's OWN elevation, not on a broker probe. An elevated +/// daemon opens volumes directly (`CreateFileW`), so a broker request would be +/// a futile per-drive pipe-open + WARN. Crucially, `is_elevated()` is a token +/// query — it does NOT touch the broker pipe, so it has none of the race the +/// removed `broker_available()` probe had (that probe connected to and consumed +/// the broker's single pipe instance, starving the real request; 2026-06-13 VM +/// finding). When NOT elevated, the handle request itself is the authoritative +/// broker-presence test: it succeeds when a broker is serving and fails fast +/// (WARN + direct-open fallback) when not. +/// +/// Extracted from `crate::load_live_drives_if_windows` so that caller stays +/// under the `cognitive_complexity` ceiling. +#[cfg(windows)] +pub(crate) fn warm_up_broker_handles_unless_elevated(drives: &[uffs_mft::platform::DriveLetter]) { + if uffs_mft::is_elevated() { + tracing::debug!( + pid = std::process::id(), + "Daemon is elevated — skipping broker warm-up (direct volume open)" + ); + return; + } + tracing::info!( + pid = std::process::id(), + drive_count = drives.len(), + "Daemon not elevated — attempting broker warm-up" + ); + warm_up_broker_handles(drives); +} + +/// Best-effort broker pre-warm: ask the elevated broker for a volume +/// handle per drive so the subsequent `load_live_drives` skips the +/// per-drive elevation prompt. Failures are debug-traced and +/// ignored — the direct-open path takes over transparently. +#[cfg(windows)] +fn warm_up_broker_handles(drives: &[uffs_mft::platform::DriveLetter]) { + tracing::info!( + daemon_pid = std::process::id(), + drives = ?drives, + "warm_up_broker_handles: requesting volume handles from the Access Broker" + ); + for &drive_letter in drives { + match request_volume_handle(drive_letter) { + Ok(handle) => { + // Deposit the broker's (elevated, overlapped) volume handle in + // the uffs-mft registry; the subsequent `VolumeHandle::open` + // for this drive adopts it instead of calling `CreateFileW` + // (which a non-elevated daemon can't do). This is what makes + // the broker path actually load the MFT — previously the + // handle was fetched and dropped, so the reader fell back to a + // direct open and failed with access-denied. + uffs_mft::register_broker_handle(drive_letter, handle); + tracing::info!(drive = %drive_letter, handle, "Registered broker volume handle"); + } + Err(broker_err) => { + tracing::warn!( + drive = %drive_letter, + error = %broker_err, + "Access Broker handle request FAILED — falling back to direct (elevated) open" + ); + } + } + } +} diff --git a/crates/uffs-daemon/src/handler_csv_blob_tests.rs b/crates/uffs-daemon/src/handler_csv_blob_tests.rs index 4bafd9a13..3058da6ce 100644 --- a/crates/uffs-daemon/src/handler_csv_blob_tests.rs +++ b/crates/uffs-daemon/src/handler_csv_blob_tests.rs @@ -78,6 +78,7 @@ fn sample_row( malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } diff --git a/crates/uffs-daemon/src/handler_paths_blob_tests.rs b/crates/uffs-daemon/src/handler_paths_blob_tests.rs index 009437060..d77cbf841 100644 --- a/crates/uffs-daemon/src/handler_paths_blob_tests.rs +++ b/crates/uffs-daemon/src/handler_paths_blob_tests.rs @@ -39,6 +39,7 @@ fn path_only_row(path: String) -> SearchRow { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } diff --git a/crates/uffs-daemon/src/index/loading.rs b/crates/uffs-daemon/src/index/loading.rs index 27063da48..df3f0f0b7 100644 --- a/crates/uffs-daemon/src/index/loading.rs +++ b/crates/uffs-daemon/src/index/loading.rs @@ -196,6 +196,112 @@ impl IndexManager { }); } + /// Load VSS-snapshot device sources — **all in parallel**. + /// + /// Mirrors [`Self::load_from_data_dir`]'s plain `JoinSet` pattern + /// (no semaphore throttling — an ephemeral instance loads at most a + /// handful of drives, not the "100 drives" case + /// [`Self::load_live_drives`]'s bounded fan-out guards against). + /// Each `(device_path, drive)` pair is read fresh — always + /// `no_cache = true` — via [`uffs_core::compact::MftSource::Device`], + /// which never touches the drive-letter-keyed on-disk caches (see + /// that variant's doc comment for why: this drive letter's *live* + /// cache, if any, must never be conflated with a point-in-time + /// snapshot capture). + #[cfg(windows)] + pub(crate) async fn load_device_drives( + &self, + devices: &[(String, uffs_mft::platform::DriveLetter)], + ) { + let total = devices.len(); + *self.status.write().await = DaemonStatus::Loading { + drives_loaded: 0, + drives_total: total, + }; + + let mut join_set = Self::spawn_device_drive_loaders(devices); + + let mut loaded: usize = 0; + while let Some(join_result) = join_set.join_next().await { + self.apply_device_drive_load_result(join_result, &mut loaded, total) + .await; + } + + self.tune_concurrency().await; + self.set_ready().await; + self.emit_data_dir_ready_summary().await; + } + + /// Spawn one blocking task per device source, returning the + /// `JoinSet` the caller drains for incremental progress. + #[cfg(windows)] + fn spawn_device_drive_loaders( + devices: &[(String, uffs_mft::platform::DriveLetter)], + ) -> tokio::task::JoinSet<( + uffs_mft::platform::DriveLetter, + anyhow::Result<( + uffs_core::compact::DriveCompactIndex, + uffs_core::compact::LoadTiming, + )>, + )> { + let mut join_set = tokio::task::JoinSet::new(); + for (device_path, drive) in devices { + let path = device_path.clone(); + let letter = *drive; + tracing::info!(device = %path, drive = %letter, "Loading snapshot device (parallel)"); + join_set.spawn_blocking(move || { + let source = uffs_core::compact::MftSource::Device(path, letter); + let result = uffs_core::compact::load_drive(&source, /* no_cache= */ true); + (letter, result) + }); + } + join_set + } + + /// Process a single device-source `JoinSet` completion — mirrors + /// [`Self::apply_data_dir_load_result`] exactly, keyed by + /// `DriveLetter` instead of `PathBuf` (a device source's identity + /// for logging purposes; see [`Self::spawn_device_drive_loaders`]). + #[cfg(windows)] + async fn apply_device_drive_load_result( + &self, + join_result: Result< + ( + uffs_mft::platform::DriveLetter, + anyhow::Result<( + uffs_core::compact::DriveCompactIndex, + uffs_core::compact::LoadTiming, + )>, + ), + tokio::task::JoinError, + >, + loaded: &mut usize, + total: usize, + ) { + *loaded = loaded.saturating_add(1); + match join_result { + Ok((_letter, Ok((drive_index, timing)))) => { + self.install_data_dir_drive(drive_index, &timing, *loaded, total) + .await; + } + Ok((letter, Err(load_err))) => { + tracing::error!(drive = %letter, error = %load_err, "Failed to load snapshot device"); + } + Err(join_err) => { + tracing::error!(error = %join_err, "Task panicked loading snapshot device"); + } + } + + release_allocator_pages(); + + let mut progress = self.status.write().await; + *progress = DaemonStatus::Loading { + drives_loaded: *loaded, + drives_total: total, + }; + drop(progress); + } + /// Per-drive load timeout. If a single drive's MFT read takes /// longer than this, we skip it rather than blocking the entire /// daemon. Raw NTFS volume reads can hang indefinitely when a diff --git a/crates/uffs-daemon/src/index/projection.rs b/crates/uffs-daemon/src/index/projection.rs index 83d190510..15ed77442 100644 --- a/crates/uffs-daemon/src/index/projection.rs +++ b/crates/uffs-daemon/src/index/projection.rs @@ -38,6 +38,7 @@ impl IndexManager { malformed: row.malformed, malformed_path: row.malformed_path, name_hex: row.name_hex.clone(), + file_reference: row.file_reference, } } diff --git a/crates/uffs-daemon/src/ipc.rs b/crates/uffs-daemon/src/ipc.rs index 425808cea..3283463ee 100644 --- a/crates/uffs-daemon/src/ipc.rs +++ b/crates/uffs-daemon/src/ipc.rs @@ -358,8 +358,9 @@ impl IpcServer { pub(crate) async fn run_ipc_server( index: Arc, lifecycle: LifecycleHandle, + ephemeral_id: Option<&str>, ) -> anyhow::Result<()> { - let listener = bind_unix_listener()?; + let listener = bind_unix_listener(ephemeral_id)?; let events = index.event_sender().clone(); let handler = Arc::new(RequestHandler { index, @@ -381,8 +382,10 @@ pub(crate) async fn run_ipc_server( /// cleanup, bind, and 0600-permission lockdown so the orchestrator /// can stay focused on the accept loop. #[cfg(unix)] -fn bind_unix_listener() -> anyhow::Result { - let sock_path = IpcServer::socket_path(); +fn bind_unix_listener(ephemeral_id: Option<&str>) -> anyhow::Result { + let sock_path = ephemeral_id.map_or_else(IpcServer::socket_path, |id| { + PathBuf::from(uffs_client::daemon_ctl::ephemeral_endpoint(id)) + }); if let Some(parent) = sock_path.parent() { uffs_security::fs::create_secure_dir(parent)?; @@ -482,9 +485,9 @@ fn spawn_unix_connection( pub(crate) async fn run_pipe_server( index: Arc, lifecycle: LifecycleHandle, + ephemeral_id: Option<&str>, ) -> anyhow::Result<()> { - let pipe_name = uffs_security::pipe::PipeName::for_current_user() - .map_err(|sid_err| anyhow::anyhow!("pipe name resolution failed: {sid_err}"))?; + let pipe_name = resolve_pipe_name(ephemeral_id)?; // DACL: allow the linked-token user only. Kept alive for the entire // lifetime of the listener — every server instance borrows from it. @@ -563,6 +566,24 @@ pub(crate) async fn run_pipe_server( } } +/// Resolve the pipe name [`run_pipe_server`] should bind: the per-user +/// well-known name, or (when `ephemeral_id` is set) the isolated +/// ephemeral-instance name. Extracted so `run_pipe_server` stays under +/// clippy's cognitive-complexity budget. +#[cfg(windows)] +fn resolve_pipe_name(ephemeral_id: Option<&str>) -> anyhow::Result { + ephemeral_id.map_or_else( + || { + uffs_security::pipe::PipeName::for_current_user() + .map_err(|sid_err| anyhow::anyhow!("pipe name resolution failed: {sid_err}")) + }, + |id| { + uffs_security::pipe::PipeName::parse(uffs_client::daemon_ctl::ephemeral_endpoint(id)) + .map_err(|err| anyhow::anyhow!("invalid ephemeral pipe name: {err}")) + }, + ) +} + /// Build a single named-pipe server instance bound to `pipe_name` with /// the owner-only `sd`. Set `first = true` ONLY for the initial /// instance (enables `FIRST_PIPE_INSTANCE` squat protection). diff --git a/crates/uffs-daemon/src/lib.rs b/crates/uffs-daemon/src/lib.rs index 4ca48cca4..2f5151466 100644 --- a/crates/uffs-daemon/src/lib.rs +++ b/crates/uffs-daemon/src/lib.rs @@ -138,6 +138,15 @@ pub struct DaemonConfig { pub data_dir: Option, /// Explicit drive letters (Windows only). pub drives: Vec, + /// VSS-snapshot device sources — `(device_path, drive)` pairs + /// (Windows only). Each is read fresh via + /// [`uffs_core::compact::MftSource::Device`] and, unlike `drives`, + /// never gets a background USN journal loop (the drive letter here + /// names which live volume the snapshot was taken *from*, not a + /// live volume this instance should keep polling — see + /// `spawn_journal_loops_for_warm_shards`'s ephemeral-instance + /// guard in `lib.rs`). + pub device_sources: Vec<(String, uffs_mft::platform::DriveLetter)>, /// Idle timeout in seconds (0 = use default 7200s / 2 hours). pub idle_timeout: u64, /// Disable auto-retire. @@ -151,6 +160,19 @@ pub struct DaemonConfig { /// or `"-"`, the daemon defaults to `./uffs_daemon.log` in the /// current working directory. pub log_file: Option, + /// Run as an ephemeral, job-scoped instance rather than the + /// resident per-user daemon. + /// + /// When set, both the lifecycle directory (PID file, shutdown + /// nonce) and the IPC endpoint (Unix socket / Windows named pipe) + /// are derived from this id via + /// [`uffs_client::daemon_ctl::ephemeral_lifecycle_dir`] / + /// [`uffs_client::daemon_ctl::ephemeral_endpoint`] instead of the + /// well-known per-user paths — so this instance can run alongside + /// a resident daemon without colliding with it. Callers connect + /// via [`uffs_client::connect_sync::UffsClientSync::connect_at`] + /// with the matching `ephemeral_endpoint`. + pub ephemeral_id: Option, } /// Run the UFFS daemon with the given configuration. @@ -204,18 +226,20 @@ pub async fn run_daemon(config: DaemonConfig) -> anyhow::Result<()> { tracing::info!(mft_files = mft_files.len(), drives = ?drives, "Final data sources"); // Refuse to start with zero data sources — an empty daemon is useless. - startup::validate_data_sources(&mft_files, &drives, &lifecycle_mgr)?; + startup::validate_data_sources(&mft_files, &drives, &config.device_sources, &lifecycle_mgr)?; tracing::info!("Data sources validated OK"); let load_task = spawn_load_task( Arc::clone(&idx), mft_files, drives, + config.device_sources.clone(), config.no_cache, lifecycle_mgr.handle(), + config.ephemeral_id.is_some(), ); - let ipc_task = spawn_ipc_servers(&idx, &lifecycle_mgr); + let ipc_task = spawn_ipc_servers(&idx, &lifecycle_mgr, config.ephemeral_id.clone()); let _stats_task = spawn_stats_heartbeat(Arc::clone(&idx), lifecycle_mgr.handle()); let _mem_snapshot_task = telemetry::spawn_mem_snapshot_task( Arc::clone(&idx), @@ -250,8 +274,10 @@ fn spawn_load_task( load_index: Arc, mft_files: Vec, drives: Vec, + device_sources: Vec<(String, uffs_mft::platform::DriveLetter)>, no_cache: bool, load_lifecycle: lifecycle::LifecycleHandle, + is_ephemeral: bool, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { tracing::info!(mft_files = mft_files.len(), drives = ?drives, "Load task starting"); @@ -264,6 +290,18 @@ fn spawn_load_task( // load task is fully covered by `load_from_data_dir` above. #[cfg(windows)] load_live_drives_if_windows(&load_index, &drives, no_cache, &load_lifecycle).await; + #[cfg(windows)] + if !device_sources.is_empty() { + tracing::info!("Loading VSS-snapshot device sources..."); + load_index.load_device_drives(&device_sources).await; + tracing::info!("Device sources loaded"); + } + #[cfg(not(windows))] + debug_assert!( + device_sources.is_empty(), + "MftSource::Device is Windows-only (VSS snapshots don't exist elsewhere); \ + device_sources must be empty on this platform" + ); tracing::info!("Load task completed"); // Latch the load phase as complete: from this point on, a daemon @@ -282,7 +320,18 @@ fn spawn_load_task( // for the daemon's lifetime via the `Arc` // captured by the per-loop sink clones; on shutdown the // applier exits cleanly when the last sink Arc drops. - let _journal_applier = spawn_journal_loops_for_warm_shards(&load_index).await; + // + // Skipped entirely for an ephemeral instance: its loaded drive + // letters (from `--device`) name the volume a VSS snapshot was + // taken *from*, not a live volume to keep polling — applying + // live USN deltas onto that frozen, point-in-time capture would + // silently corrupt the very guarantee VSS exists to provide. + let _journal_applier = if is_ephemeral { + tracing::info!("Ephemeral instance: skipping per-shard journal loops"); + None + } else { + Some(spawn_journal_loops_for_warm_shards(&load_index).await) + }; zero_drive_shutdown_guard(&load_index, &load_lifecycle).await; }) @@ -301,7 +350,7 @@ async fn load_live_drives_if_windows( if drives.is_empty() { return; } - warm_up_broker_handles_unless_elevated(drives); + broker_client::warm_up_broker_handles_unless_elevated(drives); tracing::info!(drives = ?drives, "Loading live drives..."); load_index .load_live_drives(drives, no_cache, load_lifecycle) @@ -309,72 +358,6 @@ async fn load_live_drives_if_windows( tracing::info!("Live drives loaded"); } -/// Run the broker warm-up only when the daemon is **not** already elevated. -/// -/// Gate on the daemon's OWN elevation, not on a broker probe. An elevated -/// daemon opens volumes directly (`CreateFileW`), so a broker request would be -/// a futile per-drive pipe-open + WARN. Crucially, `is_elevated()` is a token -/// query — it does NOT touch the broker pipe, so it has none of the race the -/// removed `broker_available()` probe had (that probe connected to and consumed -/// the broker's single pipe instance, starving the real request; 2026-06-13 VM -/// finding). When NOT elevated, the handle request itself is the authoritative -/// broker-presence test: it succeeds when a broker is serving and fails fast -/// (WARN + direct-open fallback) when not. -/// -/// Extracted from `load_live_drives_if_windows` so that caller stays under the -/// `cognitive_complexity` ceiling. -#[cfg(windows)] -fn warm_up_broker_handles_unless_elevated(drives: &[uffs_mft::platform::DriveLetter]) { - if uffs_mft::is_elevated() { - tracing::debug!( - pid = std::process::id(), - "Daemon is elevated — skipping broker warm-up (direct volume open)" - ); - return; - } - tracing::info!( - pid = std::process::id(), - drive_count = drives.len(), - "Daemon not elevated — attempting broker warm-up" - ); - warm_up_broker_handles(drives); -} - -/// Best-effort broker pre-warm: ask the elevated broker for a volume -/// handle per drive so the subsequent `load_live_drives` skips the -/// per-drive elevation prompt. Failures are debug-traced and -/// ignored — the direct-open path takes over transparently. -#[cfg(windows)] -fn warm_up_broker_handles(drives: &[uffs_mft::platform::DriveLetter]) { - tracing::info!( - daemon_pid = std::process::id(), - drives = ?drives, - "warm_up_broker_handles: requesting volume handles from the Access Broker" - ); - for &drive_letter in drives { - match broker_client::request_volume_handle(drive_letter) { - Ok(handle) => { - // Deposit the broker's (elevated, overlapped) volume handle in - // the uffs-mft registry; the subsequent `VolumeHandle::open` - // for this drive adopts it instead of calling `CreateFileW` - // (which a non-elevated daemon can't do). This is what makes - // the broker path actually load the MFT — previously the - // handle was fetched and dropped, so the reader fell back to a - // direct open and failed with access-denied. - uffs_mft::register_broker_handle(drive_letter, handle); - tracing::info!(drive = %drive_letter, handle, "Registered broker volume handle"); - } - Err(broker_err) => { - tracing::warn!( - drive = %drive_letter, - error = %broker_err, - "Access Broker handle request FAILED — falling back to direct (elevated) open" - ); - } - } - } -} - /// Catch the "every load failed but `Ready` fired anyway" zombie /// state. Triggers an explicit shutdown request when the post-load /// drive count is zero so the lifecycle's `select!` tears the daemon @@ -396,6 +379,44 @@ async fn zero_drive_shutdown_guard( } } +/// Run the primary (`AF_UNIX`) IPC listener to completion, logging any +/// terminal error instead of propagating it — matches the fire-and-log +/// contract `spawn_ipc_servers`'s `tokio::spawn` body used to embed +/// inline before this helper was split out for cfg-branching. +#[cfg(unix)] +async fn run_primary_ipc_server( + index: Arc, + lifecycle: lifecycle::LifecycleHandle, + ephemeral_id: Option, +) { + if let Err(ipc_err) = ipc::run_ipc_server(index, lifecycle, ephemeral_id.as_deref()).await { + tracing::error!(error = %ipc_err, "IPC server error"); + } +} + +/// Windows counterpart of [`run_primary_ipc_server`]. +/// +/// The legacy `AF_UNIX` bridge (`ipc::run_ipc_server` on Windows) has no +/// ephemeral-endpoint support, and none is needed: an ephemeral instance +/// is reached exclusively via the named pipe (`spawn_ipc_servers`'s +/// `_pipe_task`, which IS ephemeral-aware). Skip the bridge entirely for +/// an ephemeral instance rather than bind it to the resident daemon's +/// well-known `AF_UNIX` path, where it would collide. +#[cfg(windows)] +async fn run_primary_ipc_server( + index: Arc, + lifecycle: lifecycle::LifecycleHandle, + ephemeral_id: Option, +) { + if ephemeral_id.is_some() { + tracing::info!("Ephemeral instance: skipping legacy AF_UNIX bridge (named pipe only)"); + return; + } + if let Err(ipc_err) = ipc::run_ipc_server(index, lifecycle).await { + tracing::error!(error = %ipc_err, "IPC server error"); + } +} + /// Spawn the IPC server task(s). /// /// Always spawns the `AF_UNIX` listener (the cross-platform fallback); @@ -407,16 +428,24 @@ async fn zero_drive_shutdown_guard( fn spawn_ipc_servers( idx: &Arc, lifecycle_mgr: &lifecycle::LifecycleManager, + ephemeral_id: Option, ) -> tokio::task::JoinHandle<()> { let ipc_index = Arc::clone(idx); let ipc_lifecycle = lifecycle_mgr.handle(); + // On Windows `ephemeral_id` is needed again below for the named-pipe + // task, so this must clone; on Unix there is no second use, so moving + // it directly avoids a real `redundant_clone` clippy finding. + #[cfg(windows)] + let ipc_ephemeral_id = ephemeral_id.clone(); + #[cfg(not(windows))] + let ipc_ephemeral_id = ephemeral_id; tracing::info!("Starting IPC server..."); - let ipc_task = tokio::spawn(async move { - if let Err(ipc_err) = ipc::run_ipc_server(ipc_index, ipc_lifecycle).await { - tracing::error!(error = %ipc_err, "IPC server error"); - } - }); + let ipc_task = tokio::spawn(run_primary_ipc_server( + ipc_index, + ipc_lifecycle, + ipc_ephemeral_id, + )); tracing::info!("IPC server task spawned"); // Task ownership (Phase 10c): the Windows named-pipe IPC server is @@ -443,9 +472,12 @@ fn spawn_ipc_servers( let _pipe_task = { let pipe_index = Arc::clone(idx); let pipe_lifecycle = lifecycle_mgr.handle(); + let pipe_ephemeral_id = ephemeral_id; tracing::info!("Starting named-pipe IPC server..."); tokio::spawn(async move { - if let Err(pipe_err) = ipc::run_pipe_server(pipe_index, pipe_lifecycle).await { + if let Err(pipe_err) = + ipc::run_pipe_server(pipe_index, pipe_lifecycle, pipe_ephemeral_id.as_deref()).await + { tracing::error!(error = %pipe_err, "Named-pipe IPC server error"); } }) diff --git a/crates/uffs-daemon/src/main.rs b/crates/uffs-daemon/src/main.rs index a1364d11e..d0ea0cd95 100644 --- a/crates/uffs-daemon/src/main.rs +++ b/crates/uffs-daemon/src/main.rs @@ -114,6 +114,47 @@ struct Cli { /// Use `"-"` or omit the path to default to `./uffs_daemon.log`. #[arg(long, value_name = "PATH")] log_file: Option, + + /// Run as an ephemeral, job-scoped instance identified by `ID` + /// rather than the resident per-user daemon. + /// + /// Both the lifecycle directory (PID file) and the IPC endpoint are + /// derived from `ID` — see [`uffs_daemon::DaemonConfig::ephemeral_id`] + /// — so this instance can run alongside a resident daemon without + /// colliding with it. Connect via + /// `UffsClientSync::connect_at(& + /// uffs_client::daemon_ctl::ephemeral_endpoint(ID))`. + #[arg(long, value_name = "ID")] + ephemeral_id: Option, + + /// VSS-snapshot device to load, as `DEVICE_PATH=LETTER` (Windows + /// only, repeatable — e.g. + /// `\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy5=C`). + /// + /// Read fresh (never cached — see + /// [`uffs_daemon::DaemonConfig::device_sources`]), so this instance + /// never affects, and is never affected by, a resident daemon's + /// cache for the named drive letter. + #[arg(long = "device", value_name = "DEVICE_PATH=LETTER", value_parser = parse_device_source)] + device_sources: Vec<(String, uffs_mft::platform::DriveLetter)>, +} + +/// Parse one `--device DEVICE_PATH=LETTER` argument. +/// +/// Splits on the *last* `=` — Windows device paths never contain `=`, +/// but splitting from the end is the more conservative choice if that +/// ever changes. +fn parse_device_source(input: &str) -> Result<(String, uffs_mft::platform::DriveLetter), String> { + let (path, letter) = input + .rsplit_once('=') + .ok_or_else(|| format!("expected DEVICE_PATH=LETTER, got '{input}' (missing '=')"))?; + if path.is_empty() { + return Err(format!("empty device path in '{input}'")); + } + let drive: uffs_mft::platform::DriveLetter = letter + .parse() + .map_err(|err| format!("invalid drive letter '{letter}' in '{input}': {err}"))?; + Ok((path.to_owned(), drive)) } #[tokio::main] @@ -152,6 +193,7 @@ async fn main() -> anyhow::Result<()> { .map(|path| path.to_string_lossy().into_owned()) .collect(); let fwd_no_cache = cli.no_cache; + let is_ephemeral = cli.ephemeral_id.is_some(); let config = uffs_daemon::DaemonConfig { mft_files: cli.mft_files, @@ -162,11 +204,17 @@ async fn main() -> anyhow::Result<()> { no_cache: cli.no_cache, log_level: cli.log_level, log_file: cli.log_file, + ephemeral_id: cli.ephemeral_id, + device_sources: cli.device_sources, }; match uffs_daemon::run_daemon(config).await { Ok(()) => Ok(()), - Err(err) if is_already_running(&err) => { + // "Forward to the already-running daemon" only makes sense for + // the resident, well-known-endpoint daemon — an ephemeral + // instance has no sibling to forward to, and the resident + // daemon (if any) is a different, unrelated instance. + Err(err) if is_already_running(&err) && !is_ephemeral => { // Another daemon is running — forward the load request via IPC. forward_to_running_daemon(&fwd_drives, &fwd_mft_files, fwd_no_cache) } diff --git a/crates/uffs-daemon/src/startup.rs b/crates/uffs-daemon/src/startup.rs index a65a94fbc..201578d5e 100644 --- a/crates/uffs-daemon/src/startup.rs +++ b/crates/uffs-daemon/src/startup.rs @@ -20,9 +20,10 @@ use crate::{DaemonConfig, config, events, lifecycle}; pub(crate) fn validate_data_sources( mft_files: &[PathBuf], drives: &[uffs_mft::platform::DriveLetter], + device_sources: &[(String, uffs_mft::platform::DriveLetter)], lifecycle_mgr: &lifecycle::LifecycleManager, ) -> anyhow::Result<()> { - let has_data = !mft_files.is_empty() || { + let has_data = !mft_files.is_empty() || !device_sources.is_empty() || { #[cfg(windows)] { !drives.is_empty() @@ -140,10 +141,17 @@ pub(crate) fn bootstrap_lifecycle_manager( event_tx: events::EventSender, ) -> anyhow::Result { // Determine data directory: - // - lifecycle_dir: always %LOCALAPPDATA%\uffs — PID/socket/lock files + // - lifecycle_dir: %LOCALAPPDATA%\uffs (or, for an ephemeral instance — see + // `DaemonConfig::ephemeral_id` — `.../uffs/ephemeral/`) — PID/socket/lock + // files // - data_dir: user-supplied --data-dir (for MFT file discovery/hot-load) - let lifecycle_dir = dirs_next::data_local_dir() - .map_or_else(|| PathBuf::from("/tmp/uffs"), |base| base.join("uffs")); + let lifecycle_dir = config.ephemeral_id.as_deref().map_or_else( + || { + dirs_next::data_local_dir() + .map_or_else(|| PathBuf::from("/tmp/uffs"), |base| base.join("uffs")) + }, + uffs_client::daemon_ctl::ephemeral_lifecycle_dir, + ); let idle_timeout = if config.no_retire { None diff --git a/crates/uffs-mcp/src/lib_tests.rs b/crates/uffs-mcp/src/lib_tests.rs index 44e363435..c1aa56db3 100644 --- a/crates/uffs-mcp/src/lib_tests.rs +++ b/crates/uffs-mcp/src/lib_tests.rs @@ -80,6 +80,7 @@ mod text_tests { malformed: false, malformed_path: false, name_hex: None, + file_reference: 0, } } diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index 85a60adca..12e7fe6e1 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -541,7 +541,6 @@ impl VolumeHandle { /// path fails (typically `ERROR_ACCESS_DENIED` when the caller is not /// elevated), or if `FSCTL_GET_NTFS_VOLUME_DATA` cannot read the volume /// descriptor for the opened handle. - #[expect(unsafe_code, reason = "FFI: windows API (CreateFileW)")] pub fn open(volume: super::DriveLetter) -> Result { // Access Broker fast-path: if the (elevated) broker has deposited a // pre-opened, duplicated volume handle for this drive, adopt a @@ -560,13 +559,50 @@ impl VolumeHandle { .encode_utf16() .chain(core::iter::once(0)) .collect(); + Self::open_raw_path(&volume_path, volume) + } - // SAFETY: `volume_path` is UTF-16 and NUL-terminated for the duration of + /// Opens an arbitrary device path for direct MFT reading — e.g. a VSS + /// snapshot device (`\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`), + /// rather than a live drive letter's `\\.\:` path. + /// + /// `volume` is used only as a diagnostic label (mirroring + /// [`crate::MftReader::from_file`]'s existing precedent of associating + /// an arbitrary data source with a caller-supplied [`super::DriveLetter`] + /// for logging/error messages) — it does not need to correspond to how + /// `device_path` is actually opened; pass the drive letter of the + /// *original* live volume the snapshot was taken from. + /// + /// Unlike [`Self::open`], this has no Access Broker fast-path: a VSS + /// snapshot device is never broker-vended (the broker's handle registry + /// is keyed by drive letter, not by an arbitrary device path), so the + /// caller must already be running elevated. + /// + /// # Errors + /// + /// Returns [`MftError::Io`] if `CreateFileW` on `device_path` fails + /// (typically `ERROR_ACCESS_DENIED` when the caller is not elevated), or + /// if `FSCTL_GET_NTFS_VOLUME_DATA` cannot read the volume descriptor for + /// the opened handle. + pub fn open_device_path(device_path: &str, volume: super::DriveLetter) -> Result { + let wide_path: Vec = device_path + .encode_utf16() + .chain(core::iter::once(0)) + .collect(); + Self::open_raw_path(&wide_path, volume) + } + + /// `CreateFileW` + `FSCTL_GET_NTFS_VOLUME_DATA` against an already + /// NUL-terminated UTF-16 `path` — the shared body of [`Self::open`] + /// (after its Access Broker fast-path) and [`Self::open_device_path`]. + #[expect(unsafe_code, reason = "FFI: windows API (CreateFileW)")] + fn open_raw_path(path: &[u16], volume: super::DriveLetter) -> Result { + // SAFETY: `path` is UTF-16 and NUL-terminated for the duration of // the call, optional pointers are passed as `None`, and on success the // returned handle is owned by this function. let create_result = unsafe { CreateFileW( - PCWSTR::from_raw(volume_path.as_ptr()), + PCWSTR::from_raw(path.as_ptr()), FILE_READ_DATA | FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, None, diff --git a/crates/uffs-mft/src/reader.rs b/crates/uffs-mft/src/reader.rs index dd52955f5..06baf8c90 100644 --- a/crates/uffs-mft/src/reader.rs +++ b/crates/uffs-mft/src/reader.rs @@ -212,6 +212,58 @@ impl MftReader { Err(MftError::PlatformNotSupported) } + /// Open an arbitrary device path for MFT reading — e.g. a VSS snapshot + /// device (`\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`), rather + /// than a live drive letter's `\\.\:` path. See + /// [`crate::platform::volume::VolumeHandle::open_device_path`] for the + /// full contract (no Access Broker fast-path; caller must already be + /// elevated) and what `volume` (a diagnostic label only) is for. + /// + /// # Errors + /// + /// Returns an error if the device path cannot be opened or is not NTFS + /// formatted. + /// + /// # Platform + /// + /// This function is only available on Windows. + #[cfg(windows)] + pub fn open_device_path( + device_path: &str, + volume: crate::platform::DriveLetter, + ) -> Result { + let handle = VolumeHandle::open_device_path(device_path, volume)?; + + Ok(Self { + volume, + source: MftSource::LiveVolume(Box::new(handle)), + mode: MftReadMode::Auto, + merge_extensions: true, + use_bitmap: true, + expand_links: true, + add_placeholders: true, + concurrency: None, + io_size: None, + parallel_parse: None, + parse_workers: None, + forensic: false, + }) + } + + /// Open an arbitrary device path for MFT reading (non-Windows stub). + /// + /// # Errors + /// + /// Always returns `MftError::PlatformNotSupported` on non-Windows + /// platforms. + #[cfg(not(windows))] + pub const fn open_device_path( + _device_path: &str, + _volume: crate::platform::DriveLetter, + ) -> Result { + Err(MftError::PlatformNotSupported) + } + /// Create a reader from a pre-captured `.mft` file (cross-platform). /// /// This enables the full search/filter/sort pipeline on any platform diff --git a/scripts/windows/content-reader-validation.rs b/scripts/windows/content-reader-validation.rs new file mode 100644 index 000000000..2b32b6d94 --- /dev/null +++ b/scripts/windows/content-reader-validation.rs @@ -0,0 +1,363 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1.0" +//! colored = "2.0" +//! ``` +// ============================================================================= +// scripts/windows/content-reader-validation — Content Reader Playback Smoke Test +// ============================================================================= +// +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Real, runnable proof that the whole content pipeline (Broker VSS +// lease -> ephemeral target-selection uffsd -> candidate enumeration -> +// privileged uffs-content-reader -> streamed content) actually works at +// runtime on this machine, not just compiles and links: creates a +// uniquely-named sample file, runs the real job pipeline against it, +// and asserts the played-back bytes exactly match what was written. +// +// This is a thin wrapper: it spawns `uffs-content --self-test-vss-playback +// ` and reports its exit status. The round-trip logic itself +// (create snapshot -> spawn ephemeral daemon -> enumerate candidate -> +// spawn Reader -> stream content -> verify -> tear down) lives once, in +// production code, at crates/uffs-content/src/job/self_test.rs +// (`self_test_vss_playback`) — the exact same function this script's +// target and `cargo test -p uffs-content -- --ignored` both exercise, +// so none of the three ever drift apart. Mirrors +// scripts/windows/vss-snapshot-validation.rs's own shape. +// +// Requirements: +// - Windows with NTFS +// - Administrator privileges (VSS snapshot creation, and the Reader's +// OpenFileById device-path open, both need it) +// - uffs-content.exe, uffsd.exe, and uffs-content-reader.exe built and +// sitting in the same directory (production install layout, or +// `cargo build --release` output: all three land in target/release/) +// - uffs-broker --install already run once on this host +// +// Usage: +// rust-script scripts/windows/content-reader-validation.rs +// rust-script scripts/windows/content-reader-validation.rs C:\Temp\uffs-content-test +// rust-script scripts/windows/content-reader-validation.rs --bin path\to\uffs-content.exe +// rust-script scripts/windows/content-reader-validation.rs --timeout-secs 60 + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use colored::Colorize; + +/// How long to wait for `--self-test-vss-playback` before killing it and +/// reporting a timeout, absent a `--timeout-secs` override. The round +/// trip involves a real VSS snapshot create/delete plus spawning two +/// extra child processes (`uffsd`, `uffs-content-reader`), so this is +/// more generous than `vss-snapshot-validation.rs`'s own 30s budget. +const DEFAULT_TIMEOUT_SECS: u64 = 60; + +/// How often the child-process watchdog re-checks `tasklist`. +const WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Every child-process image name the pipeline may spawn, watched by +/// the watchdog so a hang shows *which* stage it's stuck in. +const WATCHED_IMAGES: &[&str] = &["uffsd.exe", "uffs-content-reader.exe"]; + +/// Parsed script arguments. +struct ScriptArgs { + /// Path to the `uffs-content` binary to exercise. + bin: String, + /// Directory the self-test creates its sample file under. + test_dir: String, + /// How long to wait before killing the child and reporting a timeout. + timeout: Duration, +} + +/// Parse CLI args. +/// +/// Usage: `rust-script content-reader-validation [test-dir] [--bin ] +/// [--timeout-secs ]` +fn parse_script_args() -> ScriptArgs { + let args: Vec = std::env::args().collect(); + let mut test_dir: Option = None; + let mut bin_override: Option = None; + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--bin" | "--binary" => { + bin_override = args.get(i + 1).cloned(); + i += 2; + } + "--timeout-secs" => { + if let Some(value) = args.get(i + 1).and_then(|value| value.parse().ok()) { + timeout_secs = value; + } + i += 2; + } + other if !other.starts_with('-') && test_dir.is_none() => { + test_dir = Some(other.to_string()); + i += 1; + } + _ => { + i += 1; + } + } + } + + ScriptArgs { + bin: bin_override.unwrap_or_else(default_binary), + test_dir: test_dir.unwrap_or_else(default_test_dir), + timeout: Duration::from_secs(timeout_secs), + } +} + +/// Locate an existing `uffs-content` binary; do **not** auto-build. +/// +/// Search order: +/// 1. `target\release\uffs-content.exe` — `cargo build --release` output +/// 2. `$USERPROFILE\bin\uffs-content.exe` — `just use` install location +/// 3. Bare `uffs-content.exe` — falls through to PATH lookup +fn default_binary() -> String { + let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string()); + let candidates = [ + PathBuf::from("target") + .join("release") + .join("uffs-content.exe"), + PathBuf::from(&home).join("bin").join("uffs-content.exe"), + ]; + for candidate in &candidates { + if candidate.exists() { + return candidate.to_string_lossy().into_owned(); + } + } + "uffs-content.exe".to_string() +} + +/// Default self-test directory: `%TEMP%\uffs-content-self-test`, or +/// `.\uffs-content-self-test` if `TEMP` isn't set. +fn default_test_dir() -> String { + let temp = std::env::var("TEMP") + .or_else(|_| std::env::var("TMP")) + .unwrap_or_else(|_| ".".to_string()); + PathBuf::from(temp) + .join("uffs-content-self-test") + .to_string_lossy() + .into_owned() +} + +/// The expected sibling binary paths (`uffsd.exe`, `uffs-content-reader.exe`), +/// mirroring `uffs-content`'s own spawn-a-sibling-binary lookup +/// (`job::ephemeral_daemon::find_daemon_exe` / +/// `job::reader_client::find_reader_exe`) — both must sit next to `bin`. +fn sibling_binary_paths(bin: &str) -> Vec { + let dir = PathBuf::from(bin) + .parent() + .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf); + WATCHED_IMAGES + .iter() + .map(|name| dir.join(name)) + .collect() +} + +/// Print ` --version -v` (the long, build-fingerprinted form +/// every UFFS binary supports) before running anything — makes a stale- +/// binary mismatch across the three cooperating processes obvious up +/// front instead of something to reverse-engineer from a hang, matching +/// `vss-snapshot-validation.rs`'s own rationale. +fn print_binary_version(label: &str, path: &std::path::Path) { + match Command::new(path).args(["--version", "-v"]).output() { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + for (i, line) in text.lines().enumerate() { + if i == 0 { + eprintln!(" {label} {}", line.cyan()); + } else { + eprintln!(" {} {line}", " ".repeat(label.len())); + } + } + } + Ok(output) => { + eprintln!( + " {label} {} exited {} — {}", + "?".yellow(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Err(err) => { + eprintln!( + " {label} {} not found at {}: {err}", + "✗".red(), + path.display() + ); + } + } +} + +/// Whether a process with the given image name currently exists, +/// checked via `tasklist`. +fn process_running(image_name: &str) -> bool { + Command::new("tasklist") + .args(["/FI", &format!("IMAGENAME eq {image_name}"), "/NH"]) + .output() + .is_ok_and(|output| { + String::from_utf8_lossy(&output.stdout) + .to_lowercase() + .contains(&image_name.to_lowercase()) + }) +} + +/// Spawn a background thread that logs each watched image's RUNNING / +/// NOT RUNNING transitions to the terminal, until `stop` is set. +/// Returns the thread's `JoinHandle` so the caller can `stop` then +/// `join` it once the round trip finishes. +fn spawn_process_watchdog(stop: &Arc) -> std::thread::JoinHandle<()> { + let stop = Arc::clone(stop); + std::thread::spawn(move || { + let mut last_seen: Vec> = vec![None; WATCHED_IMAGES.len()]; + while !stop.load(Ordering::Relaxed) { + for (index, image_name) in WATCHED_IMAGES.iter().enumerate() { + let running = process_running(image_name); + if last_seen.get(index).copied().flatten() != Some(running) { + if running { + eprintln!(" [watchdog] {} {image_name}", "RUNNING".green()); + } else { + eprintln!(" [watchdog] {} {image_name}", "NOT RUNNING".yellow()); + } + if let Some(slot) = last_seen.get_mut(index) { + *slot = Some(running); + } + } + } + std::thread::sleep(WATCHDOG_POLL_INTERVAL); + } + }) +} + +fn main() { + let script_start = Instant::now(); + let args = parse_script_args(); + + eprintln!(); + eprintln!("╔═══════════════════════════════════════════════════════════════╗"); + eprintln!("║ UFFS Content Reader Playback Smoke Test ║"); + eprintln!("╚═══════════════════════════════════════════════════════════════╝"); + eprintln!(" Binary: {}", args.bin.cyan()); + eprintln!(" Test dir: {}", args.test_dir.cyan()); + eprintln!(); + + if !cfg!(windows) { + eprintln!( + " {} uffs-content's real VSS + Reader pipeline is Windows-only — nothing to test on this platform.", + "⚠".yellow() + ); + std::process::exit(1); + } + + print_binary_version("uffs-content: ", std::path::Path::new(&args.bin)); + for sibling in sibling_binary_paths(&args.bin) { + let label = format!( + "{}:", + sibling.file_stem().map_or_else( + || "sibling".to_string(), + |stem| stem.to_string_lossy().into_owned() + ) + ); + print_binary_version(&format!("{label:<20}"), &sibling); + } + eprintln!(); + + eprintln!( + " Running: {} --self-test-vss-playback {} (timeout: {}s)", + args.bin, + args.test_dir, + args.timeout.as_secs() + ); + eprintln!(" ─────────────────────────────────────────────────────────────────"); + + // `Stdio::inherit()` + `.spawn()` — deliberately NOT `.output()`, and + // `.spawn()` (not `.status()`) so the loop below can poll and kill + // on timeout. Same rationale as `vss-snapshot-validation.rs`. + let mut child = match Command::new(&args.bin) + .arg("--self-test-vss-playback") + .arg(&args.test_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(child) => child, + Err(err) => { + eprintln!(" {} failed to spawn {}: {err}", "✗".red(), args.bin); + eprintln!( + " (build it first: cargo build --release -p uffs-content -p uffs-daemon -p uffs-content-reader)" + ); + std::process::exit(1); + } + }; + + let watchdog_stop = Arc::new(AtomicBool::new(false)); + let watchdog = spawn_process_watchdog(&watchdog_stop); + + let deadline = Instant::now() + args.timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => {} + Err(err) => { + eprintln!(" {} failed to poll child process: {err}", "✗".red()); + std::process::exit(1); + } + } + if Instant::now() >= deadline { + eprintln!( + " {} timed out after {}s — killing uffs-content", + "✗".red(), + args.timeout.as_secs() + ); + if let Err(err) = child.kill() { + eprintln!(" {} failed to kill timed-out process: {err}", "✗".red()); + } + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(200)); + }; + + watchdog_stop.store(true, Ordering::Relaxed); + let _ = watchdog.join(); + + let elapsed_ms = script_start.elapsed().as_millis(); + + eprintln!(" ─────────────────────────────────────────────────────────────────"); + match status { + Some(status) if status.success() => { + eprintln!( + " {} VSS snapshot + Reader playback round trip passed ({elapsed_ms}ms)", + "✓".green() + ); + eprintln!(); + std::process::exit(0); + } + Some(status) => { + eprintln!( + " {} VSS snapshot + Reader playback round trip failed ({elapsed_ms}ms)", + "✗".red() + ); + eprintln!(); + std::process::exit(status.code().unwrap_or(1)); + } + None => { + eprintln!( + " {} VSS snapshot + Reader playback round trip timed out ({elapsed_ms}ms) — the \ + last line printed above (and the watchdog log) show where it was stuck", + "✗".red() + ); + eprintln!(); + std::process::exit(124); + } + } +} From 02b4abcace766f1e96b2e49dd1383ba4a63db8dc Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:45:35 -0700 Subject: [PATCH 33/98] fix(rustdoc): de-link cross-platform doc comments pointing at Windows-only items rustdoc's broken-intra-doc-links lint resolves links against the crate's own compiled item set, so an intra-doc link from a cross-platform doc comment to a #[cfg(windows)]-only item (MftSource::Device, load_mft_index_from_device, super::logical, super::snapshot_client:: SnapshotLease, uffs_security::pipe::OwnerOnlySd) breaks whenever that comment's own item is compiled without the windows cfg. Downgraded each to a plain code span (no link), matching this crate's existing WindowsJournalSource precedent. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-reader/src/reader.rs | 2 +- crates/uffs-content-reader/src/reader/read_plan.rs | 2 +- crates/uffs-content/src/job/candidate_source.rs | 2 +- crates/uffs-core/src/compact_loader.rs | 2 +- crates/uffs-daemon/src/lib.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/uffs-content-reader/src/reader.rs b/crates/uffs-content-reader/src/reader.rs index b17b93707..a4d0171df 100644 --- a/crates/uffs-content-reader/src/reader.rs +++ b/crates/uffs-content-reader/src/reader.rs @@ -14,7 +14,7 @@ //! while it is itself elevated — there is no Broker-mediated handle //! duplication or Authenticode identity check on the connecting client //! for this pipe (unlike the Broker's Snapshot Manager pipe). The named -//! pipe's owner-only DACL ([`uffs_security::pipe::OwnerOnlySd`]) is the +//! pipe's owner-only DACL (`uffs_security::pipe::OwnerOnlySd`) is the //! security boundary: only the current elevated user's linked/primary //! token can open it at all. `FIRST_PIPE_INSTANCE` protects against //! another process squatting the well-known name first. diff --git a/crates/uffs-content-reader/src/reader/read_plan.rs b/crates/uffs-content-reader/src/reader/read_plan.rs index 770078d69..2c460cbee 100644 --- a/crates/uffs-content-reader/src/reader/read_plan.rs +++ b/crates/uffs-content-reader/src/reader/read_plan.rs @@ -17,7 +17,7 @@ //! "the single highest-value unit-test target in the whole Reader" //! per the implementation plan. //! -//! Its only real (non-test) caller, [`super::logical`], is +//! Its only real (non-test) caller, `super::logical`, is //! `#[cfg(windows)]` — so on every other platform this module is //! genuinely unused outside its own tests, permanently (not "deferred //! until wired up" the way other dead-code states in this workspace diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index b69f86650..59cd7e09f 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -24,7 +24,7 @@ pub struct CandidateEntry { /// source uses the OS's native per-volume file identifier, which is /// stable across hard links the same way an NTFS file reference is. pub file_reference: u64, - /// Which VSS snapshot lease (see [`super::snapshot_client::SnapshotLease`]) + /// Which VSS snapshot lease (see `super::snapshot_client::SnapshotLease`) /// this candidate's device path/file reference resolve against — a /// job may lease more than one drive. `0` (never a real lease id, /// which the Broker assigns starting from 1) for diff --git a/crates/uffs-core/src/compact_loader.rs b/crates/uffs-core/src/compact_loader.rs index 93e54463a..74a9032b0 100644 --- a/crates/uffs-core/src/compact_loader.rs +++ b/crates/uffs-core/src/compact_loader.rs @@ -83,7 +83,7 @@ impl MftSource { /// that key would let the resident daemon serve snapshot data as if /// it were live, or (worse) let a snapshot read silently overwrite /// the live drive's cache. `Device` sources are always read fresh - /// and never cached — see [`load_mft_index_from_device`]. + /// and never cached — see `load_mft_index_from_device`. #[must_use] pub const fn is_ephemeral_device(&self) -> bool { match self { diff --git a/crates/uffs-daemon/src/lib.rs b/crates/uffs-daemon/src/lib.rs index 2f5151466..3c23cdd7c 100644 --- a/crates/uffs-daemon/src/lib.rs +++ b/crates/uffs-daemon/src/lib.rs @@ -140,7 +140,7 @@ pub struct DaemonConfig { pub drives: Vec, /// VSS-snapshot device sources — `(device_path, drive)` pairs /// (Windows only). Each is read fresh via - /// [`uffs_core::compact::MftSource::Device`] and, unlike `drives`, + /// `uffs_core::compact::MftSource::Device` and, unlike `drives`, /// never gets a background USN journal loop (the drive letter here /// names which live volume the snapshot was taken *from*, not a /// live volume this instance should keep polling — see From 3387604e498ff525f9a51ceae65b9999b13a2b1d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:18:18 -0700 Subject: [PATCH 34/98] fix(broker): surface Snapshot Manager pipe failures instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosing a real-hardware failure where uffs-content's VSS lease request fails with "No process is on the other end of the pipe" right around when the Broker finishes creating the snapshot (~8-9s), with no visibility into which side or which framing step actually broke: - uffs-broker: handle_one_request's read/write failures were logged at debug, invisible at the service/--run default INFO level — the only place a production deployment (no console) could ever have observed this. Bumped to warn. - uffs-content: snapshot_client's round trip only had context on the pipe open; a write or read failure surfaced as a bare io::Error with no way to tell which framing step (request write, response length, response payload) failed. Added distinguishing .context() at each step. Co-Authored-By: Claude Sonnet 5 --- .../uffs-broker/src/broker/snapshot_manager/mod.rs | 9 +++++++-- crates/uffs-content/src/job/snapshot_client.rs | 13 +++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 1c39f858a..2c179b448 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -167,7 +167,12 @@ fn handle_one_request(pipe: HANDLE, manager: &SnapshotLeaseManager bytes, Err(err) => { - tracing::debug!(error = %err, "snapshot pipe: failed to read request"); + // Kept at `warn!` (not `debug!`): a broken Coordinator request + // read is operationally significant — it's the only signal a + // production deployment (service, no console) gets that a + // client round trip silently died. Was invisible at the + // default `--run`/service INFO level until this fix. + tracing::warn!(error = %err, "snapshot pipe: failed to read request"); return; } }; @@ -179,7 +184,7 @@ fn handle_one_request(pipe: HANDLE, manager: &SnapshotLeaseManager anyhow::Result anyhow::Res /// client-side mirror of the Broker's `read_framed_message`. fn read_framed_message(pipe: &mut std::fs::File) -> anyhow::Result> { let mut length_bytes = [0_u8; 4]; - pipe.read_exact(&mut length_bytes)?; + pipe.read_exact(&mut length_bytes) + .context("reading response length prefix")?; let length = u32::from_le_bytes(length_bytes); if length > MAX_RESPONSE_BYTES { anyhow::bail!("response length {length} exceeds maximum {MAX_RESPONSE_BYTES}"); } let mut payload = vec![0_u8; usize::try_from(length).unwrap_or(0)]; - pipe.read_exact(&mut payload)?; + pipe.read_exact(&mut payload) + .context("reading response payload")?; Ok(payload) } From 039bb82aa5f7fdb8b54b2951fa713a146c286f20 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:14:35 -0700 Subject: [PATCH 35/98] fix(broker): fix the real Snapshot Manager pipe data-loss race + stamp uffs-content's version Root cause of the real-hardware failure ("No process is on the other end of the pipe" while reading the response payload, right after the length prefix already arrived): serve_snapshot_pipe's worker thread called disconnect_pipe(owned.raw()) immediately after write_framed_message returned. WriteFile succeeding only means the bytes reached the pipe's kernel buffer, not that the client has read them yet; DisconnectNamedPipe discards anything still buffered-but-unread. The 4-byte length prefix reliably won that race, the larger response payload didn't. Fixed by calling FlushFileBuffers (blocks until the client has drained everything just written) before returning from write_framed_message. Applied the same fix to broker.rs's own write_pipe (the MFT-handle pipe) for the identical latent race, even though its fixed 9-byte response made it far less likely to lose in practice. Also: uffs-content was the only UFFS binary with no build.rs, so its --version output always showed "unknown" for commit/rustc/target/profile (visible in this same validation run) instead of a real build stamp. Added the same build.rs (uffs_version::emit_build_env + winresource icon/ manifest embed) every other UFFS binary already has. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/uffs-broker/src/broker.rs | 20 +++++-- .../src/broker/snapshot_manager/mod.rs | 24 ++++++++- crates/uffs-content/Cargo.toml | 4 ++ crates/uffs-content/build.rs | 54 +++++++++++++++++++ 5 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 crates/uffs-content/build.rs diff --git a/Cargo.lock b/Cargo.lock index f7f0c282c..657e32db6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4496,6 +4496,7 @@ dependencies = [ "uffs-content-reader-protocol", "uffs-version", "uuid", + "winresource", ] [[package]] diff --git a/crates/uffs-broker/src/broker.rs b/crates/uffs-broker/src/broker.rs index cbe41b673..1ed795bad 100644 --- a/crates/uffs-broker/src/broker.rs +++ b/crates/uffs-broker/src/broker.rs @@ -709,10 +709,20 @@ fn read_pipe(pipe: windows::Win32::Foundation::HANDLE, buf: &mut [u8]) -> anyhow } /// Write bytes to the pipe. -#[cfg(windows)] -#[expect(unsafe_code, reason = "WriteFile is an FFI call")] +/// +/// Flushes via `FlushFileBuffers` after a successful `WriteFile`, before +/// returning — the caller (`serve_pipe_requests`) disconnects the pipe +/// immediately once this returns, and `WriteFile` succeeding only means +/// the bytes reached the pipe's kernel buffer, not that the client has +/// read them. Without the flush, `DisconnectNamedPipe` can discard a +/// buffered-but-unread response out from under the client. Same fix as +/// `snapshot_manager::write_framed_message`, applied here for the same +/// reason even though this pipe's fixed 9-byte response makes the race +/// far narrower in practice. +#[cfg(windows)] +#[expect(unsafe_code, reason = "WriteFile/FlushFileBuffers are FFI calls")] fn write_pipe(pipe: windows::Win32::Foundation::HANDLE, buf: &[u8]) -> anyhow::Result<()> { - use windows::Win32::Storage::FileSystem::WriteFile; + use windows::Win32::Storage::FileSystem::{FlushFileBuffers, WriteFile}; let mut bytes_written = 0_u32; @@ -723,6 +733,10 @@ fn write_pipe(pipe: windows::Win32::Foundation::HANDLE, buf: &[u8]) -> anyhow::R if let Err(win_err) = result { anyhow::bail!("WriteFile failed: {win_err}"); } + // SAFETY: `pipe` is the same valid, still-open pipe HANDLE written to above. + if let Err(win_err) = unsafe { FlushFileBuffers(pipe) } { + anyhow::bail!("FlushFileBuffers failed: {win_err}"); + } Ok(()) } diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 2c179b448..21edc6033 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -464,9 +464,25 @@ fn read_exact(pipe: HANDLE, buf: &mut [u8]) -> anyhow::Result<()> { } /// Write a `u32`-LE-length-prefixed message to the pipe. -#[expect(unsafe_code, reason = "WriteFile is an FFI call")] +/// +/// Calls `FlushFileBuffers` after a successful `WriteFile` and before +/// returning, so the caller's subsequent `disconnect_pipe` (see +/// `serve_snapshot_pipe`) can never race the client's read of this +/// response. `WriteFile` returning success only means the bytes were +/// copied into the pipe's kernel buffer — it does **not** mean the +/// client has actually read them yet. `DisconnectNamedPipe` discards +/// any buffered-but-unread bytes immediately, which is exactly what +/// produced `ERROR_PIPE_NOT_CONNECTED` (233) on the client's *second* +/// `ReadFile` (the response payload, after the 4-byte length prefix +/// already made it through) in the 2026-07-17 real-hardware VSS +/// playback test: the length prefix is 4 bytes and gets read almost +/// instantly, but the larger payload was still in flight when the +/// worker thread's `disconnect_pipe` ran. `FlushFileBuffers` on a pipe +/// server handle blocks until the client has drained everything this +/// call wrote, closing that window. +#[expect(unsafe_code, reason = "WriteFile/FlushFileBuffers are FFI calls")] fn write_framed_message(pipe: HANDLE, payload: &[u8]) -> anyhow::Result<()> { - use windows::Win32::Storage::FileSystem::WriteFile; + use windows::Win32::Storage::FileSystem::{FlushFileBuffers, WriteFile}; let length = u32::try_from(payload.len()).unwrap_or(u32::MAX); let mut framed = Vec::with_capacity(payload.len() + 4); @@ -480,6 +496,10 @@ fn write_framed_message(pipe: HANDLE, payload: &[u8]) -> anyhow::Result<()> { if let Err(win_err) = result { anyhow::bail!("WriteFile failed: {win_err}"); } + // SAFETY: `pipe` is the same valid, still-open pipe HANDLE written to above. + if let Err(win_err) = unsafe { FlushFileBuffers(pipe) } { + anyhow::bail!("FlushFileBuffers failed: {win_err}"); + } Ok(()) } diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 109cceb2b..e7b7c0f69 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -103,6 +103,10 @@ uffs-content-reader-protocol.workspace = true # this crate today. tracing.workspace = true +[build-dependencies] +uffs-version = { workspace = true, features = ["build"] } +winresource.workspace = true + [dev-dependencies] tempfile.workspace = true # Independent oracle digest for the E2E dir-walk parity harness diff --git a/crates/uffs-content/build.rs b/crates/uffs-content/build.rs new file mode 100644 index 000000000..1c7cc106d --- /dev/null +++ b/crates/uffs-content/build.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Build scripts run on the build host, not the shipping binary's target, so the +// workspace `deny(expect_used)` / `deny(unwrap_used)` runtime lints do not +// apply here; panicking on a build-host failure (missing icon / no resource +// compiler) is the idiomatic shape for a build script. +#![allow( + clippy::expect_used, + reason = "build scripts may panic on build-host failure; workspace deny-expect targets runtime code" +)] + +//! Build script for `uffs-content`. +//! +//! Two jobs, mirroring `uffs-daemon`/`uffs-content-reader`/`uffs-broker`: +//! +//! 1. Emits `UFFS_GIT_SHA` + build metadata (commit date, rustc, target, +//! profile) via [`uffs_version::emit_build_env`], so `uffs-content --version +//! --verbose` reports the real commit instead of `"unknown"`. Before this +//! script existed, `uffs-content.exe` was the only UFFS binary whose version +//! banner never carried a real build stamp — exactly the "ran the +//! wrong/stale binary" trap the other binaries' build scripts exist to +//! close. +//! 2. On MSVC-Windows, embeds PE resources (UFFS icon, version info, +//! shared `app.manifest`) into `uffs-content.exe` via +//! [`winresource`](https://crates.io/crates/winresource), so the shipped +//! binary carries proper metadata instead of shipping bare. + +fn main() { + uffs_version::emit_build_env(); + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../assets/brand/icons/uffs.ico"); + println!("cargo:rerun-if-changed=../../assets/brand/app.manifest"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os != "windows" || target_env != "msvc" { + return; + } + + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../assets/brand/icons/uffs.ico") + .set("ProductName", "UltraFastFileSearch") + .set( + "FileDescription", + "UFFS Content Service (VSS-snapshot-scoped file content export)", + ) + .set("CompanyName", "SKY, LLC.") + .set("LegalCopyright", "(c) 2025-2026 SKY, LLC. MPL-2.0.") + .set("OriginalFilename", "uffs-content.exe") + .set_manifest_file("../../assets/brand/app.manifest"); + res.compile() + .expect("winresource: failed to embed uffs-content resources"); +} From 2e70aa5925cbeefa141b780878960f477e8e3f46 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:39:59 -0700 Subject: [PATCH 36/98] feat(content): widen JobRequest's filter surface + add a real-corpus metadata/content verification test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-test suite only ever exercised the trivial case: one uniquely- named synthetic file, a query matching just its name, and a bare candidate-count + content-bytes-match assertion. It never verified per-candidate metadata (size, mtime) against ground truth, never exercised more than one candidate, and couldn't exercise size/extension/ date-filtered queries at all — JobRequest::query only ever mapped to SearchParams::pattern, with every other daemon filter field hardcoded to its default. - JobRequest gains ext/min_size/max_size/newer/older/exclude/attr, mirroring the daemon's own SearchParams fields (the same surface the CLI's --ext/--min-size/etc. flags and api-validation.rs exercise directly against the daemon). VssCandidateSource forwards all of them verbatim instead of leaving them defaulted; DirWalkCandidateSource (the fake, cross-platform backend) still ignores them by design. - self_test_vss_query_metadata(root, ext): runs a real ext-filtered query against an *existing* directory of real files (not a synthetic sample), and cross-checks three independent totals against a ground-truth std::fs walk of the same directory: candidate count, the manifest's own logical_size sum, and the bytes actually streamed over CONTENT_CHUNK frames. Exposed via --self-test-vss-query , an #[ignore] cargo test gated on UFFS_CONTENT_QUERY_TEST_ROOT (machine-specific, so not hardcoded), and scripts/windows/content-query-metadata-validation.rs — the same 3-consumer single-source-of-truth pattern as self_test_vss_playback. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 47 ++- crates/uffs-content/src/job/intake.rs | 51 ++- crates/uffs-content/src/job/self_test.rs | 171 +++++++- crates/uffs-content/src/job/tests.rs | 1 + crates/uffs-content/src/job/vss_job.rs | 3 +- crates/uffs-content/src/main.rs | 62 ++- .../tests/e2e_dir_walk_parity_fake_reader.rs | 1 + .../tests/e2e_real_vss_content_reader.rs | 29 ++ .../content-query-metadata-validation.rs | 370 ++++++++++++++++++ 9 files changed, 713 insertions(+), 22 deletions(-) create mode 100644 scripts/windows/content-query-metadata-validation.rs diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 59cd7e09f..b737f2d2e 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -132,9 +132,27 @@ const fn file_identity(_metadata: &fs::Metadata) -> u64 { /// [`super::ephemeral_daemon`]'s own scoping. #[cfg(windows)] pub struct VssCandidateSource<'a> { - /// UFFS query expression (`JobRequest::query`), forwarded verbatim + /// UFFS name/path pattern (`JobRequest::query`), forwarded verbatim /// to the daemon as `SearchParams::pattern`. - query: String, + pattern: String, + // Remaining filter fields, forwarded verbatim to the matching + // `SearchParams` field — see `JobRequest`'s doc comment for why + // this is a deliberately curated subset, not the full daemon + // filter surface. + /// Mirrors `SearchParams::ext`. + ext: Option, + /// Mirrors `SearchParams::min_size`. + min_size: Option, + /// Mirrors `SearchParams::max_size`. + max_size: Option, + /// Mirrors `SearchParams::newer`. + newer: Option, + /// Mirrors `SearchParams::older`. + older: Option, + /// Mirrors `SearchParams::exclude`. + exclude: Option, + /// Mirrors `SearchParams::attr`. + attr: Option, /// The already-spawned, already-`Ready` ephemeral daemon covering /// every drive this job leased. daemon: &'a super::ephemeral_daemon::EphemeralDaemon, @@ -147,15 +165,23 @@ pub struct VssCandidateSource<'a> { #[cfg(windows)] impl<'a> VssCandidateSource<'a> { - /// Wrap an already-spawned, already-`Ready` ephemeral daemon. + /// Wrap an already-spawned, already-`Ready` ephemeral daemon, + /// copying every filter field off `request`. #[must_use] - pub(crate) const fn new( - query: String, + pub(crate) fn new( + request: &super::intake::JobRequest, daemon: &'a super::ephemeral_daemon::EphemeralDaemon, drive_to_lease: std::collections::HashMap, ) -> Self { Self { - query, + pattern: request.query.clone(), + ext: request.ext.clone(), + min_size: request.min_size, + max_size: request.max_size, + newer: request.newer.clone(), + older: request.older.clone(), + exclude: request.exclude.clone(), + attr: request.attr.clone(), daemon, drive_to_lease, } @@ -176,10 +202,17 @@ impl CandidateSource for VssCandidateSource<'_> { // needing this crate to walk anything itself. let root_glob = format!("{}*", root.display()); let params = uffs_client::protocol::SearchParams { - pattern: self.query.clone(), + pattern: self.pattern.clone(), filter_mode: Some(uffs_client::protocol::SearchFilterMode::Files), path_contains: Some(root_glob), limit: None, + ext: self.ext.clone(), + min_size: self.min_size, + max_size: self.max_size, + newer: self.newer.clone(), + older: self.older.clone(), + exclude: self.exclude.clone(), + attr: self.attr.clone(), ..Default::default() }; let response = client diff --git a/crates/uffs-content/src/job/intake.rs b/crates/uffs-content/src/job/intake.rs index 076dd6745..5c40b3cf3 100644 --- a/crates/uffs-content/src/job/intake.rs +++ b/crates/uffs-content/src/job/intake.rs @@ -11,14 +11,22 @@ use std::path::PathBuf; /// Docenta-facing frame protocol, which uses the explicit binary codec /// (addendum §5.4). /// -/// `query` carries the UFFS query expression (e.g. `"*.txt"`, or `"*"` -/// to match everything), matching the daemon's own query grammar so the -/// real, VSS+MFT-query-backed `CandidateSource` can forward it verbatim -/// to an ephemeral `uffsd` instance rather than re-implementing query -/// parsing in this crate. [`super::candidate_source::DirWalkCandidateSource`] -/// (the fake backend) ignores this field entirely — it always matches -/// every regular file under `root`, equivalent to `query: "*"`. -#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +/// `query` carries the UFFS name/path pattern (glob, regex with a `>` +/// prefix, or substring — e.g. `"*.txt"`, or `"*"` to match everything). +/// The remaining fields mirror a narrow, deliberately curated subset of +/// the daemon's own `SearchParams` filter surface (`uffs-client`'s +/// `search` method — the same one the CLI's `--ext`/`--min-size`/etc. +/// flags and `scripts/windows/api-validation.rs` exercise) so a job can +/// express the size/extension/date-bounded queries a real content-ingest +/// consumer (e.g. Docenta) actually needs, without this crate +/// re-implementing query parsing. All are forwarded verbatim to an +/// ephemeral `uffsd` instance by the real, +/// VSS+MFT-query-backed `super::candidate_source::VssCandidateSource`. +/// [`super::candidate_source::DirWalkCandidateSource`] (the fake, +/// cross-platform backend) ignores every filter field — it always +/// matches every regular file under `root`, equivalent to `query: "*"` +/// with no other filters set. +#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize)] pub struct JobRequest { /// Identifier for the source this job's candidates came from. /// `ManifestHeader::source_id` is derived deterministically from this @@ -26,7 +34,32 @@ pub struct JobRequest { pub source_id: String, /// Root directory to enumerate candidates under. pub root: PathBuf, - /// UFFS query expression to evaluate against the snapshot's MFT + /// UFFS name/path pattern to evaluate against the snapshot's MFT /// (e.g. `"*.txt"`); `"*"` matches every regular file. pub query: String, + /// Comma-separated extension filter (e.g. `"txt"` or `"rs,toml,md"`). + /// Mirrors `SearchParams::ext`. + #[serde(default)] + pub ext: Option, + /// Minimum file size in bytes. Mirrors `SearchParams::min_size`. + #[serde(default)] + pub min_size: Option, + /// Maximum file size in bytes. Mirrors `SearchParams::max_size`. + #[serde(default)] + pub max_size: Option, + /// Modified-time lower bound (e.g. `"7d"`, `"24h"`, `"2026-01-15"`). + /// Mirrors `SearchParams::newer`. + #[serde(default)] + pub newer: Option, + /// Modified-time upper bound. Mirrors `SearchParams::older`. + #[serde(default)] + pub older: Option, + /// Exclude glob pattern (e.g. `"backup*"`). Mirrors + /// `SearchParams::exclude`. + #[serde(default)] + pub exclude: Option, + /// Attribute filter spec (e.g. `"hidden,compressed,!system"`). + /// Mirrors `SearchParams::attr`. + #[serde(default)] + pub attr: Option, } diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 9d473e5d7..c86b30e55 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -18,8 +18,9 @@ use std::path::Path; use anyhow::{Context as _, Result}; use uffs_content_protocol::codec::Reader as WireReader; use uffs_content_protocol::frame::{ContentChunk, FileEnd, FrameEnvelope, FrameType}; -use uffs_content_protocol::manifest::ManifestHeader; +use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader}; +use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; use super::intake::JobRequest; use super::vss_job::run_vss_job; @@ -54,6 +55,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { source_id: "uffs-content-self-test".to_owned(), root: test_dir.to_path_buf(), query: unique_name, + ..Default::default() }; let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; @@ -85,6 +87,173 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { Ok(()) } +/// Run a real, extension-filtered query against an existing directory and +/// verify the pipeline's reported metadata/content totals against ground +/// truth. +/// +/// Runs against a real drive with real files already on it — not a +/// synthetic sample. Unlike [`self_test_vss_playback`] (one synthetic +/// file, content-only), +/// this validates the pipeline against however many real files of +/// `extension` already exist under `root`: every candidate must succeed, +/// the candidate count must match the ground-truth walk's file count, the +/// manifest's own `logical_size` fields must sum to the ground-truth +/// total, and the bytes actually streamed over `CONTENT_CHUNK` frames +/// must also sum to that same total. Ground truth comes from +/// [`DirWalkCandidateSource`] — the same cross-platform `std::fs` walker +/// used elsewhere in this crate — filtered to `extension`, reading the +/// **live** volume rather than the job's VSS snapshot; on a quiescent +/// drive the two are expected to match exactly. +/// +/// # Errors +/// Returns an error if the ground-truth walk finds no matching files, +/// `run_vss_job` fails, any candidate doesn't succeed, or any of the +/// three totals (candidate count, manifest metadata bytes, streamed +/// content bytes) disagrees with ground truth. +pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> { + let (ground_truth_count, ground_truth_bytes) = ground_truth_extension_totals(root, extension) + .with_context(|| { + format!("ground-truth filesystem walk of {} failed", root.display()) + })?; + anyhow::ensure!( + ground_truth_count > 0, + "no *.{extension} files found under {} — nothing to validate", + root.display() + ); + + let run_dir = std::env::temp_dir().join(format!( + "uffs-content-query-metadata-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&run_dir) + .with_context(|| format!("failed to create run dir {}", run_dir.display()))?; + + let request = JobRequest { + source_id: "uffs-content-self-test-query".to_owned(), + root: root.to_path_buf(), + query: "*".to_owned(), + ext: Some(extension.to_owned()), + ..Default::default() + }; + + let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; + + anyhow::ensure!( + outcome.run_summary.candidate_count == ground_truth_count, + "candidate count mismatch: pipeline found {}, ground-truth disk walk found {}", + outcome.run_summary.candidate_count, + ground_truth_count + ); + anyhow::ensure!( + outcome.run_summary.succeeded_count == outcome.run_summary.candidate_count, + "not every candidate succeeded: {} of {} (failed-retryable={}, failed-terminal={}, \ + deferred={})", + outcome.run_summary.succeeded_count, + outcome.run_summary.candidate_count, + outcome.run_summary.failed_retryable_count, + outcome.run_summary.failed_terminal_count, + outcome.run_summary.deferred_manual_count + ); + + let summary = summarize_query_outcome(&outcome.manifest_bytes, &outcome.frames) + .context("failed to decode the job's own manifest/frame output")?; + anyhow::ensure!( + summary.metadata_total_bytes == ground_truth_bytes, + "manifest metadata size total mismatch: pipeline reported {} bytes, ground-truth {} bytes", + summary.metadata_total_bytes, + ground_truth_bytes + ); + anyhow::ensure!( + summary.content_total_bytes == ground_truth_bytes, + "streamed content byte total mismatch: pipeline streamed {} bytes, ground-truth {} bytes", + summary.content_total_bytes, + ground_truth_bytes + ); + + Ok(()) +} + +/// Independent ground truth for [`self_test_vss_query_metadata`]: walk +/// `root` live via `std::fs` (bypassing VSS/the daemon entirely) and sum +/// the size of every regular file whose extension case-insensitively +/// matches `extension`. +/// +/// Returns `(matching_file_count, total_logical_bytes)`. +fn ground_truth_extension_totals(root: &Path, extension: &str) -> Result<(u64, u64)> { + let entries = DirWalkCandidateSource + .enumerate(root) + .with_context(|| format!("failed to walk {}", root.display()))?; + let mut count: u64 = 0; + let mut total_bytes: u64 = 0; + for entry in &entries { + let matches = entry + .relative_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case(extension)); + if matches { + count += 1; + total_bytes += entry.logical_size; + } + } + Ok((count, total_bytes)) +} + +/// Aggregate totals decoded from a job's own manifest + frame output, for +/// [`self_test_vss_query_metadata`]. +struct QueryOutcomeSummary { + /// Sum of every `CandidateRecord::logical_size` in the manifest. + metadata_total_bytes: u64, + /// Sum of every `CONTENT_CHUNK.payload.len()` actually streamed. + content_total_bytes: u64, +} + +/// Decode a manifest describing `header.candidate_count` candidates plus +/// their frame stream, returning both the manifest's own metadata-size +/// total and the total bytes actually streamed over `CONTENT_CHUNK` +/// frames — the two independent numbers [`self_test_vss_query_metadata`] +/// cross-checks against ground truth. +/// +/// Deliberately duplicated from [`decode_single_file_content`] rather than +/// generalizing that one: this decoder sums across an arbitrary number of +/// candidates and never buffers content bytes, while that one is scoped +/// to exactly one candidate and returns its buffered content — different +/// enough shapes that a shared abstraction would obscure both. +fn summarize_query_outcome( + manifest_bytes: &[u8], + frames: &[Vec], +) -> Result { + let mut manifest_reader = WireReader::new(manifest_bytes); + let header = ManifestHeader::decode(&mut manifest_reader) + .map_err(|err| anyhow::anyhow!("decode manifest header: {err}"))?; + + let mut metadata_total_bytes: u64 = 0; + for _ in 0..header.candidate_count { + let record = CandidateRecord::decode(&mut manifest_reader) + .map_err(|err| anyhow::anyhow!("decode candidate record: {err}"))?; + metadata_total_bytes += record.logical_size; + } + + let mut content_total_bytes: u64 = 0; + for frame_bytes in frames { + let mut frame_reader = WireReader::new(frame_bytes); + let (envelope, payload) = FrameEnvelope::decode(&mut frame_reader, u64::MAX) + .map_err(|err| anyhow::anyhow!("decode frame envelope: {err}"))?; + if envelope.frame_type != FrameType::ContentChunk { + continue; + } + let mut payload_reader = WireReader::new(&payload); + let chunk = ContentChunk::decode(&mut payload_reader, u32::MAX) + .map_err(|err| anyhow::anyhow!("decode CONTENT_CHUNK: {err}"))?; + content_total_bytes += chunk.payload.len() as u64; + } + + Ok(QueryOutcomeSummary { + metadata_total_bytes, + content_total_bytes, + }) +} + /// Decode a manifest + frame stream that is known to describe exactly /// one candidate, returning the bytes its `CONTENT_CHUNK` frames /// carried. diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 2a956b15a..9cd876918 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -139,6 +139,7 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { source_id: "test-source".to_owned(), root: source_dir.path().to_path_buf(), query: "*".to_owned(), + ..Default::default() }; let outcome = run_job( diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index b4f4630ec..3401d51b6 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -49,8 +49,7 @@ pub fn run_vss_job(request: &JobRequest, run_dir: &Path) -> Result { .iter() .map(|lease| (lease.drive_letter, lease.lease_id)) .collect(); - let candidate_source = - VssCandidateSource::new(request.query.clone(), &resources.daemon, drive_to_lease); + let candidate_source = VssCandidateSource::new(request, &resources.daemon, drive_to_lease); let devices_for_reader: Vec<(String, u64)> = resources .leases diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index b95a30535..a555a0be3 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -19,9 +19,13 @@ //! # Usage //! //! ```bash -//! uffs-content --version # Print version (also -V) -//! uffs-content --self-test-vss-playback # Elevated smoke test: real VSS -//! # snapshot + real Reader playback +//! uffs-content --version # Print version (also -V) +//! uffs-content --self-test-vss-playback # Elevated smoke test: real VSS +//! # snapshot + real Reader playback +//! uffs-content --self-test-vss-query # Elevated smoke test: real +//! # extension-filtered query against +//! # an existing directory, verified +//! # against a ground-truth disk walk //! ``` // Reserved for the wire types the bin will emit once job intake is wired @@ -74,6 +78,9 @@ fn main() { if let Some(test_dir) = self_test_vss_playback_dir(&args) { std::process::exit(run_self_test_vss_playback(&test_dir)); } + if let Some((root, extension)) = self_test_vss_query_args(&args) { + std::process::exit(run_self_test_vss_query(&root, &extension)); + } if uffs_content::is_implemented() { eprintln!("uffs-content: ready."); @@ -132,3 +139,52 @@ fn run_self_test_vss_playback(test_dir: &std::path::Path) -> i32 { const fn run_self_test_vss_playback(_test_dir: &std::path::Path) -> i32 { 1 } + +/// Return the `(root, extension)` arguments following +/// `--self-test-vss-query`, if present. +#[cfg(windows)] +fn self_test_vss_query_args(args: &[String]) -> Option<(std::path::PathBuf, String)> { + let flag_index = args.iter().position(|arg| arg == "--self-test-vss-query")?; + let root = args.get(flag_index + 1).map(std::path::PathBuf::from)?; + let extension = args.get(flag_index + 2).cloned()?; + Some((root, extension)) +} + +/// Non-Windows stub: `--self-test-vss-query` needs a real VSS snapshot, +/// which doesn't exist on this platform. +#[cfg(not(windows))] +const fn self_test_vss_query_args(_args: &[String]) -> Option<(std::path::PathBuf, String)> { + None +} + +/// Run [`uffs_content::job::self_test::self_test_vss_query_metadata`] and +/// print a PASS/FAIL result. Returns the process exit code (`0` pass, `1` +/// fail). +#[cfg(windows)] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn run_self_test_vss_query(root: &std::path::Path, extension: &str) -> i32 { + match uffs_content::job::self_test::self_test_vss_query_metadata(root, extension) { + Ok(()) => { + eprintln!( + "PASS: query metadata/content totals matched ground truth ({}, *.{extension})", + root.display() + ); + 0 + } + Err(err) => { + eprintln!("FAIL: {err:#}"); + 1 + } + } +} + +/// Non-Windows stub, matching [`self_test_vss_query_args`] always +/// returning `None` there (so this is unreachable in practice, but kept +/// for a symmetrical `#[cfg]` shape). +#[cfg(not(windows))] +const fn run_self_test_vss_query(_root: &std::path::Path, _extension: &str) -> i32 { + 1 +} diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index 276e039fe..8ab7e201c 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -84,6 +84,7 @@ mod tests { source_id: "fixture-source".to_owned(), root: source_dir.path().to_path_buf(), query: "*".to_owned(), + ..Default::default() }; let outcome = run_job( &request, diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 1b2469a7a..9b9d49a5f 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -80,4 +80,33 @@ mod windows_tests { uffs_content::job::self_test::self_test_vss_playback(test_dir.path()) .expect("real VSS snapshot + Reader playback round trip must succeed"); } + + /// An extension-filtered query against a real, pre-existing directory + /// (an arbitrary number of real files, not a synthetic sample) must + /// report metadata and streamed-content totals that exactly match an + /// independent ground-truth filesystem walk. + /// + /// The root directory is necessarily machine-specific (a real drive + /// with real files already on it), so it can't be hardcoded here — + /// set `UFFS_CONTENT_QUERY_TEST_ROOT` (e.g. `G:\`) and, optionally, + /// `UFFS_CONTENT_QUERY_TEST_EXT` (default `txt`). + /// + /// # Requirements + /// See this file's module doc comment, plus `UFFS_CONTENT_QUERY_TEST_ROOT` + /// above. + #[test] + #[ignore = "requires Windows, elevation, an installed uffs-broker, \ + uffsd/uffs-content-reader built alongside the test binary, and \ + UFFS_CONTENT_QUERY_TEST_ROOT set to a real directory"] + fn real_vss_query_metadata_matches_ground_truth_disk_walk() { + let root = std::env::var("UFFS_CONTENT_QUERY_TEST_ROOT") + .expect("set UFFS_CONTENT_QUERY_TEST_ROOT to a real directory, e.g. G:\\"); + let extension = + std::env::var("UFFS_CONTENT_QUERY_TEST_EXT").unwrap_or_else(|_| "txt".to_owned()); + uffs_content::job::self_test::self_test_vss_query_metadata( + std::path::Path::new(&root), + &extension, + ) + .expect("real VSS query metadata/content totals must match ground truth"); + } } diff --git a/scripts/windows/content-query-metadata-validation.rs b/scripts/windows/content-query-metadata-validation.rs new file mode 100644 index 000000000..4efd21962 --- /dev/null +++ b/scripts/windows/content-query-metadata-validation.rs @@ -0,0 +1,370 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! anyhow = "1.0" +//! colored = "2.0" +//! ``` +// ============================================================================= +// scripts/windows/content-query-metadata-validation — Query Metadata Smoke Test +// ============================================================================= +// +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. +// +// Real, runnable proof that a complex (extension-filtered) query against +// the real VSS + ephemeral-daemon pipeline reports correct metadata and +// streams correct content for an *arbitrary, pre-existing* directory of +// real files — not just the single-synthetic-file case +// `content-reader-validation.rs` covers. Runs `uffs-content +// --self-test-vss-query `, which leases a real VSS snapshot +// of `root`'s drive, spawns the real ephemeral target-selection daemon, +// evaluates a real `ext:` query against it, streams every matching +// candidate's content through the real privileged `uffs-content-reader`, +// and asserts three independent totals against a ground-truth `std::fs` +// walk of the same directory: candidate count, the manifest's own +// `logical_size` sum, and the bytes actually streamed over CONTENT_CHUNK +// frames. +// +// This is a thin wrapper: the round-trip + verification logic lives once, +// in production code, at +// crates/uffs-content/src/job/self_test.rs (`self_test_vss_query_metadata`) +// — the exact same function this script's target and +// `cargo test -p uffs-content -- --ignored` both exercise, so none of the +// three ever drift apart. Mirrors content-reader-validation.rs's own shape. +// +// Requirements: +// - Windows with NTFS +// - Administrator privileges (VSS snapshot creation, and the Reader's +// OpenFileById device-path open, both need it) +// - uffs-content.exe, uffsd.exe, and uffs-content-reader.exe built and +// sitting in the same directory (production install layout, or +// `cargo build --release` output: all three land in target/release/) +// - uffs-broker --install already run once on this host +// - `root` must already exist and contain at least one file with the +// given extension +// +// Usage: +// rust-script scripts/windows/content-query-metadata-validation.rs G:\ txt +// rust-script scripts/windows/content-query-metadata-validation.rs D:\logs log --bin path\to\uffs-content.exe +// rust-script scripts/windows/content-query-metadata-validation.rs G:\ txt --timeout-secs 120 + +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use colored::Colorize; + +/// How long to wait for `--self-test-vss-query` before killing it and +/// reporting a timeout. Streaming an arbitrary, possibly large, real +/// corpus of files (not one synthetic file) can take much longer than +/// `content-reader-validation.rs`'s 60s budget. +const DEFAULT_TIMEOUT_SECS: u64 = 180; + +/// How often the child-process watchdog re-checks `tasklist`. +const WATCHDOG_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Every child-process image name the pipeline may spawn, watched by +/// the watchdog so a hang shows *which* stage it's stuck in. +const WATCHED_IMAGES: &[&str] = &["uffsd.exe", "uffs-content-reader.exe"]; + +/// Parsed script arguments. +struct ScriptArgs { + /// Path to the `uffs-content` binary to exercise. + bin: String, + /// Existing directory to query. + root: String, + /// Extension to filter on (no leading dot, e.g. `"txt"`). + extension: String, + /// How long to wait before killing the child and reporting a timeout. + timeout: Duration, +} + +/// Parse CLI args. +/// +/// Usage: `rust-script content-query-metadata-validation +/// [--bin ] [--timeout-secs ]` +fn parse_script_args() -> ScriptArgs { + let args: Vec = std::env::args().collect(); + let mut positional: Vec = Vec::new(); + let mut bin_override: Option = None; + let mut timeout_secs = DEFAULT_TIMEOUT_SECS; + + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--bin" | "--binary" => { + bin_override = args.get(i + 1).cloned(); + i += 2; + } + "--timeout-secs" => { + if let Some(value) = args.get(i + 1).and_then(|value| value.parse().ok()) { + timeout_secs = value; + } + i += 2; + } + other if !other.starts_with('-') => { + positional.push(other.to_string()); + i += 1; + } + _ => { + i += 1; + } + } + } + + if positional.len() < 2 { + eprintln!( + "{} usage: content-query-metadata-validation [--bin ] \ + [--timeout-secs ]", + "✗".red() + ); + eprintln!(" example: rust-script scripts/windows/content-query-metadata-validation.rs G:\\ txt"); + std::process::exit(1); + } + + ScriptArgs { + bin: bin_override.unwrap_or_else(default_binary), + root: positional[0].clone(), + extension: positional[1].clone(), + timeout: Duration::from_secs(timeout_secs), + } +} + +/// Locate an existing `uffs-content` binary; do **not** auto-build. +/// +/// Search order: +/// 1. `target\release\uffs-content.exe` — `cargo build --release` output +/// 2. `$USERPROFILE\bin\uffs-content.exe` — `just use` install location +/// 3. Bare `uffs-content.exe` — falls through to PATH lookup +fn default_binary() -> String { + let home = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string()); + let candidates = [ + PathBuf::from("target") + .join("release") + .join("uffs-content.exe"), + PathBuf::from(&home).join("bin").join("uffs-content.exe"), + ]; + for candidate in &candidates { + if candidate.exists() { + return candidate.to_string_lossy().into_owned(); + } + } + "uffs-content.exe".to_string() +} + +/// The expected sibling binary paths (`uffsd.exe`, `uffs-content-reader.exe`), +/// mirroring `uffs-content`'s own spawn-a-sibling-binary lookup +/// (`job::ephemeral_daemon::find_daemon_exe` / +/// `job::reader_client::find_reader_exe`) — both must sit next to `bin`. +fn sibling_binary_paths(bin: &str) -> Vec { + let dir = PathBuf::from(bin) + .parent() + .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf); + WATCHED_IMAGES + .iter() + .map(|name| dir.join(name)) + .collect() +} + +/// Print ` --version -v` (the long, build-fingerprinted form +/// every UFFS binary supports) before running anything — makes a stale- +/// binary mismatch across the three cooperating processes obvious up +/// front instead of something to reverse-engineer from a hang. +fn print_binary_version(label: &str, path: &std::path::Path) { + match Command::new(path).args(["--version", "-v"]).output() { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + for (i, line) in text.lines().enumerate() { + if i == 0 { + eprintln!(" {label} {}", line.cyan()); + } else { + eprintln!(" {} {line}", " ".repeat(label.len())); + } + } + } + Ok(output) => { + eprintln!( + " {label} {} exited {} — {}", + "?".yellow(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Err(err) => { + eprintln!( + " {label} {} not found at {}: {err}", + "✗".red(), + path.display() + ); + } + } +} + +/// Whether a process with the given image name currently exists, +/// checked via `tasklist`. +fn process_running(image_name: &str) -> bool { + Command::new("tasklist") + .args(["/FI", &format!("IMAGENAME eq {image_name}"), "/NH"]) + .output() + .is_ok_and(|output| { + String::from_utf8_lossy(&output.stdout) + .to_lowercase() + .contains(&image_name.to_lowercase()) + }) +} + +/// Spawn a background thread that logs each watched image's RUNNING / +/// NOT RUNNING transitions to the terminal, until `stop` is set. +/// Returns the thread's `JoinHandle` so the caller can `stop` then +/// `join` it once the round trip finishes. +fn spawn_process_watchdog(stop: &Arc) -> std::thread::JoinHandle<()> { + let stop = Arc::clone(stop); + std::thread::spawn(move || { + let mut last_seen: Vec> = vec![None; WATCHED_IMAGES.len()]; + while !stop.load(Ordering::Relaxed) { + for (index, image_name) in WATCHED_IMAGES.iter().enumerate() { + let running = process_running(image_name); + if last_seen.get(index).copied().flatten() != Some(running) { + if running { + eprintln!(" [watchdog] {} {image_name}", "RUNNING".green()); + } else { + eprintln!(" [watchdog] {} {image_name}", "NOT RUNNING".yellow()); + } + if let Some(slot) = last_seen.get_mut(index) { + *slot = Some(running); + } + } + } + std::thread::sleep(WATCHDOG_POLL_INTERVAL); + } + }) +} + +fn main() { + let script_start = Instant::now(); + let args = parse_script_args(); + + eprintln!(); + eprintln!("╔═══════════════════════════════════════════════════════════════╗"); + eprintln!("║ UFFS Content Query Metadata Smoke Test ║"); + eprintln!("╚═══════════════════════════════════════════════════════════════╝"); + eprintln!(" Binary: {}", args.bin.cyan()); + eprintln!(" Root: {}", args.root.cyan()); + eprintln!(" Extension: {}", args.extension.cyan()); + eprintln!(); + + if !cfg!(windows) { + eprintln!( + " {} uffs-content's real VSS + Reader pipeline is Windows-only — nothing to test on this platform.", + "⚠".yellow() + ); + std::process::exit(1); + } + + print_binary_version("uffs-content: ", std::path::Path::new(&args.bin)); + for sibling in sibling_binary_paths(&args.bin) { + let label = format!( + "{}:", + sibling.file_stem().map_or_else( + || "sibling".to_string(), + |stem| stem.to_string_lossy().into_owned() + ) + ); + print_binary_version(&format!("{label:<20}"), &sibling); + } + eprintln!(); + + eprintln!( + " Running: {} --self-test-vss-query {} {} (timeout: {}s)", + args.bin, + args.root, + args.extension, + args.timeout.as_secs() + ); + eprintln!(" ─────────────────────────────────────────────────────────────────"); + + // `Stdio::inherit()` + `.spawn()` — deliberately NOT `.output()`, and + // `.spawn()` (not `.status()`) so the loop below can poll and kill + // on timeout. Same rationale as `content-reader-validation.rs`. + let mut child = match Command::new(&args.bin) + .arg("--self-test-vss-query") + .arg(&args.root) + .arg(&args.extension) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(child) => child, + Err(err) => { + eprintln!(" {} failed to spawn {}: {err}", "✗".red(), args.bin); + eprintln!( + " (build it first: cargo build --release -p uffs-content -p uffs-daemon -p uffs-content-reader)" + ); + std::process::exit(1); + } + }; + + let watchdog_stop = Arc::new(AtomicBool::new(false)); + let watchdog = spawn_process_watchdog(&watchdog_stop); + + let deadline = Instant::now() + args.timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => {} + Err(err) => { + eprintln!(" {} failed to poll child process: {err}", "✗".red()); + std::process::exit(1); + } + } + if Instant::now() >= deadline { + eprintln!( + " {} timed out after {}s — killing uffs-content", + "✗".red(), + args.timeout.as_secs() + ); + if let Err(err) = child.kill() { + eprintln!(" {} failed to kill timed-out process: {err}", "✗".red()); + } + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(200)); + }; + + watchdog_stop.store(true, Ordering::Relaxed); + let _ = watchdog.join(); + + let elapsed_ms = script_start.elapsed().as_millis(); + + eprintln!(" ─────────────────────────────────────────────────────────────────"); + match status { + Some(status) if status.success() => { + eprintln!( + " {} query metadata/content totals matched ground truth ({elapsed_ms}ms)", + "✓".green() + ); + eprintln!(); + std::process::exit(0); + } + Some(status) => { + eprintln!( + " {} query metadata/content verification failed ({elapsed_ms}ms)", + "✗".red() + ); + eprintln!(); + std::process::exit(status.code().unwrap_or(1)); + } + None => { + eprintln!( + " {} query metadata/content verification timed out ({elapsed_ms}ms) — the \ + last line printed above (and the watchdog log) show where it was stuck", + "✗".red() + ); + eprintln!(); + std::process::exit(124); + } + } +} From 261616a4187e272e3e0aea3c518b8030caea4cd5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:48:39 -0700 Subject: [PATCH 37/98] fix(content): tolerate ACL-locked directories in the query-metadata ground-truth walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit self_test_vss_query_metadata's ground truth used DirWalkCandidateSource, which correctly bails hard on any std::fs::read_dir error (right behavior for the synthetic-fixture parity harness it's shared with, where an access error is itself a bug). A real, pre-existing drive routinely has OS-reserved, ACL-locked directories (System Volume Information, $RECYCLE.BIN) that plain std::fs can't enter but that the real MFT-based query engine reads regardless (it never goes through filesystem permission checks) — exactly what broke the first real run against a live drive ("Access is denied", os error 5). Replaced with a dedicated, permissive walker (walk_tolerating_denied) that skips (and reports, via a tracing::warn with the skip count/paths) any directory it can't list instead of failing the whole walk. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/self_test.rs | 94 ++++++++++++++++++------ 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index c86b30e55..c18f1d4e9 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -20,7 +20,6 @@ use uffs_content_protocol::codec::Reader as WireReader; use uffs_content_protocol::frame::{ContentChunk, FileEnd, FrameEnvelope, FrameType}; use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader}; -use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; use super::intake::JobRequest; use super::vss_job::run_vss_job; @@ -100,8 +99,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { /// manifest's own `logical_size` fields must sum to the ground-truth /// total, and the bytes actually streamed over `CONTENT_CHUNK` frames /// must also sum to that same total. Ground truth comes from -/// [`DirWalkCandidateSource`] — the same cross-platform `std::fs` walker -/// used elsewhere in this crate — filtered to `extension`, reading the +/// [`walk_tolerating_denied`] — a permissive `std::fs` walker reading the /// **live** volume rather than the job's VSS snapshot; on a quiescent /// drive the two are expected to match exactly. /// @@ -111,10 +109,19 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { /// three totals (candidate count, manifest metadata bytes, streamed /// content bytes) disagrees with ground truth. pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> { - let (ground_truth_count, ground_truth_bytes) = ground_truth_extension_totals(root, extension) - .with_context(|| { - format!("ground-truth filesystem walk of {} failed", root.display()) - })?; + let (ground_truth_count, ground_truth_bytes, skipped_dirs) = + ground_truth_extension_totals(root, extension); + if !skipped_dirs.is_empty() { + tracing::warn!( + skipped_count = skipped_dirs.len(), + skipped = ?skipped_dirs, + "ground-truth walk skipped {} inaccessible director{} (e.g. OS-reserved \ + folders) — the real MFT-based query engine reads these regardless, so a \ + mismatch caused by this is a ground-truth walker limitation, not a pipeline bug", + skipped_dirs.len(), + if skipped_dirs.len() == 1 { "y" } else { "ies" } + ); + } anyhow::ensure!( ground_truth_count > 0, "no *.{extension} files found under {} — nothing to validate", @@ -178,25 +185,68 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> /// the size of every regular file whose extension case-insensitively /// matches `extension`. /// -/// Returns `(matching_file_count, total_logical_bytes)`. -fn ground_truth_extension_totals(root: &Path, extension: &str) -> Result<(u64, u64)> { - let entries = DirWalkCandidateSource - .enumerate(root) - .with_context(|| format!("failed to walk {}", root.display()))?; +/// Deliberately **not** [`DirWalkCandidateSource`] (used elsewhere in this +/// crate for synthetic test fixtures, where an access-denied error is +/// itself a bug worth failing loud on): a real, pre-existing drive +/// routinely has OS-reserved, ACL-locked directories (`System Volume +/// Information`, `$RECYCLE.BIN`) that plain `std::fs::read_dir` can't +/// enter but that the real MFT-based query engine reads regardless (it +/// never goes through filesystem permission checks). This walker treats +/// a directory it can't enter as "skip, not fail" and reports how many +/// were skipped, so a real discrepancy is still visible rather than +/// silently swallowed. +/// +/// Returns `(matching_file_count, total_logical_bytes, skipped_dirs)`. +fn ground_truth_extension_totals( + root: &Path, + extension: &str, +) -> (u64, u64, Vec) { let mut count: u64 = 0; let mut total_bytes: u64 = 0; - for entry in &entries { - let matches = entry - .relative_path - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| ext.eq_ignore_ascii_case(extension)); - if matches { - count += 1; - total_bytes += entry.logical_size; + let mut skipped_dirs = Vec::new(); + walk_tolerating_denied( + root, + extension, + &mut count, + &mut total_bytes, + &mut skipped_dirs, + ); + (count, total_bytes, skipped_dirs) +} + +/// Recursive worker for [`ground_truth_extension_totals`]. A directory +/// that can't be listed (permission denied, or any other `read_dir` +/// error) is appended to `skipped_dirs` and skipped, rather than +/// propagated — see that function's doc comment for why. +fn walk_tolerating_denied( + dir: &Path, + extension: &str, + count: &mut u64, + total_bytes: &mut u64, + skipped_dirs: &mut Vec, +) { + let Ok(read_dir) = std::fs::read_dir(dir) else { + skipped_dirs.push(dir.to_path_buf()); + return; + }; + for entry in read_dir.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_dir() { + walk_tolerating_denied(&path, extension, count, total_bytes, skipped_dirs); + } else if metadata.is_file() { + let matches = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case(extension)); + if matches { + *count += 1; + *total_bytes += metadata.len(); + } } } - Ok((count, total_bytes)) } /// Aggregate totals decoded from a job's own manifest + frame output, for From 34779a33cb76a36d820844894057c74abf184a17 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:09:59 -0700 Subject: [PATCH 38/98] feat(content): show exactly which paths differ on a query-metadata count mismatch The real-hardware run against C:\ with an ext filter found 8 candidates where the ground-truth disk walk found 4 -- a genuine, reproducible discrepancy. Rather than guess at the cause (candidates: hard-link expansion, MFT extension-record merge duplicating instead of merging, compact-cache staleness in the daemon this test doesn't go through), the mismatch error now decodes every candidate path from the manifest and diffs their occurrence counts against the ground-truth path list, so the next run shows exactly which path(s) disagree and by how much instead of just a bare count. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/self_test.rs | 80 ++++++++++++++++++++---- crates/uffs-content/src/lib.rs | 2 + 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index c18f1d4e9..7b4670a05 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -109,7 +109,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { /// three totals (candidate count, manifest metadata bytes, streamed /// content bytes) disagrees with ground truth. pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> { - let (ground_truth_count, ground_truth_bytes, skipped_dirs) = + let (ground_truth_count, ground_truth_bytes, skipped_dirs, ground_truth_paths) = ground_truth_extension_totals(root, extension); if !skipped_dirs.is_empty() { tracing::warn!( @@ -145,12 +145,17 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; - anyhow::ensure!( - outcome.run_summary.candidate_count == ground_truth_count, - "candidate count mismatch: pipeline found {}, ground-truth disk walk found {}", - outcome.run_summary.candidate_count, - ground_truth_count - ); + if outcome.run_summary.candidate_count != ground_truth_count { + let pipeline_paths = decode_candidate_paths(&outcome.manifest_bytes) + .context("failed to decode candidate paths for mismatch diagnostics")?; + anyhow::bail!( + "candidate count mismatch: pipeline found {}, ground-truth disk walk found {}\n\ + (path, pipeline_count, ground_truth_count) for every differing path:\n{:#?}", + outcome.run_summary.candidate_count, + ground_truth_count, + count_mismatches(&pipeline_paths, &ground_truth_paths), + ); + } anyhow::ensure!( outcome.run_summary.succeeded_count == outcome.run_summary.candidate_count, "not every candidate succeeded: {} of {} (failed-retryable={}, failed-terminal={}, \ @@ -196,22 +201,25 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> /// were skipped, so a real discrepancy is still visible rather than /// silently swallowed. /// -/// Returns `(matching_file_count, total_logical_bytes, skipped_dirs)`. +/// Returns `(matching_file_count, total_logical_bytes, skipped_dirs, +/// matching_paths)`. fn ground_truth_extension_totals( root: &Path, extension: &str, -) -> (u64, u64, Vec) { +) -> (u64, u64, Vec, Vec) { let mut count: u64 = 0; let mut total_bytes: u64 = 0; let mut skipped_dirs = Vec::new(); + let mut matching_paths = Vec::new(); walk_tolerating_denied( root, extension, &mut count, &mut total_bytes, &mut skipped_dirs, + &mut matching_paths, ); - (count, total_bytes, skipped_dirs) + (count, total_bytes, skipped_dirs, matching_paths) } /// Recursive worker for [`ground_truth_extension_totals`]. A directory @@ -224,6 +232,7 @@ fn walk_tolerating_denied( count: &mut u64, total_bytes: &mut u64, skipped_dirs: &mut Vec, + matching_paths: &mut Vec, ) { let Ok(read_dir) = std::fs::read_dir(dir) else { skipped_dirs.push(dir.to_path_buf()); @@ -235,7 +244,14 @@ fn walk_tolerating_denied( continue; }; if metadata.is_dir() { - walk_tolerating_denied(&path, extension, count, total_bytes, skipped_dirs); + walk_tolerating_denied( + &path, + extension, + count, + total_bytes, + skipped_dirs, + matching_paths, + ); } else if metadata.is_file() { let matches = path .extension() @@ -244,11 +260,53 @@ fn walk_tolerating_denied( if matches { *count += 1; *total_bytes += metadata.len(); + matching_paths.push(path); } } } } +/// Decode every `CandidateRecord::path` out of a manifest, for the +/// candidate-count-mismatch diagnostic in +/// [`self_test_vss_query_metadata`]. +fn decode_candidate_paths(manifest_bytes: &[u8]) -> Result> { + let mut manifest_reader = WireReader::new(manifest_bytes); + let header = ManifestHeader::decode(&mut manifest_reader) + .map_err(|err| anyhow::anyhow!("decode manifest header: {err}"))?; + let mut paths = Vec::with_capacity(usize::try_from(header.candidate_count).unwrap_or(0)); + for _ in 0..header.candidate_count { + let record = CandidateRecord::decode(&mut manifest_reader) + .map_err(|err| anyhow::anyhow!("decode candidate record: {err}"))?; + paths.push(std::path::PathBuf::from(record.path.display_lossy())); + } + Ok(paths) +} + +/// For every path whose occurrence count differs between `left` and +/// `right`, `(path, left_count, right_count)` — for the candidate-count- +/// mismatch diagnostic in [`self_test_vss_query_metadata`]. Counts a path +/// appearing twice in one side but once in the other (a literal duplicate +/// row), not just paths missing entirely from one side, since that's +/// exactly the shape a merge/dedup bug would produce. +fn count_mismatches( + left: &[std::path::PathBuf], + right: &[std::path::PathBuf], +) -> Vec<(std::path::PathBuf, usize, usize)> { + let mut counts: alloc::collections::BTreeMap<&Path, (usize, usize)> = + alloc::collections::BTreeMap::new(); + for path in left { + counts.entry(path.as_path()).or_default().0 += 1; + } + for path in right { + counts.entry(path.as_path()).or_default().1 += 1; + } + counts + .into_iter() + .filter(|(_, (left_count, right_count))| left_count != right_count) + .map(|(path, (left_count, right_count))| (path.to_path_buf(), left_count, right_count)) + .collect() +} + /// Aggregate totals decoded from a job's own manifest + frame output, for /// [`self_test_vss_query_metadata`]. struct QueryOutcomeSummary { diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index c381bae4e..76a0a65ac 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -36,6 +36,8 @@ //! and privileged-Reader-backed ones (UFI.1/UFI.2). [`is_implemented`] //! tracks the latter, not this crate's own workflow logic. +extern crate alloc; + pub mod job; pub mod run; From ab370410fdadea282af5db962bf6f08ae95ca219 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 04:35:27 -0700 Subject: [PATCH 39/98] chore(content): dump the exact daemon query/response + fix a path-format bug in the mismatch diagnostic TEMP DEBUG: VssCandidateSource::enumerate now unconditionally prints the full SearchParams JSON sent to the ephemeral daemon and the full resolved SearchRow list it returns, to see directly whether the daemon itself is returning duplicate rows for the C:\ *.ort candidate-count mismatch (8 pipeline vs 4 ground-truth), or whether the duplication happens downstream of the daemon's response. To be removed once root-caused -- not meant to ship. Also: the count-mismatch diagnostic's own path comparison had a bug -- CandidateRecord::path is root-relative by design, but the ground-truth walker's paths are absolute, so every path looked "different" even where it wasn't. decode_candidate_paths now re-joins onto root before comparing, so the diff only shows genuine occurrence-count disagreements. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 38 +++++++++++++++++++ crates/uffs-content/src/job/self_test.rs | 16 +++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index b737f2d2e..1f2dfeb8c 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -190,6 +190,12 @@ impl<'a> VssCandidateSource<'a> { #[cfg(windows)] impl CandidateSource for VssCandidateSource<'_> { + #[expect( + clippy::print_stderr, + reason = "TEMP DEBUG (2026-07-17): loud, unconditional dump of the query sent to \ + and result received from the ephemeral daemon while diagnosing the real- \ + hardware candidate-count mismatch. Remove once root-caused." + )] fn enumerate(&self, root: &Path) -> io::Result> { let mut client = self .daemon @@ -215,11 +221,43 @@ impl CandidateSource for VssCandidateSource<'_> { attr: self.attr.clone(), ..Default::default() }; + // TEMP DEBUG (2026-07-17): dumping the exact query sent to, and + // result received from, the ephemeral target-selection daemon + // while diagnosing the real-hardware candidate-count mismatch + // (pipeline found 8 rows for 4 real files with `--ext ort` on + // C:\, ground-truth disk walk found 4). Remove once root-caused. + eprintln!( + "=== DEBUG VssCandidateSource: search query sent to daemon ===\n{}", + serde_json::to_string_pretty(¶ms) + .unwrap_or_else(|err| format!("")) + ); + let response = client .search(¶ms) .map_err(|err| io::Error::other(err.to_string()))?; + // TEMP DEBUG (2026-07-17): see the comment above. + eprintln!( + "=== DEBUG VssCandidateSource: search response metadata ===\n\ + total_count={} records_scanned={} duration_ms={} truncated={}", + response.total_count, + response.records_scanned, + response.duration_ms, + response.truncated + ); + let rows = resolve_rows(response.payload)?; + + // TEMP DEBUG (2026-07-17): see the comment above — this is the + // fully resolved row list (post shmem-read if applicable), i.e. + // exactly what becomes this job's CandidateEntry list. + eprintln!( + "=== DEBUG VssCandidateSource: resolved rows ({}) ===\n{}", + rows.len(), + serde_json::to_string_pretty(&rows) + .unwrap_or_else(|err| format!("")) + ); + let mut entries = Vec::with_capacity(rows.len()); for row in rows { let letter = row.drive.as_char(); diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 7b4670a05..0072d31cb 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -146,7 +146,7 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; if outcome.run_summary.candidate_count != ground_truth_count { - let pipeline_paths = decode_candidate_paths(&outcome.manifest_bytes) + let pipeline_paths = decode_candidate_paths(&outcome.manifest_bytes, root) .context("failed to decode candidate paths for mismatch diagnostics")?; anyhow::bail!( "candidate count mismatch: pipeline found {}, ground-truth disk walk found {}\n\ @@ -266,10 +266,16 @@ fn walk_tolerating_denied( } } -/// Decode every `CandidateRecord::path` out of a manifest, for the -/// candidate-count-mismatch diagnostic in +/// Decode every `CandidateRecord::path` out of a manifest, re-joined onto +/// `root` for the candidate-count-mismatch diagnostic in /// [`self_test_vss_query_metadata`]. -fn decode_candidate_paths(manifest_bytes: &[u8]) -> Result> { +/// +/// `CandidateRecord::path` is root-relative by design (see +/// `CandidateEntry::relative_path`'s doc comment), while the ground-truth +/// walker's paths are absolute — rejoining here puts both sides in the +/// same representation so the diff isn't swamped by a spurious +/// "every path differs" noise from the root prefix alone. +fn decode_candidate_paths(manifest_bytes: &[u8], root: &Path) -> Result> { let mut manifest_reader = WireReader::new(manifest_bytes); let header = ManifestHeader::decode(&mut manifest_reader) .map_err(|err| anyhow::anyhow!("decode manifest header: {err}"))?; @@ -277,7 +283,7 @@ fn decode_candidate_paths(manifest_bytes: &[u8]) -> Result Date: Fri, 17 Jul 2026 05:28:22 -0700 Subject: [PATCH 40/98] fix(content): dedup search rows by (file_reference, path) before building candidates Real-hardware investigation traced the C:\ *.ort candidate-count mismatch (8 pipeline rows for 4 real files) down to on-disk MFT records: none of the 4 affected files has an NTFS extension record, and 3 of the 4 have only a single $FILE_NAME attribute -- ruling out the extension-record/ attribute-list merge theory entirely. The duplicate rows are byte-for- byte identical (same file_reference, same path, same everything), and total_count=8 is an index-level match count a filter dispatch can't fabricate from 4 real candidates, so the duplication is baked into the loaded index itself, most likely a chunk-scheduling double-read in the sliding-window I/O reader. Root cause not yet pinned down further. Regardless of where it originates, VssCandidateSource now defensively dedups the daemon's resolved rows on this crate's own side, keyed on (file_reference, path) -- not file_reference alone, since a genuine hard link legitimately shares file_reference across different paths and must stay expanded into separate candidates. Only a row identical in both identity and path (what the spurious duplication produces) collapses. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 1f2dfeb8c..93cffe9da 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -250,7 +250,7 @@ impl CandidateSource for VssCandidateSource<'_> { // TEMP DEBUG (2026-07-17): see the comment above — this is the // fully resolved row list (post shmem-read if applicable), i.e. - // exactly what becomes this job's CandidateEntry list. + // exactly what becomes this job's CandidateEntry list (pre-dedup). eprintln!( "=== DEBUG VssCandidateSource: resolved rows ({}) ===\n{}", rows.len(), @@ -258,8 +258,16 @@ impl CandidateSource for VssCandidateSource<'_> { .unwrap_or_else(|err| format!("")) ); - let mut entries = Vec::with_capacity(rows.len()); - for row in rows { + let deduped = dedup_rows_by_file_reference_and_path(rows); + + // TEMP DEBUG (2026-07-17): see the comment above. + eprintln!( + "=== DEBUG VssCandidateSource: after dedup ({} rows) ===", + deduped.len() + ); + + let mut entries = Vec::with_capacity(deduped.len()); + for row in deduped { let letter = row.drive.as_char(); let Some(&lease_id) = self.drive_to_lease.get(&letter) else { return Err(io::Error::other(format!( @@ -305,3 +313,32 @@ fn resolve_rows( )), } } + +/// Collapse exact-duplicate rows the daemon's search occasionally returns +/// for the same physical file. Seen on real hardware: a fresh, uncached +/// VSS-device MFT parse returned two byte-for-byte identical rows (same +/// `file_reference`, same path, same everything) for files that have no +/// NTFS extension record and (for 3 of the 4 observed) only a single +/// on-disk `$FILE_NAME` attribute — ruling out extension-record/attribute- +/// list double-counting as the cause. Root cause not yet pinned down +/// (suspected: a chunk-scheduling double-read in the sliding-window I/O +/// reader); this dedup is a defensive guard on this crate's own +/// consumption of the daemon's response, independent of wherever the +/// duplication actually originates. +/// +/// Keyed on `(file_reference, path)`, not `file_reference` alone: a +/// genuine hard link shares the same `file_reference` across multiple +/// *different* paths, and those must stay as separate candidates (that's +/// the whole point of `expand_links`). Only a row that is identical in +/// both file identity *and* path — which is what a spurious double-count +/// produces — gets collapsed. Preserves input order (first occurrence of +/// each key wins). +#[cfg(windows)] +fn dedup_rows_by_file_reference_and_path( + rows: Vec, +) -> Vec { + let mut seen: std::collections::HashSet<(u64, String)> = std::collections::HashSet::new(); + rows.into_iter() + .filter(|row| seen.insert((row.file_reference, row.path.clone()))) + .collect() +} From 1fc71df8657e204de768b612025131c616dfa9f3 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:48:16 -0700 Subject: [PATCH 41/98] feat(vss-requestor): retry snapshot creation with backoff on transient VSS errors Real-hardware run hit VSS_E_PROVIDER_VETO (0x80042306) on DoSnapshotSet after several back-to-back snapshot create/release cycles on the same volume in a short window -- a real, reproducible transient failure, not a code bug. Microsoft's own VSS requestor guidance lists this HRESULT (along with VSS_E_SNAPSHOT_SET_IN_PROGRESS, VSS_E_HOLD_WRITES_TIMEOUT, VSS_E_FLUSH_WRITES_TIMEOUT, VSS_E_WRITERERROR_RETRYABLE) as expected to be retried by restarting the entire snapshot-creation sequence from a fresh IVssBackupComponents session -- never resuming mid-sequence. VssSnapshotSession::create already does exactly that (a brand-new COM session every call), so create_snapshot_with_retry just wraps it in a bounded loop (3 attempts, 2s/4s exponential backoff) that only retries the documented-transient HRESULT set; anything else (unsupported volume, bad arguments, access denied, ...) still fails immediately. No changes to the native VSS shim or the FFI boundary -- each retry is simply another call to the existing single-attempt primitive. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-vss-requestor/src/run.rs | 81 +++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/uffs-vss-requestor/src/run.rs b/crates/uffs-vss-requestor/src/run.rs index 896f37b8f..17e0520c0 100644 --- a/crates/uffs-vss-requestor/src/run.rs +++ b/crates/uffs-vss-requestor/src/run.rs @@ -15,7 +15,7 @@ use windows::Win32::System::Threading::{ use crate::pipe; use crate::protocol::{self, BrokerCommand, HelperEvent}; -use crate::snapshot::VssSnapshotSession; +use crate::snapshot::{SnapshotDescriptor, VssRequestError, VssSnapshotSession}; /// Parsed command-line arguments. struct Args { @@ -70,6 +70,83 @@ fn next_value(args: &mut impl Iterator, flag: &str) -> anyhow::Re .ok_or_else(|| anyhow::anyhow!("{flag} requires a value")) } +/// `HRESULT`s the VSS documentation defines as transient: a fresh +/// attempt (a brand-new `IVssBackupComponents` session, which is exactly +/// what every [`VssSnapshotSession::create`] call already does) is +/// expected to succeed once the underlying contention clears. Every +/// other failure (unsupported volume, bad arguments, access denied, …) +/// is left to fail immediately — retrying those would just waste the +/// backoff budget on something that will never succeed. +/// +/// - `VSS_E_PROVIDER_VETO` (`0x80042306`) — the provider couldn't currently +/// service the request; per Microsoft's VSS requestor guidance this is one of +/// the errors a requestor is expected to retry. Observed on real hardware +/// from back-to-back snapshot create/release cycles on the same volume. +/// - `VSS_E_SNAPSHOT_SET_IN_PROGRESS` (`0x80042316`) — another shadow copy +/// operation is still in flight on this volume. +/// - `VSS_E_HOLD_WRITES_TIMEOUT` (`0x80042317`) / `VSS_E_FLUSH_WRITES_TIMEOUT` +/// (`0x80042318`) — the freeze/flush phase didn't complete in time. +/// - `VSS_E_WRITERERROR_RETRYABLE` (`0x800423F3`) — a writer reported a +/// retryable error. This requestor runs `VSS_CTX_FILE_SHARE_BACKUP` with no +/// writer coordination (see `native/vss_shim.cpp`'s header comment), so +/// writers should never actually be in play, but the code is retryable by +/// definition if it ever is returned. +const RETRYABLE_HRESULTS: [i32; 5] = [ + 0x8004_2306_u32.cast_signed(), // VSS_E_PROVIDER_VETO + 0x8004_2316_u32.cast_signed(), // VSS_E_SNAPSHOT_SET_IN_PROGRESS + 0x8004_2317_u32.cast_signed(), // VSS_E_HOLD_WRITES_TIMEOUT + 0x8004_2318_u32.cast_signed(), // VSS_E_FLUSH_WRITES_TIMEOUT + 0x8004_23F3_u32.cast_signed(), // VSS_E_WRITERERROR_RETRYABLE +]; + +/// Total attempts [`create_snapshot_with_retry`] makes before giving up +/// (the first attempt plus this many retries). +const MAX_SNAPSHOT_ATTEMPTS: u32 = 3; + +/// Backoff before retry attempt `N` (1-based): attempt 2 waits +/// [`RETRY_BACKOFF_BASE`], attempt 3 waits `RETRY_BACKOFF_BASE * 2`, and +/// so on — a short exponential backoff bounded by [`MAX_SNAPSHOT_ATTEMPTS`] +/// so a genuinely stuck volume fails in single-digit-second multiples of +/// this, not indefinitely. +const RETRY_BACKOFF_BASE: core::time::Duration = core::time::Duration::from_secs(2); + +/// Create a `VSS_CTX_FILE_SHARE_BACKUP` snapshot of `volume_path`, +/// retrying with backoff on the small set of `HRESULT`s VSS documents as +/// transient ([`RETRYABLE_HRESULTS`]). Each attempt is a fully fresh +/// [`VssSnapshotSession::create`] call — a brand-new `IVssBackupComponents` +/// session — which is exactly what Microsoft's own guidance requires: +/// retrying a transient VSS failure means restarting the whole sequence, +/// never resuming mid-sequence. +/// +/// # Errors +/// Returns the last attempt's [`VssRequestError`] if every attempt +/// failed, or immediately on the first attempt that fails with a +/// non-retryable `HRESULT`. +fn create_snapshot_with_retry( + volume_path: &str, +) -> Result<(VssSnapshotSession, SnapshotDescriptor), VssRequestError> { + let mut backoff = RETRY_BACKOFF_BASE; + let mut attempt = 1_u32; + loop { + match VssSnapshotSession::create(volume_path) { + Ok(created) => return Ok(created), + Err(err) + if attempt < MAX_SNAPSHOT_ATTEMPTS && RETRYABLE_HRESULTS.contains(&err.hresult) => + { + debug_log(&format!( + "snapshot creation attempt {attempt}/{MAX_SNAPSHOT_ATTEMPTS} failed with \ + retryable hresult={:#x} (stage={}); retrying in {backoff:?}", + err.hresult, err.stage + )); + std::thread::sleep(backoff); + backoff *= 2; + attempt += 1; + } + Err(err) => return Err(err), + } + } +} + /// An event the main loop reacts to — a decoded command from the /// Broker, the pipe closing, or the parent process dying (a second, /// independent safety net alongside the Job Object the Broker assigns @@ -147,7 +224,7 @@ pub(crate) fn run() -> anyhow::Result<()> { .try_clone() .map_err(|err| anyhow::anyhow!("failed to clone pipe handle for reading: {err}"))?; - let session = match VssSnapshotSession::create(&args.volume_path) { + let session = match create_snapshot_with_retry(&args.volume_path) { Ok((session, descriptor)) => { debug_log("snapshot created; writing Ready event"); protocol::write_event(&mut writer, &HelperEvent::Ready { From f32fca137e02f264163cfe4b712db705ad695aca Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:56:04 -0700 Subject: [PATCH 42/98] chore(content): remove the temp debug dumps from VssCandidateSource::enumerate The query/response JSON dumps served their purpose (found the duplicate- row candidate-count mismatch, now fixed by dedup_rows_by_file_reference_and_path and confirmed passing on real hardware). Removing the loud, unconditional eprintln!s and their clippy::print_stderr expect now that the dedup guard is validated. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 93cffe9da..ece387073 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -190,12 +190,6 @@ impl<'a> VssCandidateSource<'a> { #[cfg(windows)] impl CandidateSource for VssCandidateSource<'_> { - #[expect( - clippy::print_stderr, - reason = "TEMP DEBUG (2026-07-17): loud, unconditional dump of the query sent to \ - and result received from the ephemeral daemon while diagnosing the real- \ - hardware candidate-count mismatch. Remove once root-caused." - )] fn enumerate(&self, root: &Path) -> io::Result> { let mut client = self .daemon @@ -221,51 +215,13 @@ impl CandidateSource for VssCandidateSource<'_> { attr: self.attr.clone(), ..Default::default() }; - // TEMP DEBUG (2026-07-17): dumping the exact query sent to, and - // result received from, the ephemeral target-selection daemon - // while diagnosing the real-hardware candidate-count mismatch - // (pipeline found 8 rows for 4 real files with `--ext ort` on - // C:\, ground-truth disk walk found 4). Remove once root-caused. - eprintln!( - "=== DEBUG VssCandidateSource: search query sent to daemon ===\n{}", - serde_json::to_string_pretty(¶ms) - .unwrap_or_else(|err| format!("")) - ); - let response = client .search(¶ms) .map_err(|err| io::Error::other(err.to_string()))?; - // TEMP DEBUG (2026-07-17): see the comment above. - eprintln!( - "=== DEBUG VssCandidateSource: search response metadata ===\n\ - total_count={} records_scanned={} duration_ms={} truncated={}", - response.total_count, - response.records_scanned, - response.duration_ms, - response.truncated - ); - let rows = resolve_rows(response.payload)?; - - // TEMP DEBUG (2026-07-17): see the comment above — this is the - // fully resolved row list (post shmem-read if applicable), i.e. - // exactly what becomes this job's CandidateEntry list (pre-dedup). - eprintln!( - "=== DEBUG VssCandidateSource: resolved rows ({}) ===\n{}", - rows.len(), - serde_json::to_string_pretty(&rows) - .unwrap_or_else(|err| format!("")) - ); - let deduped = dedup_rows_by_file_reference_and_path(rows); - // TEMP DEBUG (2026-07-17): see the comment above. - eprintln!( - "=== DEBUG VssCandidateSource: after dedup ({} rows) ===", - deduped.len() - ); - let mut entries = Vec::with_capacity(deduped.len()); for row in deduped { let letter = row.drive.as_char(); From 6157aad276215c4b7c1f37198f3ec31321cbb494 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:32:22 -0700 Subject: [PATCH 43/98] feat(content): add in-memory job registry for connection-blip resume First piece of the two-pipe transport work: JobRegistry tracks each active job's candidate ids and which of them the consumer has FILE_ACK'd, purely in memory. Lets a JOB_RESUME reconnect skip already-acked candidates and continue from there instead of re-streaming a whole job after a transport blip (consumer process restart, pipe hiccup) -- without reopening the already-decided "no durable per-candidate ledger, producer-process crash means a fresh job attempt" position in run/mod.rs. That position is about surviving a producer *process* crash; this is about a live producer not re-sending data the consumer already has after a mere *connection* drop. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/mod.rs | 4 + crates/uffs-content/src/job/registry.rs | 267 ++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 crates/uffs-content/src/job/registry.rs diff --git a/crates/uffs-content/src/job/mod.rs b/crates/uffs-content/src/job/mod.rs index 1364fde34..d7695a83c 100644 --- a/crates/uffs-content/src/job/mod.rs +++ b/crates/uffs-content/src/job/mod.rs @@ -17,6 +17,10 @@ pub mod candidate_source; pub mod content_source; pub mod intake; pub mod manifest_builder; +// In-memory per-job resume state (which candidates a reconnecting +// consumer still needs streamed). Cross-platform: pure logic, no VSS/ +// pipe dependency of its own. +mod registry; // Coordinator-side client for the Broker's Snapshot Manager pipe — the // real VSS lease backend `candidate_source`'s VSS-backed implementation // calls into. Windows-only: no VSS, no Broker to talk to elsewhere, diff --git a/crates/uffs-content/src/job/registry.rs b/crates/uffs-content/src/job/registry.rs new file mode 100644 index 000000000..9b2d9c010 --- /dev/null +++ b/crates/uffs-content/src/job/registry.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Not wired into the streaming engine yet — that lands with the two-pipe +// server and the window-enforcing streaming loop (same feature arc, +// still in progress). Exercised today only by this module's own unit +// tests. +// Only expected outside `#[cfg(test)]` builds: this module's own unit +// tests exercise every item, so a `--tests`/`cargo test` build sees no +// dead code at all — the expectation would be unfulfilled there. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "consumed by the not-yet-landed two-pipe server / \ + streaming engine; the type and its API are complete \ + and unit-tested ahead of that wiring landing, matching \ + this crate's existing build-ahead-of-the-consumer \ + precedent" + ) +)] + +//! In-memory, per-process job registry: the resume state a `JOB_RESUME` +//! reconnect consults to skip candidates the consumer already +//! acknowledged, instead of re-streaming the whole job. +//! +//! Deliberately **not** durable. This is scoped to the specific gap +//! between two already-decided positions: +//! +//! - [`crate::run`]'s own doc comment: no durable per-candidate ledger, no +//! crash-recovery reconciliation — a producer-*process* crash means a fresh +//! job attempt with a fresh VSS snapshot, relying on the consumer's own +//! content-hash dedup to make re-streaming already- ingested content a no-op. +//! - A live producer process losing its *connection* to the consumer (a +//! transport blip, the consumer process restarting) is a much more common +//! event than a producer crash, and re-streaming everything already streamed +//! and acknowledged before the blip (potentially most of a large job) is +//! real, avoidable waste — not a correctness requirement, since the +//! consumer's dedup would absorb it either way. +//! +//! So: while the producer *process* is alive, it keeps this registry in +//! memory; a reconnecting consumer names the `job_id` it wants to +//! resume, and the registry reports which candidates still need +//! streaming. If the producer process itself has died, the registry +//! (and the job with it) is gone — that falls through to the existing, +//! already-decided "start a fresh job" path, unchanged. + +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; + +/// One job's resume-relevant state: which candidate ids exist, and +/// which of them the consumer has already acknowledged. +struct ActiveJob { + /// Every candidate id this job's manifest assigned, in enumeration + /// order. + candidate_ids: Vec, + /// Candidate ids the consumer has sent `FILE_ACK` for. + acked: HashSet, +} + +/// Registry of jobs the current producer process is actively serving. +/// +/// Cheap to hold for a job's whole lifetime: `acked` is a `HashSet`, +/// a few bytes per candidate — negligible even for a job with hundreds +/// of thousands of candidates, and nothing here is written to disk. +pub(crate) struct JobRegistry { + /// Active jobs keyed by `job_id`. + jobs: Mutex>, +} + +impl JobRegistry { + /// An empty registry. + pub(crate) fn new() -> Self { + Self { + jobs: Mutex::new(HashMap::new()), + } + } + + /// Register a freshly started job with its full candidate id list. + /// Replaces any prior registration under the same `job_id` (there + /// shouldn't be one — job ids are fresh UUIDs per job — but a + /// pathological duplicate submission overwrites rather than panics). + pub(crate) fn register(&self, job_id: [u8; 16], candidate_ids: Vec) { + let mut jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + jobs.insert(job_id, ActiveJob { + candidate_ids, + acked: HashSet::new(), + }); + } + + /// Record a `FILE_ACK` for `candidate_id` under `job_id`. + /// + /// Returns `true` if `job_id` is a known, still-registered job + /// (regardless of whether `candidate_id` was already acked — acking + /// twice is a harmless no-op, matching the wire protocol's own + /// idempotency contract). + pub(crate) fn ack(&self, job_id: [u8; 16], candidate_id: u64) -> bool { + let mut jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(job) = jobs.get_mut(&job_id) else { + return false; + }; + job.acked.insert(candidate_id); + drop(jobs); + true + } + + /// Candidate ids for `job_id` that have **not** yet been acked, in + /// their original enumeration order — what a fresh connection or a + /// `JOB_RESUME` reconnect should stream. `None` if `job_id` isn't a + /// currently-registered job (producer restarted, job finished and + /// was removed, or it was never this producer's job). + pub(crate) fn pending(&self, job_id: [u8; 16]) -> Option> { + let jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let job = jobs.get(&job_id)?; + let pending_ids: Vec = job + .candidate_ids + .iter() + .copied() + .filter(|id| !job.acked.contains(id)) + .collect(); + drop(jobs); + Some(pending_ids) + } + + /// Whether every candidate registered for `job_id` has been acked. + /// `None` if `job_id` isn't currently registered. + pub(crate) fn is_complete(&self, job_id: [u8; 16]) -> Option { + let jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let job = jobs.get(&job_id)?; + let complete = job.candidate_ids.iter().all(|id| job.acked.contains(id)); + drop(jobs); + Some(complete) + } + + /// Drop `job_id`'s state — once a job is fully acked (or explicitly + /// cancelled), there is nothing left to resume. + pub(crate) fn remove(&self, job_id: [u8; 16]) { + let mut jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + jobs.remove(&job_id); + } + + /// Whether `job_id` is currently registered (alive in this + /// producer process). + pub(crate) fn contains(&self, job_id: [u8; 16]) -> bool { + let jobs = self + .jobs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + jobs.contains_key(&job_id) + } +} + +impl Default for JobRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::JobRegistry; + + const JOB_A: [u8; 16] = [1; 16]; + const JOB_B: [u8; 16] = [2; 16]; + + #[test] + fn unregistered_job_reports_no_pending_and_not_complete() { + let registry = JobRegistry::new(); + assert_eq!(registry.pending(JOB_A), None); + assert_eq!(registry.is_complete(JOB_A), None); + assert!(!registry.contains(JOB_A)); + assert!(!registry.ack(JOB_A, 1)); + } + + #[test] + fn fresh_registration_has_every_candidate_pending() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2, 3]); + assert!(registry.contains(JOB_A)); + assert_eq!(registry.pending(JOB_A), Some(vec![1, 2, 3])); + assert_eq!(registry.is_complete(JOB_A), Some(false)); + } + + #[test] + fn acking_a_candidate_removes_it_from_pending() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2, 3]); + assert!(registry.ack(JOB_A, 2)); + assert_eq!(registry.pending(JOB_A), Some(vec![1, 3])); + assert_eq!(registry.is_complete(JOB_A), Some(false)); + } + + #[test] + fn acking_every_candidate_marks_the_job_complete() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + assert!(registry.ack(JOB_A, 1)); + assert!(registry.ack(JOB_A, 2)); + assert_eq!(registry.pending(JOB_A), Some(vec![])); + assert_eq!(registry.is_complete(JOB_A), Some(true)); + } + + #[test] + fn acking_the_same_candidate_twice_is_a_harmless_no_op() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + assert!(registry.ack(JOB_A, 1)); + assert!(registry.ack(JOB_A, 1)); + assert_eq!(registry.pending(JOB_A), Some(vec![2])); + } + + #[test] + fn acking_an_unknown_candidate_id_is_recorded_but_never_appears_pending() { + // Defends against a malicious/buggy consumer acking an id that + // was never in the manifest: it's silently absorbed (the ack + // just never removes anything from `pending`, since `pending` + // is built from `candidate_ids`, not from `acked`), never + // fabricates a phantom pending entry. + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + assert!(registry.ack(JOB_A, 999)); + assert_eq!(registry.pending(JOB_A), Some(vec![1, 2])); + } + + #[test] + fn jobs_are_independent() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + registry.register(JOB_B, vec![10, 20]); + assert!(registry.ack(JOB_A, 1)); + assert_eq!(registry.pending(JOB_A), Some(vec![2])); + assert_eq!(registry.pending(JOB_B), Some(vec![10, 20])); + } + + #[test] + fn removing_a_job_drops_its_resume_state() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + registry.remove(JOB_A); + assert!(!registry.contains(JOB_A)); + assert_eq!(registry.pending(JOB_A), None); + } + + #[test] + fn re_registering_the_same_job_id_replaces_prior_state() { + let registry = JobRegistry::new(); + registry.register(JOB_A, vec![1, 2]); + assert!(registry.ack(JOB_A, 1)); + registry.register(JOB_A, vec![5, 6, 7]); + assert_eq!(registry.pending(JOB_A), Some(vec![5, 6, 7])); + } +} From 7737920a427d31afbd5d5bfde684fdcfaf5268e4 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:36:50 -0700 Subject: [PATCH 44/98] feat(content): add credit-based window/backpressure tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WindowTracker: the same flow-control mechanism as HTTP/2 stream credit (RFC 7540 §6.9) and TCP's receive window (design-doc §13.1/§13.2) -- the consumer grants a byte budget up front, the producer consumes it as it sends CONTENT_CHUNK bytes and must stop admitting new read work once exhausted, and a WINDOW_UPDATE frame raises the ceiling. Deliberately independent of FILE_ACK (registry.rs's concern) -- a consumer can grant window credit from buffer headroom alone, without having verified or durably persisted any specific file yet. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/mod.rs | 3 + crates/uffs-content/src/job/window.rs | 137 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 crates/uffs-content/src/job/window.rs diff --git a/crates/uffs-content/src/job/mod.rs b/crates/uffs-content/src/job/mod.rs index d7695a83c..ceb907daa 100644 --- a/crates/uffs-content/src/job/mod.rs +++ b/crates/uffs-content/src/job/mod.rs @@ -21,6 +21,9 @@ pub mod manifest_builder; // consumer still needs streamed). Cross-platform: pure logic, no VSS/ // pipe dependency of its own. mod registry; +// Credit-based backpressure tracker (design-doc §13). Cross-platform: +// pure logic, no VSS/pipe dependency of its own. +mod window; // Coordinator-side client for the Broker's Snapshot Manager pipe — the // real VSS lease backend `candidate_source`'s VSS-backed implementation // calls into. Windows-only: no VSS, no Broker to talk to elsewhere, diff --git a/crates/uffs-content/src/job/window.rs b/crates/uffs-content/src/job/window.rs new file mode 100644 index 000000000..62a58cf04 --- /dev/null +++ b/crates/uffs-content/src/job/window.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +// Not wired into the streaming engine yet — that lands with the +// two-pipe server (same feature arc, still in progress). Exercised +// today only by this module's own unit tests. +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "consumed by the not-yet-landed two-pipe server / \ + streaming engine; the type and its API are complete \ + and unit-tested ahead of that wiring landing, matching \ + this crate's existing build-ahead-of-the-consumer \ + precedent" + ) +)] + +//! Credit-based backpressure (design-doc §13.1 "Byte-based limits" / +//! §13.2 "Slow consumer"). +//! +//! Same mechanism as HTTP/2 stream-level flow control (RFC 7540 §6.9) +//! and TCP's receive-window advertisement: the consumer grants the +//! producer a byte budget up front; the producer consumes budget as it +//! sends `CONTENT_CHUNK` bytes and must stop admitting new read work +//! once the budget is exhausted; a `WINDOW_UPDATE` frame from the +//! consumer raises the ceiling as it frees buffer space. Deliberately +//! independent of `FILE_ACK` (a separate, file-granularity, digest- +//! verified concern — see `crate::job::registry`) — a consumer may grant +//! window credit as soon as it has buffer room, without having verified +//! or durably persisted any specific file yet. + +/// Tracks how many bytes the producer may still send before it must +/// pause and wait for a `WINDOW_UPDATE`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct WindowTracker { + /// Total bytes ever granted: the initial negotiated + /// `max_unacknowledged_bytes` plus every `WINDOW_UPDATE` grant since. + granted_bytes: u64, + /// Total bytes sent so far. + sent_bytes: u64, +} + +impl WindowTracker { + /// A new tracker starting with `initial_window_bytes` of budget + /// (the negotiated `max_unacknowledged_bytes`). + pub(crate) const fn new(initial_window_bytes: u64) -> Self { + Self { + granted_bytes: initial_window_bytes, + sent_bytes: 0, + } + } + + /// Bytes still available to send before the window is exhausted. + pub(crate) const fn available(&self) -> u64 { + self.granted_bytes.saturating_sub(self.sent_bytes) + } + + /// Whether `bytes` more may be sent without exceeding the current + /// window. + pub(crate) const fn can_admit(&self, bytes: u64) -> bool { + bytes <= self.available() + } + + /// Record that `bytes` were just sent (now counted against the + /// window until a matching `WINDOW_UPDATE` arrives). + pub(crate) const fn record_sent(&mut self, bytes: u64) { + self.sent_bytes = self.sent_bytes.saturating_add(bytes); + } + + /// Apply a `WINDOW_UPDATE { additional_window_bytes }` frame, + /// raising the ceiling. + pub(crate) const fn grant(&mut self, additional_window_bytes: u64) { + self.granted_bytes = self.granted_bytes.saturating_add(additional_window_bytes); + } +} + +#[cfg(test)] +mod tests { + use super::WindowTracker; + + #[test] + fn fresh_tracker_has_the_full_initial_window_available() { + let tracker = WindowTracker::new(1000); + assert_eq!(tracker.available(), 1000); + assert!(tracker.can_admit(1000)); + assert!(!tracker.can_admit(1001)); + } + + #[test] + fn sending_bytes_reduces_availability() { + let mut tracker = WindowTracker::new(1000); + tracker.record_sent(400); + assert_eq!(tracker.available(), 600); + assert!(tracker.can_admit(600)); + assert!(!tracker.can_admit(601)); + } + + #[test] + fn exhausting_the_window_admits_nothing_further() { + let mut tracker = WindowTracker::new(500); + tracker.record_sent(500); + assert_eq!(tracker.available(), 0); + assert!(!tracker.can_admit(1)); + assert!(tracker.can_admit(0)); + } + + #[test] + fn window_update_raises_the_ceiling() { + let mut tracker = WindowTracker::new(500); + tracker.record_sent(500); + assert_eq!(tracker.available(), 0); + tracker.grant(300); + assert_eq!(tracker.available(), 300); + assert!(tracker.can_admit(300)); + assert!(!tracker.can_admit(301)); + } + + #[test] + fn sent_bytes_never_underflow_available_below_zero() { + // Sending exactly up to the ceiling, never past it (can_admit is + // the caller's contract to honor), leaves available() at exactly + // zero rather than wrapping. + let mut tracker = WindowTracker::new(100); + tracker.record_sent(100); + assert_eq!(tracker.available(), 0); + } + + #[test] + fn multiple_grants_accumulate() { + let mut tracker = WindowTracker::new(0); + assert_eq!(tracker.available(), 0); + tracker.grant(100); + tracker.grant(50); + assert_eq!(tracker.available(), 150); + } +} From 33966e3545ba6fd9f505d8c58a8bf0f68c57aafe Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:43:00 -0700 Subject: [PATCH 45/98] feat(content-protocol): add JOB_RESUME frame + progress marker on HEARTBEAT Two additions on top of the design doc's 12 required frame types, both needed for the connection-blip resume mechanism (job::registry, already landed): - FrameType::JobResume (13): consumer reconnect signal, empty payload -- the frame envelope's own job_id already names which job to resume, so nothing else needs to travel in the payload. - Heartbeat gains last_completed_candidate_id: u64 (0 = none yet, the same reserved-zero-sentinel convention manifest_builder's candidate ids already use). A cheap, no-ledger-required checkpoint hint riding an already-required liveness frame; FILE_ACK-driven state in job::registry remains authoritative whenever the two disagree. Old empty HEARTBEAT payloads still decode fine (missing bytes -> the zero sentinel), so this isn't a breaking change to the wire format for a peer that predates the marker. Co-Authored-By: Claude Sonnet 5 --- .../src/frame/control.rs | 53 +++++++++++++++++-- crates/uffs-content-protocol/src/frame/mod.rs | 14 ++++- .../uffs-content-protocol/src/frame/tests.rs | 42 ++++++++++++--- crates/uffs-content/src/job/self_test.rs | 3 +- .../tests/support/test_consumer.rs | 3 +- 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/uffs-content-protocol/src/frame/control.rs b/crates/uffs-content-protocol/src/frame/control.rs index df82c3ba6..43626b128 100644 --- a/crates/uffs-content-protocol/src/frame/control.rs +++ b/crates/uffs-content-protocol/src/frame/control.rs @@ -51,13 +51,58 @@ impl Progress { } } -/// `HEARTBEAT` payload: empty. Its purpose is solely the frame envelope -/// arriving at all (design-doc §12.2 "prevents an idle long-file -/// operation from looking dead"). +/// `HEARTBEAT` payload. +/// +/// Primarily exists so the frame envelope arriving at all proves +/// liveness (design-doc §12.2 "prevents an idle long-file operation from +/// looking dead"), but also carries a cheap resume marker — the +/// producer's own idea of the last candidate it completed — so a +/// reconnecting consumer (or the producer itself, after a transport blip +/// that didn't kill the process) has a recent, no-cost checkpoint +/// without needing a durable ledger. Superseded by the authoritative +/// `FILE_ACK`-driven state in `crate::job::registry` (UFFS-side, not +/// part of this wire crate) whenever the two disagree — this is a hint, +/// not a source of truth. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Heartbeat; +pub struct Heartbeat { + /// The most recent candidate id the producer finished streaming + /// (`FILE_END`/`FILE_FAILED`/`FILE_DEFERRED` already sent for it), or + /// `0` if none yet — `0` is never a real candidate id (candidate ids + /// are 1-based; see `manifest_builder::index_to_candidate_id`'s own + /// reserved-sentinel rationale). + pub last_completed_candidate_id: u64, +} impl Heartbeat { + /// Encode this payload. + #[must_use] + pub fn encode(self) -> Vec { + let mut out = Vec::new(); + write_u64_le(&mut out, self.last_completed_candidate_id); + out + } + + /// Decode this payload. A short/empty buffer (an old peer's empty + /// `HEARTBEAT`) decodes as `last_completed_candidate_id: 0` rather + /// than erroring — liveness-only heartbeats from a peer that + /// predates this marker are still valid heartbeats. + #[must_use] + pub fn decode(reader: &mut Reader<'_>) -> Self { + Self { + last_completed_candidate_id: reader.read_u64_le().unwrap_or(0), + } + } +} + +/// `JOB_RESUME` payload: empty. +/// +/// Sent by a reconnecting consumer to resume the job named by this +/// frame's own `FrameEnvelope::job_id` — nothing else needs to travel in +/// the payload, since the envelope already identifies the job. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JobResume; + +impl JobResume { /// Encode this payload (always empty). #[must_use] #[expect( diff --git a/crates/uffs-content-protocol/src/frame/mod.rs b/crates/uffs-content-protocol/src/frame/mod.rs index 27c8f12db..8bf137c32 100644 --- a/crates/uffs-content-protocol/src/frame/mod.rs +++ b/crates/uffs-content-protocol/src/frame/mod.rs @@ -29,7 +29,7 @@ mod job_begin; mod job_end; pub use content_chunk::ContentChunk; -pub use control::{Heartbeat, JobCancel, Progress, WindowUpdate}; +pub use control::{Heartbeat, JobCancel, JobResume, Progress, WindowUpdate}; pub use file_ack::FileAck; pub use file_begin::FileBegin; pub use file_deferred::FileDeferred; @@ -109,7 +109,13 @@ pub enum FrameError { }, } -/// The 12 required frame types (design-doc §12.2). +/// The 12 required frame types (design-doc §12.2), plus [`Self::JobResume`]. +/// +/// `JobResume` is a reconnect-after-a-transport-blip mechanism this +/// crate adds on top of the design doc's transport model (the doc +/// leaves how a consumer reconnects to an in-flight job unspecified). +/// Empty payload: the envelope's own `job_id` already names which job to +/// resume. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u16)] pub enum FrameType { @@ -137,6 +143,10 @@ pub enum FrameType { JobCancel = 11, /// Consumer-initiated backpressure window increase. WindowUpdate = 12, + /// Consumer reconnect: resume streaming the job named by this + /// frame's envelope `job_id`, skipping any candidate already + /// acknowledged before the connection dropped. + JobResume = 13, } impl FrameType { diff --git a/crates/uffs-content-protocol/src/frame/tests.rs b/crates/uffs-content-protocol/src/frame/tests.rs index 96132b0b4..47ebf38bf 100644 --- a/crates/uffs-content-protocol/src/frame/tests.rs +++ b/crates/uffs-content-protocol/src/frame/tests.rs @@ -6,8 +6,8 @@ use super::{ ConsumerAckStatus, ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileAck, FileBegin, FileDeferred, FileEnd, FileFailed, FrameEnvelope, FrameError, - FrameOrdering, FrameType, Heartbeat, JobBegin, JobCancel, JobEnd, JobStatus, Progress, - ReadMode, RetryClass, WindowUpdate, + FrameOrdering, FrameType, Heartbeat, JobBegin, JobCancel, JobEnd, JobResume, JobStatus, + Progress, ReadMode, RetryClass, WindowUpdate, }; use crate::codec::Reader; use crate::error::ErrorCode; @@ -468,11 +468,41 @@ fn progress_round_trips() { } #[test] -fn heartbeat_encodes_to_empty_bytes() { - let payload = Heartbeat; +fn heartbeat_round_trips_the_progress_marker() { + let payload = Heartbeat { + last_completed_candidate_id: 42, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = Heartbeat::decode(&mut reader); + assert_eq!(decoded, payload); +} + +#[test] +fn heartbeat_with_no_progress_yet_uses_the_zero_sentinel() { + let payload = Heartbeat { + last_completed_candidate_id: 0, + }; + let bytes = payload.encode(); + let mut reader = Reader::new(&bytes); + let decoded = Heartbeat::decode(&mut reader); + assert_eq!(decoded, payload); +} + +#[test] +fn heartbeat_decodes_an_old_peers_empty_payload_as_the_zero_sentinel() { + let decoded = Heartbeat::decode(&mut Reader::new(&[])); + assert_eq!(decoded, Heartbeat { + last_completed_candidate_id: 0 + }); +} + +#[test] +fn job_resume_encodes_to_empty_bytes() { + let payload = JobResume; assert!(payload.encode().is_empty()); - let decoded = Heartbeat::decode(); - assert_eq!(decoded, Heartbeat); + let decoded = JobResume::decode(); + assert_eq!(decoded, JobResume); } #[test] diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 0072d31cb..382366616 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -416,7 +416,8 @@ fn decode_single_file_content(manifest_bytes: &[u8], frames: &[Vec]) -> Resu | FrameType::Heartbeat | FrameType::JobEnd | FrameType::JobCancel - | FrameType::WindowUpdate => {} + | FrameType::WindowUpdate + | FrameType::JobResume => {} } } anyhow::ensure!( diff --git a/crates/uffs-content/tests/support/test_consumer.rs b/crates/uffs-content/tests/support/test_consumer.rs index dd3c64182..387f3d23d 100644 --- a/crates/uffs-content/tests/support/test_consumer.rs +++ b/crates/uffs-content/tests/support/test_consumer.rs @@ -145,6 +145,7 @@ fn apply_frame( | FrameType::Heartbeat | FrameType::JobEnd | FrameType::JobCancel - | FrameType::WindowUpdate => {} + | FrameType::WindowUpdate + | FrameType::JobResume => {} } } From 7d9a7413afff623ee8161ae3ab479b8408a4b3ab Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:52:56 -0700 Subject: [PATCH 46/98] fix(content-protocol): FrameType::decode was missing the JobResume(13) arm + add JobSubmit(14) The previous commit added FrameType::JobResume = 13 to the enum but never added its `13 => Ok(Self::JobResume)` decode arm, so JobResume frames could be encoded but never decoded (silently fell through to Err(13)) -- caught by the crate's own frame_type_round_trips_all_variants test once its hardcoded 1..=12 range got extended. Also adds FrameType::JobSubmit (14): job submission over the command pipe, payload is opaque JSON job-spec bytes, envelope's own job_id is the consumer-chosen id for the new job. Keeps the whole command-pipe transport uniform -- job submission, resume, and every control frame all travel as FrameEnvelope-wrapped messages, no separate framing convention needed for "start a job" versus "control an existing one." Co-Authored-By: Claude Sonnet 5 --- .../src/frame/control.rs | 38 ++++++++++++++++++- crates/uffs-content-protocol/src/frame/mod.rs | 23 +++++++---- .../uffs-content-protocol/src/frame/tests.rs | 24 ++++++++++-- crates/uffs-content-protocol/src/lib.rs | 21 ++++++++++ crates/uffs-content/src/job/self_test.rs | 3 +- .../tests/support/test_consumer.rs | 3 +- 6 files changed, 98 insertions(+), 14 deletions(-) diff --git a/crates/uffs-content-protocol/src/frame/control.rs b/crates/uffs-content-protocol/src/frame/control.rs index 43626b128..3e0230787 100644 --- a/crates/uffs-content-protocol/src/frame/control.rs +++ b/crates/uffs-content-protocol/src/frame/control.rs @@ -1,8 +1,10 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! `PROGRESS`, `HEARTBEAT`, `JOB_CANCEL`, and `WINDOW_UPDATE` payloads -//! (design-doc §12.2). +//! `PROGRESS`, `HEARTBEAT`, `JOB_CANCEL`, `WINDOW_UPDATE`, `JOB_RESUME`, +//! and `JOB_SUBMIT` payloads (design-doc §12.2, plus this crate's own +//! `JOB_RESUME`/`JOB_SUBMIT` additions — see [`super::FrameType`]'s doc +//! comment). use super::{FrameError, read_message, write_message}; use crate::codec::{Reader, write_u64_le}; @@ -122,6 +124,38 @@ impl JobResume { } } +/// `JOB_SUBMIT` payload: a JSON-encoded job spec. +/// +/// Deliberately opaque bytes rather than a structured wire layout this +/// crate parses field-by-field: the job spec is UFFS-side application +/// data (`uffs_content::job::intake::JobRequest`, outside this crate), +/// not part of the UFFS/Docenta content-stream contract itself. This +/// frame only needs to get those bytes from the consumer to the +/// producer intact; the envelope's own checksums already guard that. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JobSubmit { + /// The job spec, as UTF-8 JSON bytes. + pub job_spec_json: Vec, +} + +impl JobSubmit { + /// Encode this payload (the JSON bytes verbatim). + #[must_use] + pub fn encode(&self) -> Vec { + self.job_spec_json.clone() + } + + /// Decode this payload: the entire frame payload is the JSON bytes, + /// so this takes the raw payload directly rather than a [`Reader`] + /// (there are no further sub-fields to walk). + #[must_use] + pub fn decode(payload: &[u8]) -> Self { + Self { + job_spec_json: payload.to_vec(), + } + } +} + /// `JOB_CANCEL` payload, sent by the consumer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct JobCancel { diff --git a/crates/uffs-content-protocol/src/frame/mod.rs b/crates/uffs-content-protocol/src/frame/mod.rs index 8bf137c32..ba1697b2f 100644 --- a/crates/uffs-content-protocol/src/frame/mod.rs +++ b/crates/uffs-content-protocol/src/frame/mod.rs @@ -29,7 +29,7 @@ mod job_begin; mod job_end; pub use content_chunk::ContentChunk; -pub use control::{Heartbeat, JobCancel, JobResume, Progress, WindowUpdate}; +pub use control::{Heartbeat, JobCancel, JobResume, JobSubmit, Progress, WindowUpdate}; pub use file_ack::FileAck; pub use file_begin::FileBegin; pub use file_deferred::FileDeferred; @@ -109,13 +109,17 @@ pub enum FrameError { }, } -/// The 12 required frame types (design-doc §12.2), plus [`Self::JobResume`]. +/// The 12 required frame types (design-doc §12.2), plus +/// [`Self::JobResume`] and [`Self::JobSubmit`]. /// -/// `JobResume` is a reconnect-after-a-transport-blip mechanism this -/// crate adds on top of the design doc's transport model (the doc -/// leaves how a consumer reconnects to an in-flight job unspecified). -/// Empty payload: the envelope's own `job_id` already names which job to -/// resume. +/// Both additions cover ground the design doc leaves unspecified: how a +/// consumer starts a job and how it reconnects to one after a transport +/// blip (§10 "Transport model" names the channels but not a submission/ +/// reconnect handshake). `JobResume` has an empty payload — the frame +/// envelope's own `job_id` already names which job to resume. +/// `JobSubmit`'s payload is a JSON-encoded job spec; the consumer +/// chooses the `job_id` up front (in the envelope) and the producer +/// adopts it for the whole job, including its own `JOB_BEGIN`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[repr(u16)] pub enum FrameType { @@ -147,6 +151,9 @@ pub enum FrameType { /// frame's envelope `job_id`, skipping any candidate already /// acknowledged before the connection dropped. JobResume = 13, + /// Consumer-initiated job submission: payload is a JSON job spec; + /// the envelope's `job_id` is the consumer-chosen id for the new job. + JobSubmit = 14, } impl FrameType { @@ -175,6 +182,8 @@ impl FrameType { 10 => Ok(Self::JobEnd), 11 => Ok(Self::JobCancel), 12 => Ok(Self::WindowUpdate), + 13 => Ok(Self::JobResume), + 14 => Ok(Self::JobSubmit), other => Err(other), } } diff --git a/crates/uffs-content-protocol/src/frame/tests.rs b/crates/uffs-content-protocol/src/frame/tests.rs index 47ebf38bf..8e3cf022b 100644 --- a/crates/uffs-content-protocol/src/frame/tests.rs +++ b/crates/uffs-content-protocol/src/frame/tests.rs @@ -7,7 +7,7 @@ use super::{ ConsumerAckStatus, ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileAck, FileBegin, FileDeferred, FileEnd, FileFailed, FrameEnvelope, FrameError, FrameOrdering, FrameType, Heartbeat, JobBegin, JobCancel, JobEnd, JobResume, JobStatus, - Progress, ReadMode, RetryClass, WindowUpdate, + JobSubmit, Progress, ReadMode, RetryClass, WindowUpdate, }; use crate::codec::Reader; use crate::error::ErrorCode; @@ -135,12 +135,12 @@ fn envelope_rejects_unknown_frame_type() { #[test] fn frame_type_round_trips_all_variants() { - for value in 1_u16..=12 { + for value in 1_u16..=14 { let frame_type = FrameType::decode(value).unwrap(); assert_eq!(frame_type.encode(), value); } assert_eq!(FrameType::decode(0), Err(0)); - assert_eq!(FrameType::decode(13), Err(13)); + assert_eq!(FrameType::decode(15), Err(15)); } fn sample_job_begin() -> JobBegin { @@ -505,6 +505,24 @@ fn job_resume_encodes_to_empty_bytes() { assert_eq!(decoded, JobResume); } +#[test] +fn job_submit_round_trips_arbitrary_json_bytes() { + let payload = JobSubmit { + job_spec_json: br#"{"source_id":"s","root":"C:\\","query":"*.txt"}"#.to_vec(), + }; + let bytes = payload.encode(); + let decoded = JobSubmit::decode(&bytes); + assert_eq!(decoded, payload); +} + +#[test] +fn job_submit_decodes_empty_payload_as_empty_json_bytes() { + let decoded = JobSubmit::decode(&[]); + assert_eq!(decoded, JobSubmit { + job_spec_json: Vec::new() + }); +} + #[test] fn job_cancel_round_trips() { let payload = JobCancel { diff --git a/crates/uffs-content-protocol/src/lib.rs b/crates/uffs-content-protocol/src/lib.rs index 4be0948ce..091ffa04d 100644 --- a/crates/uffs-content-protocol/src/lib.rs +++ b/crates/uffs-content-protocol/src/lib.rs @@ -40,3 +40,24 @@ pub mod frame; pub mod manifest; pub mod path_encoding; pub mod state; + +/// Named pipe the Content Coordinator's **data** channel listens on. +/// +/// `JOB_BEGIN`/`FILE_BEGIN`/`CONTENT_CHUNK`/`FILE_END`/`FILE_FAILED`/ +/// `FILE_DEFERRED`/`JOB_END` — the content stream itself, producer to +/// consumer. Kept on a separate pipe from [`COMMAND_PIPE_NAME`] so a +/// large in-flight `CONTENT_CHUNK` write can never head-of-line-block a +/// `WINDOW_UPDATE`/`FILE_ACK`/`JOB_CANCEL` the consumer needs to send +/// promptly (named pipes have no per-message-type multiplexing the way +/// HTTP/2 streams do, so that separation has to be a second pipe). +pub const DATA_PIPE_NAME: &str = r"\\.\pipe\uffs-content-data"; + +/// Named pipe the Content Coordinator's **command** channel listens on. +/// +/// Job submission/[`frame::JobResume`] (consumer to producer), +/// [`frame::WindowUpdate`]/[`frame::FileAck`]/[`frame::JobCancel`] +/// (consumer to producer), and [`frame::Progress`]/[`frame::Heartbeat`] +/// (producer to consumer). Always low-volume regardless of job size, so +/// it stays responsive even while [`DATA_PIPE_NAME`] is saturated with a +/// huge file's content. +pub const COMMAND_PIPE_NAME: &str = r"\\.\pipe\uffs-content-command"; diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 382366616..faa937aad 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -417,7 +417,8 @@ fn decode_single_file_content(manifest_bytes: &[u8], frames: &[Vec]) -> Resu | FrameType::JobEnd | FrameType::JobCancel | FrameType::WindowUpdate - | FrameType::JobResume => {} + | FrameType::JobResume + | FrameType::JobSubmit => {} } } anyhow::ensure!( diff --git a/crates/uffs-content/tests/support/test_consumer.rs b/crates/uffs-content/tests/support/test_consumer.rs index 387f3d23d..d180d70c4 100644 --- a/crates/uffs-content/tests/support/test_consumer.rs +++ b/crates/uffs-content/tests/support/test_consumer.rs @@ -146,6 +146,7 @@ fn apply_frame( | FrameType::JobEnd | FrameType::JobCancel | FrameType::WindowUpdate - | FrameType::JobResume => {} + | FrameType::JobResume + | FrameType::JobSubmit => {} } } From dff0fdf12ea6c8f0db5e65b6cd0d5444a5d9ab7f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:22:55 -0700 Subject: [PATCH 47/98] feat(content): wire the two-pipe transport server into uffs-content Builds the real `--serve` entry point: a server-lifetime command pipe (JOB_SUBMIT/JOB_RESUME/WINDOW_UPDATE/FILE_ACK/JOB_CANCEL in) paired with a job-owned data pipe that groups a job's frames by candidate and paces emission through the window tracker, reconnecting cleanly on a transport blip instead of restarting the job. `job::registry`/`job::window` are now real production dependencies of `serve` rather than scaffolding ahead of their consumer. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 + crates/uffs-content/Cargo.toml | 9 + crates/uffs-content/src/job/mod.rs | 10 +- crates/uffs-content/src/job/registry.rs | 59 ++- crates/uffs-content/src/job/window.rs | 29 +- crates/uffs-content/src/lib.rs | 66 +++- crates/uffs-content/src/main.rs | 47 +++ crates/uffs-content/src/serve/command_pipe.rs | 221 +++++++++++ crates/uffs-content/src/serve/mod.rs | 98 +++++ crates/uffs-content/src/serve/pipe_io.rs | 129 +++++++ crates/uffs-content/src/serve/stream.rs | 361 ++++++++++++++++++ .../tests/e2e_dir_walk_parity_fake_reader.rs | 4 + .../tests/e2e_real_vss_content_reader.rs | 4 + 13 files changed, 981 insertions(+), 58 deletions(-) create mode 100644 crates/uffs-content/src/serve/command_pipe.rs create mode 100644 crates/uffs-content/src/serve/mod.rs create mode 100644 crates/uffs-content/src/serve/pipe_io.rs create mode 100644 crates/uffs-content/src/serve/stream.rs diff --git a/Cargo.lock b/Cargo.lock index 657e32db6..87949719b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4489,11 +4489,13 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tokio", "tracing", "uffs-broker-protocol", "uffs-client", "uffs-content-protocol", "uffs-content-reader-protocol", + "uffs-security", "uffs-version", "uuid", "winresource", diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index e7b7c0f69..1bb8c6fd1 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -102,6 +102,15 @@ uffs-content-reader-protocol.workspace = true # teardown (`src/job/vss_orchestrator.rs`) — not used anywhere else in # this crate today. tracing.workspace = true +# Named-pipe server for the two-pipe transport (`src/serve/`) — every +# other feature this crate needs (`rt`/`sync`/`time`/`io-util`) is +# already unconditional at workspace scope; only `net` (named pipes) +# needs enabling here, matching `uffs-daemon`'s own declaration. +tokio = { workspace = true, features = ["net"] } +# Owner-only pipe DACL + `PipeName` validation for the two-pipe +# transport server (`src/serve/pipe_io.rs`) — the same primitives +# `uffs-daemon`'s and `uffs-content-reader`'s named-pipe servers use. +uffs-security.workspace = true [build-dependencies] uffs-version = { workspace = true, features = ["build"] } diff --git a/crates/uffs-content/src/job/mod.rs b/crates/uffs-content/src/job/mod.rs index ceb907daa..39d8bbd87 100644 --- a/crates/uffs-content/src/job/mod.rs +++ b/crates/uffs-content/src/job/mod.rs @@ -19,11 +19,13 @@ pub mod intake; pub mod manifest_builder; // In-memory per-job resume state (which candidates a reconnecting // consumer still needs streamed). Cross-platform: pure logic, no VSS/ -// pipe dependency of its own. -mod registry; +// pipe dependency of its own. `pub(crate)` so `crate::serve` (the +// two-pipe transport server, a sibling of this module) can reach it. +pub(crate) mod registry; // Credit-based backpressure tracker (design-doc §13). Cross-platform: -// pure logic, no VSS/pipe dependency of its own. -mod window; +// pure logic, no VSS/pipe dependency of its own. `pub(crate)` for the +// same reason as `registry`. +pub(crate) mod window; // Coordinator-side client for the Broker's Snapshot Manager pipe — the // real VSS lease backend `candidate_source`'s VSS-backed implementation // calls into. Windows-only: no VSS, no Broker to talk to elsewhere, diff --git a/crates/uffs-content/src/job/registry.rs b/crates/uffs-content/src/job/registry.rs index 9b2d9c010..8dbab1cab 100644 --- a/crates/uffs-content/src/job/registry.rs +++ b/crates/uffs-content/src/job/registry.rs @@ -1,25 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -// Not wired into the streaming engine yet — that lands with the two-pipe -// server and the window-enforcing streaming loop (same feature arc, -// still in progress). Exercised today only by this module's own unit -// tests. -// Only expected outside `#[cfg(test)]` builds: this module's own unit -// tests exercise every item, so a `--tests`/`cargo test` build sees no -// dead code at all — the expectation would be unfulfilled there. -#![cfg_attr( - not(test), - expect( - dead_code, - reason = "consumed by the not-yet-landed two-pipe server / \ - streaming engine; the type and its API are complete \ - and unit-tested ahead of that wiring landing, matching \ - this crate's existing build-ahead-of-the-consumer \ - precedent" - ) -)] - //! In-memory, per-process job registry: the resume state a `JOB_RESUME` //! reconnect consults to skip candidates the consumer already //! acknowledged, instead of re-streaming the whole job. @@ -50,6 +31,16 @@ use std::sync::Mutex; /// One job's resume-relevant state: which candidate ids exist, and /// which of them the consumer has already acknowledged. +#[cfg_attr( + not(any(windows, test)), + expect( + dead_code, + reason = "only constructed by the Windows-only `serve` module's streaming \ + task in production; exercised cross-platform by this module's own \ + unit tests, which is why the type still lives here rather than \ + behind `#[cfg(windows)]`" + ) +)] struct ActiveJob { /// Every candidate id this job's manifest assigned, in enumeration /// order. @@ -80,6 +71,10 @@ impl JobRegistry { /// Replaces any prior registration under the same `job_id` (there /// shouldn't be one — job ids are fresh UUIDs per job — but a /// pathological duplicate submission overwrites rather than panics). + #[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `ActiveJob` doc comment above") + )] pub(crate) fn register(&self, job_id: [u8; 16], candidate_ids: Vec) { let mut jobs = self .jobs @@ -97,6 +92,10 @@ impl JobRegistry { /// (regardless of whether `candidate_id` was already acked — acking /// twice is a harmless no-op, matching the wire protocol's own /// idempotency contract). + #[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `ActiveJob` doc comment above") + )] pub(crate) fn ack(&self, job_id: [u8; 16], candidate_id: u64) -> bool { let mut jobs = self .jobs @@ -115,6 +114,10 @@ impl JobRegistry { /// `JOB_RESUME` reconnect should stream. `None` if `job_id` isn't a /// currently-registered job (producer restarted, job finished and /// was removed, or it was never this producer's job). + #[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `ActiveJob` doc comment above") + )] pub(crate) fn pending(&self, job_id: [u8; 16]) -> Option> { let jobs = self .jobs @@ -133,6 +136,10 @@ impl JobRegistry { /// Whether every candidate registered for `job_id` has been acked. /// `None` if `job_id` isn't currently registered. + #[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `ActiveJob` doc comment above") + )] pub(crate) fn is_complete(&self, job_id: [u8; 16]) -> Option { let jobs = self .jobs @@ -146,6 +153,10 @@ impl JobRegistry { /// Drop `job_id`'s state — once a job is fully acked (or explicitly /// cancelled), there is nothing left to resume. + #[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `ActiveJob` doc comment above") + )] pub(crate) fn remove(&self, job_id: [u8; 16]) { let mut jobs = self .jobs @@ -156,6 +167,16 @@ impl JobRegistry { /// Whether `job_id` is currently registered (alive in this /// producer process). + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "exercised by this module's own unit tests only; no production \ + call site needs it yet (JOB_RESUME handling keys off \ + `ServerState::active` instead) — kept because it is the natural \ + complement to `pending`/`is_complete` and cheap to maintain" + ) + )] pub(crate) fn contains(&self, job_id: [u8; 16]) -> bool { let jobs = self .jobs diff --git a/crates/uffs-content/src/job/window.rs b/crates/uffs-content/src/job/window.rs index 62a58cf04..63ff4eabd 100644 --- a/crates/uffs-content/src/job/window.rs +++ b/crates/uffs-content/src/job/window.rs @@ -1,21 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -// Not wired into the streaming engine yet — that lands with the -// two-pipe server (same feature arc, still in progress). Exercised -// today only by this module's own unit tests. -#![cfg_attr( - not(test), - expect( - dead_code, - reason = "consumed by the not-yet-landed two-pipe server / \ - streaming engine; the type and its API are complete \ - and unit-tested ahead of that wiring landing, matching \ - this crate's existing build-ahead-of-the-consumer \ - precedent" - ) -)] - //! Credit-based backpressure (design-doc §13.1 "Byte-based limits" / //! §13.2 "Slow consumer"). //! @@ -32,6 +17,16 @@ /// Tracks how many bytes the producer may still send before it must /// pause and wait for a `WINDOW_UPDATE`. +#[cfg_attr( + not(any(windows, test)), + expect( + dead_code, + reason = "only constructed by the Windows-only `serve` module's streaming \ + task in production; exercised cross-platform by this module's own \ + unit tests, which is why the type still lives here rather than \ + behind `#[cfg(windows)]`" + ) +)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct WindowTracker { /// Total bytes ever granted: the initial negotiated @@ -41,6 +36,10 @@ pub(crate) struct WindowTracker { sent_bytes: u64, } +#[cfg_attr( + not(any(windows, test)), + expect(dead_code, reason = "see the `WindowTracker` doc comment above") +)] impl WindowTracker { /// A new tracker starting with `initial_window_bytes` of budget /// (the negotiated `max_unacknowledged_bytes`). diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index 76a0a65ac..eb88ddb7c 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -27,19 +27,27 @@ //! //! # Status //! -//! [`run`] (the ephemeral per-run manifest/failure-log/summary model) and -//! [`job`] (job intake, candidate enumeration, manifest construction, and -//! protocol framing) are real — but [`job`]'s [`job::candidate_source`] -//! and [`job::content_source`] backends are currently the cross-platform -//! `std::fs`-based stand-ins described in -//! `uffs-ingest-implementation-plan.md` §9.5, not the real VSS-snapshot -//! and privileged-Reader-backed ones (UFI.1/UFI.2). [`is_implemented`] -//! tracks the latter, not this crate's own workflow logic. +//! [`run`] (the ephemeral per-run manifest/failure-log/summary model), +//! [`job`] (job intake, candidate enumeration, manifest construction, +//! and protocol framing — both the cross-platform `std::fs`-based +//! stand-ins from `uffs-ingest-implementation-plan.md` §9.5 and the +//! real VSS-snapshot/privileged-Reader-backed production path, +//! `job::vss_job::run_vss_job`), and the two-pipe transport server +//! (`serve`, Windows-only) that lets an external consumer actually reach +//! that pipeline, are all real. [`is_implemented`] tracks the +//! VSS-backed pipeline's platform availability, not this crate's own +//! workflow logic. extern crate alloc; pub mod job; pub mod run; +// Two-pipe (data + command) transport server: the real entry point an +// external consumer (e.g. Docenta) connects to. Windows-only — named +// pipes, and every job this serves is VSS-backed (`job::vss_job`, +// itself Windows-only). +#[cfg(windows)] +pub(crate) mod serve; // `uffs_version::handle_version!` is invoked from `main.rs` only. // Dev-dependency used by `tests/support/plain_walk.rs` (the independent @@ -47,31 +55,49 @@ pub mod run; // unit tests. #[cfg(test)] use blake3 as _; -// Will spawn/query the ephemeral `uffsd` instance once the real, -// VSS+MFT-query-backed `CandidateSource` is wired up (not yet — the -// dead-code state on `job::snapshot_client` today is the same -// deliberately-deferred state, see that module's doc comment). -#[cfg(windows)] -use uffs_client as _; use uffs_version as _; /// Whether the production, VSS-snapshot-backed pipeline is wired up. /// -/// Returns `false` until [`job::candidate_source`] and -/// [`job::content_source`] have real Broker/Reader-backed implementations -/// (UFI.1/UFI.2) — the workflow itself ([`job::workflow::run_job`]) is -/// already real, just not yet running against NTFS. +/// `true` on Windows: [`job::candidate_source::VssCandidateSource`] and +/// [`job::content_source::VssContentSource`] are real, and +/// [`job::vss_job::run_vss_job`] has been validated end to end against +/// real hardware (real VSS snapshot, real ephemeral target-selection +/// daemon, real privileged Reader). `false` on every other platform — +/// VSS doesn't exist there, so this pipeline fundamentally can't run. +#[must_use] +#[cfg(windows)] +pub const fn is_implemented() -> bool { + true +} + +/// Non-Windows: see the Windows doc comment above for why this is +/// always `false` here, not a scaffold-vs-real distinction. #[must_use] +#[cfg(not(windows))] pub const fn is_implemented() -> bool { false } +/// Run the two-pipe transport server for the process's whole lifetime. +/// +/// The real entry point an external consumer (e.g. Docenta) connects +/// to. See the crate-private `serve` module's doc comment for the +/// wire-level design. +/// +/// # Errors +/// Returns an error only if a pipe itself cannot be created at all. +#[cfg(windows)] +pub fn serve() -> anyhow::Result<()> { + serve::run() +} + #[cfg(test)] mod tests { use super::is_implemented; #[test] - fn scaffold_reports_not_implemented() { - assert!(!is_implemented()); + fn is_implemented_matches_platform_capability() { + assert_eq!(is_implemented(), cfg!(windows)); } } diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index a555a0be3..bf2e721d9 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -20,6 +20,9 @@ //! //! ```bash //! uffs-content --version # Print version (also -V) +//! uffs-content --serve # Run the two-pipe transport server +//! # (the real entry point for a +//! # downstream consumer, e.g. Docenta) //! uffs-content --self-test-vss-playback # Elevated smoke test: real VSS //! # snapshot + real Reader playback //! uffs-content --self-test-vss-query # Elevated smoke test: real @@ -43,6 +46,10 @@ use serde as _; use serde_json as _; #[cfg(test)] use tempfile as _; +// Used by `uffs_content::serve`'s two-pipe transport server, not by this +// thin entry point directly. +#[cfg(windows)] +use tokio as _; // Used by `uffs_content::job::vss_orchestrator` (best-effort // lease-release warnings), not by this thin entry point directly. #[cfg(windows)] @@ -58,6 +65,10 @@ use uffs_content_protocol as _; // not by this thin entry point directly. #[cfg(windows)] use uffs_content_reader_protocol as _; +// Used by `uffs_content::serve`'s named-pipe owner-only DACL helpers, +// not by this thin entry point directly. +#[cfg(windows)] +use uffs_security as _; // Used by `uffs_content::job::workflow`, not by this thin entry point // directly. use uuid as _; @@ -81,6 +92,9 @@ fn main() { if let Some((root, extension)) = self_test_vss_query_args(&args) { std::process::exit(run_self_test_vss_query(&root, &extension)); } + if args.iter().any(|arg| arg == "--serve") { + std::process::exit(run_serve()); + } if uffs_content::is_implemented() { eprintln!("uffs-content: ready."); @@ -89,6 +103,39 @@ fn main() { } } +/// Run [`uffs_content::serve`] and report a fatal startup error, if any. +/// Does not return under normal operation — the server runs for the +/// process's whole lifetime. Returns the process exit code (`1`) only +/// if the server failed to start at all. +#[cfg(windows)] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn run_serve() -> i32 { + match uffs_content::serve() { + Ok(()) => 0, + Err(err) => { + eprintln!("FAIL: {err:#}"); + 1 + } + } +} + +/// Non-Windows stub: the two-pipe transport server only ever serves +/// VSS-backed jobs, which don't exist on this platform. +#[cfg(not(windows))] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn run_serve() -> i32 { + eprintln!( + "uffs-content: --serve is Windows-only (VSS-backed jobs don't exist on this platform)" + ); + 1 +} + /// Return the directory argument following `--self-test-vss-playback`, /// if present. #[cfg(windows)] diff --git a/crates/uffs-content/src/serve/command_pipe.rs b/crates/uffs-content/src/serve/command_pipe.rs new file mode 100644 index 000000000..241fbea40 --- /dev/null +++ b/crates/uffs-content/src/serve/command_pipe.rs @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Command pipe: job submission/resume and every control frame +//! (`WINDOW_UPDATE`/`FILE_ACK`/`JOB_CANCEL` in, `PROGRESS`/`HEARTBEAT` +//! out — the latter two not wired up yet, see the module doc's v1 gaps). +//! +//! Unlike [`super::stream`]'s data pipe (job-owned, exits once the job +//! completes), this pipe is server-lifetime: it loops accepting +//! connections for as long as the process runs, since it must remain +//! reachable across job boundaries — a `JOB_SUBMIT` for the *next* job +//! has to land somewhere even after the previous job's data pipe has +//! long since torn down. + +use alloc::sync::Arc; + +use tokio::net::windows::named_pipe::NamedPipeServer; +use uffs_content_protocol::COMMAND_PIPE_NAME; +use uffs_content_protocol::codec::Reader as WireReader; +use uffs_content_protocol::frame::{ + FileAck, FrameEnvelope, FrameType, JobCancel, JobSubmit, WindowUpdate, +}; + +use super::{ControlSignal, ServerState, stream}; +use crate::job::intake::JobRequest; + +/// Run the command pipe server for the process's whole lifetime. +/// +/// # Errors +/// Returns an error only if the pipe itself cannot be created at all. +#[expect( + clippy::infinite_loop, + reason = "server-lifetime accept loop: exits only via process shutdown, matching \ + uffs-broker's own sweep-expired-leases loop" +)] +pub(super) async fn serve(state: Arc) -> anyhow::Result<()> { + let mut first_instance = true; + loop { + let mut pipe = + super::pipe_io::accept_connection(COMMAND_PIPE_NAME, &mut first_instance).await; + tracing::info!("consumer connected on command pipe"); + serve_connection(&state, &mut pipe).await; + } +} + +/// Read and dispatch messages from one connected command-pipe client +/// until it disconnects or sends something malformed. +async fn serve_connection(state: &Arc, pipe: &mut NamedPipeServer) { + loop { + match super::pipe_io::read_one_message(pipe).await { + Ok(Some(message)) => dispatch(state, &message).await, + Ok(None) => { + tracing::info!("consumer disconnected from command pipe"); + return; + } + Err(err) => { + tracing::warn!(error = %err, "malformed command-pipe message; closing connection"); + return; + } + } + } +} + +/// Decode one framed message and dispatch it to the right handler. +async fn dispatch(state: &Arc, message: &[u8]) { + let mut reader = WireReader::new(message); + let (envelope, payload) = match FrameEnvelope::decode(&mut reader, u64::MAX) { + Ok(decoded) => decoded, + Err(err) => { + tracing::warn!(error = %err, "failed to decode command-pipe frame envelope"); + return; + } + }; + + match envelope.frame_type { + FrameType::JobSubmit => handle_job_submit(state, &JobSubmit::decode(&payload)), + FrameType::JobResume => handle_job_resume(state, envelope.job_id), + FrameType::WindowUpdate => handle_window_update(state, envelope.job_id, &payload).await, + FrameType::FileAck => handle_file_ack(state, envelope.job_id, &payload).await, + FrameType::JobCancel => handle_job_cancel(state, envelope.job_id, &payload).await, + producer_to_consumer @ (FrameType::JobBegin + | FrameType::FileBegin + | FrameType::ContentChunk + | FrameType::FileEnd + | FrameType::FileFailed + | FrameType::FileDeferred + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobEnd) => { + tracing::warn!( + ?producer_to_consumer, + "producer-to-consumer frame type on command pipe; ignoring" + ); + } + } +} + +/// `JOB_SUBMIT`: start a new job, unless one is already active (v1's +/// documented one-job-at-a-time scope — see the crate module doc). +fn handle_job_submit(state: &Arc, submit: &JobSubmit) { + { + let active = state + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active.is_some() { + tracing::warn!( + "JOB_SUBMIT rejected: a job is already active (v1 serves one at a time)" + ); + return; + } + } + let request: JobRequest = match serde_json::from_slice(&submit.job_spec_json) { + Ok(request) => request, + Err(err) => { + tracing::warn!(error = %err, "JOB_SUBMIT payload did not decode as a JobRequest"); + return; + } + }; + let run_dir = std::env::temp_dir().join(format!( + "uffs-content-serve-{}", + uuid::Uuid::new_v4().simple() + )); + if let Err(err) = std::fs::create_dir_all(&run_dir) { + tracing::warn!(error = %err, path = %run_dir.display(), "failed to create job run dir"); + return; + } + stream::spawn(Arc::clone(state), request, run_dir); +} + +/// `JOB_RESUME`: a reconnecting consumer naming a job it wants to keep +/// receiving. Nothing to *do* here beyond logging — the job's own data- +/// pipe accept loop ([`super::stream`]) is already waiting for exactly +/// this reconnection, and picks it up on its own the moment the +/// consumer opens a new data-pipe connection. If `job_id` doesn't match +/// (or there is no) active job, this producer process doesn't have that +/// job anymore (it crashed/restarted since) — the already-decided +/// fallback in `crate::run`'s own doc comment applies: the consumer +/// starts a fresh `JOB_SUBMIT` with a new VSS snapshot. +fn handle_job_resume(state: &Arc, job_id: [u8; 16]) { + let active = state + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*active { + Some(job) if job.job_id == job_id => { + tracing::info!(job_id = %super::pipe_io::hex_job_id(job_id), "JOB_RESUME acknowledged (data pipe reconnect expected)"); + } + _ => { + tracing::warn!(job_id = %super::pipe_io::hex_job_id(job_id), "JOB_RESUME for an unknown/no-longer-active job"); + } + } +} + +/// `WINDOW_UPDATE`: forward to the job's streaming task. +async fn handle_window_update(state: &Arc, job_id: [u8; 16], payload: &[u8]) { + let mut reader = WireReader::new(payload); + let Ok(update) = WindowUpdate::decode(&mut reader) else { + tracing::warn!("failed to decode WINDOW_UPDATE payload"); + return; + }; + send_signal( + state, + job_id, + ControlSignal::WindowGrant(update.additional_window_bytes), + ) + .await; +} + +/// `FILE_ACK`: forward accepted acks to the job's streaming task. A +/// rejected ack (digest mismatch on the consumer's side) is logged, not +/// forwarded — the candidate stays pending and will be retransmitted, +/// matching design-doc §12.9's "a digest mismatch is REJECTED" and +/// §9.4's retransmit-on-non-ack contract. +async fn handle_file_ack(state: &Arc, job_id: [u8; 16], payload: &[u8]) { + let mut reader = WireReader::new(payload); + let Ok(ack) = FileAck::decode(&mut reader) else { + tracing::warn!("failed to decode FILE_ACK payload"); + return; + }; + if ack.consumer_status == uffs_content_protocol::frame::ConsumerAckStatus::Rejected { + tracing::warn!( + candidate_id = ack.candidate_id, + error_code = ?ack.consumer_error_code, + "consumer rejected candidate; leaving it pending for retransmission" + ); + return; + } + send_signal(state, job_id, ControlSignal::FileAcked(ack.candidate_id)).await; +} + +/// `JOB_CANCEL`: forward to the job's streaming task. +async fn handle_job_cancel(state: &Arc, job_id: [u8; 16], payload: &[u8]) { + let mut reader = WireReader::new(payload); + let reason = + JobCancel::decode(&mut reader).map_or_else(|_| String::new(), |cancel| cancel.reason); + send_signal(state, job_id, ControlSignal::Cancel(reason)).await; +} + +/// Send `signal` to `job_id`'s streaming task, if it is the currently +/// active job. Logs and drops the signal otherwise (a control frame for +/// a job this producer process doesn't know about — already resolved, +/// or from a stale/mistaken consumer). +async fn send_signal(state: &Arc, job_id: [u8; 16], signal: ControlSignal) { + let control_tx = { + let active = state + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match &*active { + Some(job) if job.job_id == job_id => job.control_tx.clone(), + _ => { + tracing::warn!(job_id = %super::pipe_io::hex_job_id(job_id), "control signal for an unknown/no-longer-active job"); + return; + } + } + }; + if control_tx.send(signal).await.is_err() { + tracing::warn!(job_id = %super::pipe_io::hex_job_id(job_id), "job's control channel closed; signal dropped"); + } +} diff --git a/crates/uffs-content/src/serve/mod.rs b/crates/uffs-content/src/serve/mod.rs new file mode 100644 index 000000000..e13141f20 --- /dev/null +++ b/crates/uffs-content/src/serve/mod.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +#![cfg(windows)] + +//! Two-pipe transport server: the real entry point an external consumer +//! (e.g. Docenta) connects to. +//! +//! Two named pipes, per the design conversation this implements: +//! +//! - [`uffs_content_protocol::DATA_PIPE_NAME`] — the content stream itself +//! (`JOB_BEGIN`/`FILE_BEGIN`/`CONTENT_CHUNK`/.../`JOB_END`), producer to +//! consumer, paced by a [`crate::job::window::WindowTracker`]. Owned by the +//! job's own streaming task ([`stream`]) — see that module's doc comment for +//! why "accept loop lives with the job, not as a separate always-on server" +//! is what makes reconnect-and-resume correct. +//! - [`uffs_content_protocol::COMMAND_PIPE_NAME`] — job submission/resume, +//! `WINDOW_UPDATE`/`FILE_ACK`/`JOB_CANCEL` (consumer to producer), and +//! `PROGRESS`/`HEARTBEAT` (producer to consumer). Always low-volume, so it +//! stays responsive no matter how backed up the data pipe is. +//! +//! # v1 scope: one job at a time +//! +//! This server serves exactly one active job at a time — +//! [`ServerState::active`] is a single slot, not a map. A second +//! `JOB_SUBMIT` while a job is already running is rejected. This is a +//! deliberate, documented scope cut (not a design ceiling): concurrent +//! multi-job serving would need the data pipe to demultiplex frames by +//! `job_id` (today one connection carries exactly one job's stream) and +//! the command pipe to route control signals to the right job's task +//! instead of "the" active job. Revisit if a real multi-job requirement +//! shows up. + +mod command_pipe; +mod pipe_io; +mod stream; + +use alloc::sync::Arc; +use std::sync::Mutex; + +use tokio::sync::mpsc; + +use crate::job::registry::JobRegistry; + +/// A signal the command pipe delivers to the active job's streaming task +/// ([`stream::run`]). +pub(crate) enum ControlSignal { + /// `WINDOW_UPDATE`: raise the send budget by this many bytes. + WindowGrant(u64), + /// `FILE_ACK`: candidate id the consumer has durably accepted. + FileAcked(u64), + /// `JOB_CANCEL`: stop streaming. The `String` is a diagnostic reason + /// only. + Cancel(String), +} + +/// Handle to the currently-active job, from the command pipe's point of +/// view. +struct ActiveJob { + /// The producer-assigned id for this job (see [`stream::run`]'s doc + /// comment for why the producer, not the consumer, assigns it). + job_id: [u8; 16], + /// Delivers [`ControlSignal`]s to the streaming task. + control_tx: mpsc::Sender, +} + +/// Server-wide shared state, held behind an `Arc` by both the command +/// pipe and every job's streaming task. +struct ServerState { + /// Resume state for whichever job is (or was) active. + registry: Arc, + /// The single active job's control handle, if a job is running. See + /// the module doc for why this is one slot, not a map, in v1. + active: Mutex>, +} + +/// Run the command pipe server for the process's whole lifetime. Each +/// `JOB_SUBMIT` spawns a job-owned data-pipe streaming task +/// ([`stream::run`]) alongside it. +/// +/// # Errors +/// Returns an error only if the command pipe itself cannot be created at +/// all. +pub(crate) fn run() -> anyhow::Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(serve()) +} + +/// Async body of [`run`]. +async fn serve() -> anyhow::Result<()> { + let state = Arc::new(ServerState { + registry: Arc::new(JobRegistry::new()), + active: Mutex::new(None), + }); + command_pipe::serve(state).await +} diff --git a/crates/uffs-content/src/serve/pipe_io.rs b/crates/uffs-content/src/serve/pipe_io.rs new file mode 100644 index 000000000..4442beed0 --- /dev/null +++ b/crates/uffs-content/src/serve/pipe_io.rs @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Shared named-pipe helpers: pipe-instance creation (owner-only DACL) +//! and `[u32 LE length][payload]`-framed read/write. +//! +//! Reused by both the command pipe and each job's data-pipe connection +//! — mirrors `uffs-content-reader`'s own `pipe_server.rs` helpers (and, +//! further back, `uffs-daemon`'s named-pipe server), just parameterized +//! over the pipe name instead of hardcoding one. + +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}; + +/// Maximum single framed message size accepted on either pipe — a +/// generous ceiling matching this codebase's other narrow private IPC +/// APIs (the Broker's Snapshot Manager pipe, the content Reader's pipe). +pub(super) const MAX_MESSAGE_BYTES: u32 = 64 * 1024; + +/// How long to back off before retrying pipe-instance creation after a +/// transient failure. +const PIPE_RETRY_BACKOFF: core::time::Duration = core::time::Duration::from_millis(100); + +/// Build a single named-pipe server instance bound to `pipe_name` with +/// an owner-only DACL. Set `first = true` ONLY for the initial instance +/// (enables `FIRST_PIPE_INSTANCE` squat protection). +pub(super) fn create_server(pipe_name: &str, first: bool) -> anyhow::Result { + let parsed = uffs_security::pipe::PipeName::parse(pipe_name) + .map_err(|err| anyhow::anyhow!("invalid pipe name {pipe_name:?}: {err}"))?; + let sd = uffs_security::pipe::OwnerOnlySd::for_current_user() + .map_err(|err| anyhow::anyhow!("owner-only DACL build failed: {err}"))?; + let mut sa = sd.as_security_attributes(); + + let mut opts = ServerOptions::new(); + opts.access_inbound(true) + .access_outbound(true) + .pipe_mode(PipeMode::Byte) + .in_buffer_size(65_536) + .out_buffer_size(65_536) + .reject_remote_clients(true); + if first { + opts.first_pipe_instance(true); + } + + // SAFETY: `sa` is a valid `SECURITY_ATTRIBUTES` borrowing a + // `SECURITY_DESCRIPTOR` owned by `sd`, which outlives this call. + #[expect(unsafe_code, reason = "Win32 FFI — create named-pipe server")] + let server = unsafe { + opts.create_with_security_attributes_raw( + parsed.as_str(), + core::ptr::from_mut(&mut sa).cast(), + ) + }?; + Ok(server) +} + +/// Read one `[u32 LE length][payload]`-framed message, or `Ok(None)` on +/// a clean EOF (the peer disconnected between messages). +pub(super) async fn read_one_message( + pipe: &mut NamedPipeServer, +) -> anyhow::Result>> { + let mut length_bytes = [0_u8; 4]; + match pipe.read_exact(&mut length_bytes).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(err) => return Err(err.into()), + } + let length = u32::from_le_bytes(length_bytes); + anyhow::ensure!( + length <= MAX_MESSAGE_BYTES, + "message length {length} exceeds maximum {MAX_MESSAGE_BYTES}" + ); + let mut payload = vec![0_u8; length as usize]; + pipe.read_exact(&mut payload).await?; + Ok(Some(payload)) +} + +/// Write one `[u32 LE length][payload]`-framed message. +pub(super) async fn write_one_message( + pipe: &mut NamedPipeServer, + payload: &[u8], +) -> anyhow::Result<()> { + let length = u32::try_from(payload.len()) + .map_err(|err| anyhow::anyhow!("payload too large to frame: {err}"))?; + pipe.write_all(&length.to_le_bytes()).await?; + pipe.write_all(payload).await?; + pipe.flush().await?; + Ok(()) +} + +/// Repeatedly create a pipe instance and wait for a client to connect, +/// backing off on transient creation failures. Shared by the +/// server-lifetime command pipe and each job's data pipe — both need the +/// exact same "create, back off on failure, wait for connect" sequence, +/// only the pipe name differs. +pub(super) async fn accept_connection( + pipe_name: &str, + first_instance: &mut bool, +) -> NamedPipeServer { + loop { + let pipe = match create_server(pipe_name, *first_instance) { + Ok(pipe) => pipe, + Err(err) => { + tracing::warn!(error = %err, pipe_name, "pipe instance unavailable; retrying shortly"); + tokio::time::sleep(PIPE_RETRY_BACKOFF).await; + continue; + } + }; + *first_instance = false; + if pipe.connect().await.is_ok() { + return pipe; + } + } +} + +/// Render a `job_id` as a short hex string for logging. +pub(super) fn hex_job_id(job_id: [u8; 16]) -> String { + use core::fmt::Write as _; + job_id + .iter() + .fold(String::with_capacity(32), |mut out, byte| { + #[expect( + clippy::let_underscore_must_use, + reason = "String::write_fmt never fails" + )] + let _ = write!(out, "{byte:02x}"); + out + }) +} diff --git a/crates/uffs-content/src/serve/stream.rs b/crates/uffs-content/src/serve/stream.rs new file mode 100644 index 000000000..1d6cfbcf2 --- /dev/null +++ b/crates/uffs-content/src/serve/stream.rs @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Per-job streaming task: owns the data-pipe connection for exactly +//! one job, groups its frames by candidate (file-boundary resume — see +//! design-doc §6.5/§9.4), and paces emission through a +//! [`crate::job::window::WindowTracker`]. +//! +//! # Why the accept loop lives *with* the job, not as a separate +//! always-on server +//! +//! A data-pipe disconnect mid-candidate must not corrupt the stream: the +//! only correct move is to stop, wait for a fresh connection, and +//! restart that candidate from its `FILE_BEGIN` (never send a partial +//! candidate split across two connections). Owning the accept loop here +//! means "waiting for a (re)connection" and "waiting for send-window +//! budget" are the same kind of pause, handled by the same loop, instead +//! of needing a separate always-on data-pipe server to coordinate with +//! whichever job happens to be active. +//! +//! # v1 simplifications, documented rather than silent +//! +//! - No incremental production: [`crate::job::vss_job::run_vss_job`] already +//! builds the whole job's frames synchronously before this task starts pacing +//! them out. A future revision that streams while reading would let a very +//! large job start delivering bytes sooner, but does not change the +//! resume/backpressure contract this module implements. +//! - Window size is a fixed default, not negotiated per job — see +//! [`DEFAULT_WINDOW_BYTES`]. + +use alloc::sync::Arc; +use std::collections::HashMap; +use std::path::PathBuf; + +use tokio::net::windows::named_pipe::NamedPipeServer; +use tokio::sync::mpsc; +use uffs_content_protocol::DATA_PIPE_NAME; +use uffs_content_protocol::frame::{FrameEnvelope, FrameType}; + +use super::{ActiveJob, ControlSignal, ServerState, pipe_io}; +use crate::job::intake::JobRequest; +use crate::job::vss_job::run_vss_job; +use crate::job::window::WindowTracker; + +/// Default per-job send-window budget (design-doc §13.1 +/// `max_unacknowledged_bytes`). Not yet negotiated per job with the +/// consumer (`JOB_SUBMIT`'s payload is just the job spec JSON today) — +/// a fixed, generous default until per-job negotiation is worth adding. +const DEFAULT_WINDOW_BYTES: u64 = 16 * 1024 * 1024; + +/// Spawn the streaming task for a freshly submitted job. +/// +/// The producer, not the consumer, assigns the real `job_id`: +/// [`run_vss_job`] already generates a fresh one internally, matching +/// every other call site in this crate, and there is no reason to plumb +/// an externally-chosen id through that already-real, +/// already-validated-on-hardware function just to satisfy a wire +/// nicety. The consumer learns the real `job_id` from `JOB_BEGIN`, the +/// first frame on the data pipe. +pub(super) fn spawn(state: Arc, request: JobRequest, run_dir: PathBuf) { + tokio::spawn(async move { + if let Err(err) = run(&state, request, &run_dir).await { + tracing::error!(error = %err, "job streaming task failed"); + } + }); +} + +/// Async body of [`spawn`]. +async fn run( + state: &Arc, + request: JobRequest, + run_dir: &std::path::Path, +) -> anyhow::Result<()> { + let run_dir_owned = run_dir.to_path_buf(); + let outcome = tokio::task::spawn_blocking(move || run_vss_job(&request, &run_dir_owned)) + .await + .map_err(|err| anyhow::anyhow!("streaming task panicked: {err}"))??; + + let job_id = outcome.job_id; + let candidate_ids: Vec = (1..=outcome.run_summary.candidate_count).collect(); + state.registry.register(job_id, candidate_ids); + + let (control_tx, mut control_rx) = mpsc::channel(32); + set_active(state, Some(ActiveJob { job_id, control_tx })); + + let grouped = group_frames_by_candidate(&outcome.frames); + let mut window = WindowTracker::new(DEFAULT_WINDOW_BYTES); + + serve_data_pipe(state, job_id, &grouped, &mut control_rx, &mut window).await; + set_active(state, None); + state.registry.remove(job_id); + Ok(()) +} + +/// Outer accept loop: (re)connect the data pipe and stream `job_id`'s +/// frames over it until the job completes or is terminated. Reconnects +/// transparently on a write failure — the only way a partial candidate +/// mid-connection gets resolved is a fresh connection restarting that +/// candidate from its `FILE_BEGIN` (see the module doc's "why the accept +/// loop lives with the job" section). +async fn serve_data_pipe( + state: &Arc, + job_id: [u8; 16], + grouped: &Grouped, + control_rx: &mut mpsc::Receiver, + window: &mut WindowTracker, +) { + let mut first_instance = true; + loop { + let mut pipe = pipe_io::accept_connection(DATA_PIPE_NAME, &mut first_instance).await; + tracing::info!(job_id = %pipe_io::hex_job_id(job_id), "consumer connected on data pipe"); + + if pipe_io::write_one_message(&mut pipe, &grouped.job_begin) + .await + .is_err() + { + continue; + } + + match stream_over_connection(&mut pipe, state, job_id, grouped, control_rx, window).await { + ConnectionOutcome::Reconnect => {} + ConnectionOutcome::JobComplete | ConnectionOutcome::Terminated => return, + } + } +} + +/// What ended one data-pipe connection's streaming loop. +enum ConnectionOutcome { + /// Every candidate was acked and `JOB_END` was sent (best-effort). + JobComplete, + /// The consumer cancelled, or the control channel died — nothing + /// further to send or wait for. + Terminated, + /// The connection dropped mid-stream; the caller should accept a + /// fresh one and resume from wherever the registry says is pending. + Reconnect, +} + +/// Stream `job_id`'s not-yet-acked candidates over `pipe` until it +/// completes, is terminated, or the connection itself fails. +async fn stream_over_connection( + pipe: &mut NamedPipeServer, + state: &Arc, + job_id: [u8; 16], + grouped: &Grouped, + control_rx: &mut mpsc::Receiver, + window: &mut WindowTracker, +) -> ConnectionOutcome { + loop { + if state.registry.is_complete(job_id) == Some(true) { + if let Err(err) = pipe_io::write_one_message(pipe, &grouped.job_end).await { + tracing::warn!(error = %err, job_id = %pipe_io::hex_job_id(job_id), "failed to send JOB_END"); + } + return ConnectionOutcome::JobComplete; + } + let (group, group_bytes) = + match next_group_to_send(state, job_id, grouped, control_rx, window).await { + Ok(pair) => pair, + Err(outcome) => return outcome, + }; + if send_group(pipe, group).await.is_err() { + tracing::info!( + job_id = %pipe_io::hex_job_id(job_id), + "data pipe write failed; waiting for reconnect" + ); + return ConnectionOutcome::Reconnect; + } + window.record_sent(group_bytes); + } +} + +/// Pick the next not-yet-acked candidate's frame group, waiting on +/// window budget and control signals (`WINDOW_UPDATE`/`FILE_ACK`) for as +/// long as nothing is ready to send yet. +async fn next_group_to_send<'grouped>( + state: &Arc, + job_id: [u8; 16], + grouped: &'grouped Grouped, + control_rx: &mut mpsc::Receiver, + window: &mut WindowTracker, +) -> Result<(&'grouped [Vec], u64), ConnectionOutcome> { + loop { + let Some(candidate_id) = state + .registry + .pending(job_id) + .and_then(|pending| pending.into_iter().next()) + else { + // Nothing left to send this connection (a resume race, or + // every candidate already sent) — wait for an ack that + // completes the job, or a cancel, before re-checking. + wait_for_progress(control_rx, state, job_id, window).await?; + continue; + }; + let Some(group) = grouped.by_candidate.get(&candidate_id) else { + // A candidate id the manifest never actually produced frames + // for (shouldn't happen — every registered id comes from the + // same manifest — but fail safe rather than looping forever + // on it). + tracing::warn!(candidate_id, "no frame group for pending candidate id"); + state.registry.ack(job_id, candidate_id); + continue; + }; + let group_bytes: u64 = group.iter().map(|frame| frame.len() as u64).sum(); + while !window.can_admit(group_bytes) { + wait_for_progress(control_rx, state, job_id, window).await?; + } + return Ok((group, group_bytes)); + } +} + +/// Wait for and apply one control signal, collapsing the two "streaming +/// cannot continue" cases ([`SignalOutcome::Cancelled`] and +/// [`SignalOutcome::ControlChannelClosed`]) into a single `Err` the +/// caller propagates via `?`/`return`. +async fn wait_for_progress( + control_rx: &mut mpsc::Receiver, + state: &Arc, + job_id: [u8; 16], + window: &mut WindowTracker, +) -> Result<(), ConnectionOutcome> { + match apply_next_signal(control_rx, state, job_id, window).await { + SignalOutcome::Applied => Ok(()), + SignalOutcome::Cancelled | SignalOutcome::ControlChannelClosed => { + Err(ConnectionOutcome::Terminated) + } + } +} + +/// Write every frame in one candidate's group, in order. +async fn send_group(pipe: &mut NamedPipeServer, group: &[Vec]) -> anyhow::Result<()> { + for frame in group { + pipe_io::write_one_message(pipe, frame).await?; + } + Ok(()) +} + +/// What happened when [`apply_next_signal`] waited for and applied one +/// [`ControlSignal`]. +enum SignalOutcome { + /// A `WindowGrant` or `FileAcked` signal was applied; the caller + /// should re-check its own loop condition (window budget, pending + /// candidates) since state just changed. + Applied, + /// The consumer sent `JOB_CANCEL`. + Cancelled, + /// The control channel closed — the command pipe's dispatcher (and + /// with it, this job's only path to further acks/cancellation) is + /// gone. + ControlChannelClosed, +} + +/// Block until one [`ControlSignal`] arrives and apply it: a +/// `WindowGrant` raises `window`'s ceiling, a `FileAcked` updates the +/// registry, a `Cancel` is reported (not applied here — the caller owns +/// job teardown). +async fn apply_next_signal( + control_rx: &mut mpsc::Receiver, + state: &Arc, + job_id: [u8; 16], + window: &mut WindowTracker, +) -> SignalOutcome { + let Some(signal) = control_rx.recv().await else { + return SignalOutcome::ControlChannelClosed; + }; + match signal { + ControlSignal::WindowGrant(additional_bytes) => { + window.grant(additional_bytes); + SignalOutcome::Applied + } + ControlSignal::FileAcked(candidate_id) => { + state.registry.ack(job_id, candidate_id); + SignalOutcome::Applied + } + ControlSignal::Cancel(reason) => { + tracing::info!(job_id = %pipe_io::hex_job_id(job_id), reason, "job cancelled by consumer"); + SignalOutcome::Cancelled + } + } +} + +/// Group `frames` into the leading `JOB_BEGIN`, one frame list per +/// candidate (in manifest order), and the trailing `JOB_END` — +/// `run_job`'s own emission order (`push_frame(FrameType::JobBegin, ..)` +/// first, `push_frame(FrameType::JobEnd, ..)` last, every per-candidate +/// frame group in between starting with `FILE_BEGIN`) makes this a +/// single linear pass. +struct Grouped { + /// The job's `JOB_BEGIN` frame, ready to send as-is. + job_begin: Vec, + /// Every other frame, bucketed by the `candidate_id` it belongs to, + /// in manifest emission order. + by_candidate: HashMap>>, + /// The job's `JOB_END` frame, ready to send as-is. + job_end: Vec, +} + +/// Split `frames` (one job's complete, already-produced frame list) into +/// [`Grouped`]'s three buckets: the leading `JOB_BEGIN`, one frame group +/// per candidate, and the trailing `JOB_END`. +fn group_frames_by_candidate(frames: &[Vec]) -> Grouped { + let mut by_candidate: HashMap>> = HashMap::new(); + let mut job_begin = Vec::new(); + let mut job_end = Vec::new(); + let mut current: Option = None; + + for frame_bytes in frames { + let mut reader = uffs_content_protocol::codec::Reader::new(frame_bytes); + let Ok((envelope, payload)) = FrameEnvelope::decode(&mut reader, u64::MAX) else { + continue; + }; + match envelope.frame_type { + FrameType::JobBegin => job_begin.clone_from(frame_bytes), + FrameType::JobEnd => job_end.clone_from(frame_bytes), + FrameType::FileBegin => { + let mut payload_reader = uffs_content_protocol::codec::Reader::new(&payload); + if let Ok(file_begin) = + uffs_content_protocol::frame::FileBegin::decode(&mut payload_reader) + { + current = Some(file_begin.candidate_id); + by_candidate + .entry(file_begin.candidate_id) + .or_default() + .push(frame_bytes.clone()); + } + } + FrameType::ContentChunk + | FrameType::FileEnd + | FrameType::FileFailed + | FrameType::FileDeferred + | FrameType::FileAck + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobCancel + | FrameType::WindowUpdate + | FrameType::JobResume + | FrameType::JobSubmit => { + if let Some(candidate_id) = current { + by_candidate + .entry(candidate_id) + .or_default() + .push(frame_bytes.clone()); + } + } + } + } + + Grouped { + job_begin, + by_candidate, + job_end, + } +} + +/// Set (or clear) the server's single active-job slot. +fn set_active(state: &Arc, active: Option) { + let mut slot = state + .active + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = active; +} diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index 8ab7e201c..bac5f8719 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -40,6 +40,8 @@ use anyhow as _; use serde as _; use serde_json as _; #[cfg(windows)] +use tokio as _; +#[cfg(windows)] use tracing as _; #[cfg(windows)] use uffs_broker_protocol as _; @@ -47,6 +49,8 @@ use uffs_broker_protocol as _; use uffs_client as _; #[cfg(windows)] use uffs_content_reader_protocol as _; +#[cfg(windows)] +use uffs_security as _; use uffs_version as _; use uuid as _; diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 9b9d49a5f..49f71dc1e 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -51,6 +51,8 @@ use serde_json as _; #[cfg(not(windows))] use tempfile as _; #[cfg(windows)] +use tokio as _; +#[cfg(windows)] use tracing as _; #[cfg(windows)] use uffs_broker_protocol as _; @@ -61,6 +63,8 @@ use uffs_content as _; use uffs_content_protocol as _; #[cfg(windows)] use uffs_content_reader_protocol as _; +#[cfg(windows)] +use uffs_security as _; use uffs_version as _; use uuid as _; From a9623964202d14ad98da0725ddac1d79864f7140 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:51:28 -0700 Subject: [PATCH 48/98] fix(content): fix RAM materialization and frame-size ceiling bugs workflow::run_job collected every frame of a job into one in-memory Vec before streaming began, so peak memory scaled with total matched content rather than any bounded window. It now takes an emit_frame callback and streams frames as they're produced; run_vss_job runs on a blocking thread forwarding through a small bounded channel to the async streaming task, which buffers only not-yet-acked candidates and evicts on FILE_ACK. Per-file digests are now computed incrementally (IncrementalDigest) instead of buffering a whole file just to hash it. serve/pipe_io::MAX_MESSAGE_BYTES (64 KiB) was smaller than the actual worst-case frame (a full CONTENT_CHUNK, or a FILE_BEGIN with a maximum-length path) a producer can emit, which would have rejected real files >=64 KiB once a spec-compliant consumer existed. It's now derived from both worst cases plus a margin, locked by two unit tests. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-protocol/src/codec.rs | 61 +- crates/uffs-content/src/job/self_test.rs | 18 +- crates/uffs-content/src/job/tests.rs | 7 +- crates/uffs-content/src/job/vss_job.rs | 19 +- crates/uffs-content/src/job/workflow.rs | 247 +++++--- crates/uffs-content/src/serve/pipe_io.rs | 129 +++- crates/uffs-content/src/serve/stream.rs | 557 ++++++++++++++---- .../tests/e2e_dir_walk_parity_fake_reader.rs | 7 +- 8 files changed, 853 insertions(+), 192 deletions(-) diff --git a/crates/uffs-content-protocol/src/codec.rs b/crates/uffs-content-protocol/src/codec.rs index b3e45c7a8..0e198cc93 100644 --- a/crates/uffs-content-protocol/src/codec.rs +++ b/crates/uffs-content-protocol/src/codec.rs @@ -376,10 +376,51 @@ pub fn digest(bytes: &[u8]) -> Digest { *blake3::hash(bytes).as_bytes() } +/// Incremental variant of [`digest`]: the same plain, unkeyed BLAKE3-256 +/// contract, computed over bytes fed in one or more calls to +/// [`IncrementalDigest::update`] instead of one contiguous buffer. +/// +/// Exists so a caller streaming a file in bounded chunks (e.g. this +/// crate's own `CONTENT_CHUNK` producer) can compute `FILE_END`'s +/// `content_digest` without buffering the whole file's bytes just to +/// call [`digest`] once at the end — for a large file, that buffering +/// is the difference between bounded, chunk-sized memory use and memory +/// proportional to the file's full size. +#[derive(Debug, Default)] +pub struct IncrementalDigest { + /// The running BLAKE3 state. + hasher: blake3::Hasher, +} + +impl IncrementalDigest { + /// A fresh hasher with no bytes fed yet. + #[must_use] + pub fn new() -> Self { + Self { + hasher: blake3::Hasher::new(), + } + } + + /// Feed more bytes into the running hash, in order. + pub fn update(&mut self, bytes: &[u8]) { + self.hasher.update(bytes); + } + + /// Finalize and return the digest over every byte fed so far. + /// + /// Takes `&self`, not `self`, matching `blake3::Hasher::finalize`'s + /// own shape — finalizing does not consume the hasher, though this + /// crate's callers only ever finalize once per instance in practice. + #[must_use] + pub fn finalize(&self) -> Digest { + *self.hasher.finalize().as_bytes() + } +} + #[cfg(test)] mod tests { use super::{ - DecodeError, Reader, checksum32, digest, write_bytes_u16_prefixed, + DecodeError, IncrementalDigest, Reader, checksum32, digest, write_bytes_u16_prefixed, write_bytes_u32_prefixed, write_i64_le, write_u16_le, write_u32_le, write_u64_le, }; @@ -556,6 +597,24 @@ mod tests { assert_eq!(&via_this_crate, via_plain_blake3.as_bytes()); } + #[test] + fn incremental_digest_matches_one_shot_digest_over_the_same_bytes() { + let content = b"some file content, split across several chunks"; + let one_shot = digest(content); + + let mut incremental = IncrementalDigest::new(); + for chunk in content.chunks(7) { + incremental.update(chunk); + } + assert_eq!(incremental.finalize(), one_shot); + } + + #[test] + fn incremental_digest_of_no_bytes_matches_digest_of_empty_slice() { + let incremental = IncrementalDigest::new(); + assert_eq!(incremental.finalize(), digest(b"")); + } + #[test] fn reader_position_and_remaining_track_consumption() { let mut reader = Reader::new(&[0_u8; 10]); diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index faa937aad..523bfde6c 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -57,7 +57,12 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { ..Default::default() }; - let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; + let mut frames = Vec::new(); + let outcome = run_vss_job(&request, &run_dir, |frame| { + frames.push(frame); + Ok(()) + }) + .context("run_vss_job failed")?; anyhow::ensure!( outcome.run_summary.candidate_count == 1, @@ -74,7 +79,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { outcome.run_summary.deferred_manual_count ); - let played_back = decode_single_file_content(&outcome.manifest_bytes, &outcome.frames) + let played_back = decode_single_file_content(&outcome.manifest_bytes, &frames) .context("failed to decode the job's own manifest/frame output")?; anyhow::ensure!( played_back == content, @@ -143,7 +148,12 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> ..Default::default() }; - let outcome = run_vss_job(&request, &run_dir).context("run_vss_job failed")?; + let mut frames = Vec::new(); + let outcome = run_vss_job(&request, &run_dir, |frame| { + frames.push(frame); + Ok(()) + }) + .context("run_vss_job failed")?; if outcome.run_summary.candidate_count != ground_truth_count { let pipeline_paths = decode_candidate_paths(&outcome.manifest_bytes, root) @@ -167,7 +177,7 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> outcome.run_summary.deferred_manual_count ); - let summary = summarize_query_outcome(&outcome.manifest_bytes, &outcome.frames) + let summary = summarize_query_outcome(&outcome.manifest_bytes, &frames) .context("failed to decode the job's own manifest/frame output")?; anyhow::ensure!( summary.metadata_total_bytes == ground_truth_bytes, diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 9cd876918..e4b5dcb68 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -142,11 +142,16 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { ..Default::default() }; + let mut frames = Vec::new(); let outcome = run_job( &request, &DirWalkCandidateSource, &FsContentSource, run_dir.path(), + |frame| { + frames.push(frame); + Ok(()) + }, ) .expect("run_job must succeed"); @@ -161,7 +166,7 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { // JOB_BEGIN, then (FILE_BEGIN, [CONTENT_CHUNK]*, FILE_END) per // candidate, then JOB_END. let mut decoded_types = Vec::new(); - for frame_bytes in &outcome.frames { + for frame_bytes in &frames { let mut reader = Reader::new(frame_bytes); let (envelope, _payload) = FrameEnvelope::decode(&mut reader, u64::MAX).expect("decode frame envelope"); diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index 3401d51b6..bc3ccbf1e 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -28,12 +28,19 @@ use super::workflow::{JobOutcome, run_job}; /// Run `request` end to end against a real VSS snapshot. /// +/// Every encoded frame is passed to `emit_frame` as soon as it's +/// produced — see [`run_job`]'s own doc comment for why this is a +/// callback rather than a returned `Vec`. +/// /// # Errors /// Returns an error if any VSS lease, ephemeral daemon spawn, or /// content Reader spawn step fails, or if the underlying `run_job` call /// fails. Every resource successfully acquired before a failure is /// released best-effort before returning. -pub fn run_vss_job(request: &JobRequest, run_dir: &Path) -> Result { +pub fn run_vss_job(request: &JobRequest, run_dir: &Path, emit_frame: F) -> Result +where + F: FnMut(Vec) -> std::io::Result<()>, +{ let job_id = *uuid::Uuid::new_v4().as_bytes(); let ephemeral_id = uuid::Uuid::new_v4().simple().to_string(); @@ -60,8 +67,14 @@ pub fn run_vss_job(request: &JobRequest, run_dir: &Path) -> Result { .context("failed to spawn the content reader")?; let content_source = VssContentSource::new(content_reader); - let result = - run_job(request, &candidate_source, &content_source, run_dir).context("run_job failed"); + let result = run_job( + request, + &candidate_source, + &content_source, + run_dir, + emit_frame, + ) + .context("run_job failed"); // Drop the candidate source first (releases its borrow of // `resources.daemon`, which `resources.teardown()` below needs to diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index cd7bb48a1..62c897467 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -8,11 +8,26 @@ //! [`CandidateSource`]/[`ContentSource`] it's given are swappable; see //! those traits' docs for what "swappable" means today (a real vs. fake //! backing). +//! +//! # Why `emit_frame` is a callback, not a returned `Vec` +//! +//! Earlier revisions of this function collected every emitted frame into +//! one `Vec>` and returned it once the whole job finished — for a +//! job matching many/large files, that meant peak memory proportional to +//! the job's *entire* logical content, held before a single byte reached +//! any consumer. Emitting each frame through a caller-supplied callback as +//! soon as it's produced removes that ceiling: a caller that wants the old +//! all-in-memory behavior (this crate's own tests, `self_test`) can still +//! collect into a `Vec` via a trivial closure, while the real production +//! caller (`crate::serve::stream`) forwards frames onto a bounded channel +//! and paces them out under backpressure, so memory stays bounded near the +//! send-window size rather than the job size — see that module's own doc +//! comment for the consumer side of this. use std::io; use std::path::Path; -use uffs_content_protocol::codec::{Digest, digest}; +use uffs_content_protocol::codec::{Digest, IncrementalDigest, digest}; use uffs_content_protocol::error::ErrorCode; use uffs_content_protocol::frame::{ ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, @@ -35,17 +50,15 @@ use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; /// concern, not something this workflow needs to get "right" yet. pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 64 * 1024; -/// Everything one completed job produced. +/// Everything one completed job produced, aside from the frames +/// themselves (see the module doc for why those are emitted through a +/// callback instead of collected here). #[derive(Debug, Clone, PartialEq, Eq)] pub struct JobOutcome { /// Job identifier assigned to this run. pub job_id: [u8; 16], /// The finalized manifest's encoded bytes. pub manifest_bytes: Vec, - /// Every frame this job emitted, in emission order, each already - /// wrapped in its `FrameEnvelope` — exactly the bytes a consumer - /// would receive over the wire. - pub frames: Vec>, /// The finalized run summary. pub run_summary: RunSummary, } @@ -54,18 +67,27 @@ pub struct JobOutcome { /// a manifest, stream every candidate's content via `content_source`, and /// finalize the run's summary/failure log under `run_dir`. /// +/// Every encoded frame (`JOB_BEGIN`, then per-candidate frames, then +/// `JOB_END`) is passed to `emit_frame` in emission order as soon as it +/// exists — see the module doc comment. +/// /// # Errors /// Returns an [`io::Error`] for any filesystem failure enumerating -/// candidates, writing the failure log, or finalizing the summary. A -/// per-candidate content-read failure is *not* an error return — it's -/// recorded as a `FAILED_RETRYABLE` outcome for that candidate instead -/// (a [`FileFailed`] frame plus a [`FailureRecord`]). -pub fn run_job( +/// candidates, writing the failure log, or finalizing the summary, or +/// propagated from `emit_frame` itself (e.g. a downstream transport +/// failure). A per-candidate content-read failure is *not* an error +/// return — it's recorded as a `FAILED_RETRYABLE` outcome for that +/// candidate instead (a [`FileFailed`] frame plus a [`FailureRecord`]). +pub fn run_job( request: &JobRequest, candidate_source: &dyn CandidateSource, content_source: &dyn ContentSource, run_dir: &Path, -) -> io::Result { + mut emit_frame: F, +) -> io::Result +where + F: FnMut(Vec) -> io::Result<()>, +{ let job_id = *uuid::Uuid::new_v4().as_bytes(); let source_id = source_id_bytes(&request.source_id); // No query filtering is wired up yet (see `JobRequest` docs) — every @@ -78,19 +100,7 @@ pub fn run_job( let built = build_manifest(job_id, source_id, query_digest, &entries) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; - let mut frames = Vec::new(); let mut frame_sequence: u64 = 0; - let mut push_frame = |frame_type: FrameType, payload: &[u8]| { - let envelope = FrameEnvelope { - protocol_version: 2, - frame_type, - flags: 0, - job_id, - frame_sequence, - }; - frame_sequence += 1; - frames.push(envelope.encode(payload)); - }; let job_begin = JobBegin { job_id, @@ -106,7 +116,12 @@ pub fn run_job( max_chunk_bytes: DEFAULT_MAX_CHUNK_BYTES, max_content_delivery_bytes: None, }; - push_frame(FrameType::JobBegin, &job_begin.encode()); + emit_frame(encode_frame( + job_id, + &mut frame_sequence, + FrameType::JobBegin, + &job_begin.encode(), + ))?; let mut counters = RunCounters::new(candidate_count); let run_id = uuid::Uuid::from_bytes(job_id).to_string(); @@ -114,17 +129,17 @@ pub fn run_job( let mut failure_log = FailureLogWriter::open(&failures_path)?; for (entry, &candidate_id) in entries.iter().zip(&built.candidate_ids) { - let candidate_frames = stream_one_candidate( + stream_one_candidate( entry, candidate_id, content_source, DEFAULT_MAX_CHUNK_BYTES, &mut counters, &mut failure_log, + job_id, + &mut frame_sequence, + &mut emit_frame, )?; - for (frame_type, payload) in &candidate_frames { - push_frame(*frame_type, payload); - } } drop(failure_log); @@ -161,7 +176,12 @@ pub fn run_job( let job_end_bytes = job_end .encode() .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; - push_frame(FrameType::JobEnd, &job_end_bytes); + emit_frame(encode_frame( + job_id, + &mut frame_sequence, + FrameType::JobEnd, + &job_end_bytes, + ))?; let now_ms = unix_ms_now(); let summary_path = run_dir.join(format!("run-{run_id}.summary.json")); @@ -173,15 +193,40 @@ pub fn run_job( Ok(JobOutcome { job_id, manifest_bytes: built.bytes, - frames, run_summary, }) } -/// Streams one candidate's content and returns the `(FrameType, payload)` -/// pairs it produced (`FILE_BEGIN`, zero or more `CONTENT_CHUNK`s, then -/// exactly one of `FILE_END`/`FILE_FAILED`), updating `counters` and -/// appending to `failure_log` for a non-success outcome. +/// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and +/// advancing the next `frame_sequence`. +fn encode_frame( + job_id: [u8; 16], + frame_sequence: &mut u64, + frame_type: FrameType, + payload: &[u8], +) -> Vec { + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id, + frame_sequence: *frame_sequence, + }; + *frame_sequence += 1; + envelope.encode(payload) +} + +/// Streams one candidate's content, emitting `FILE_BEGIN`, zero or more +/// `CONTENT_CHUNK`s, and exactly one of `FILE_END`/`FILE_FAILED` through +/// `emit_frame` as each is produced — never buffering more than one +/// chunk's worth of this candidate's content at a time. Updates +/// `counters` and appends to `failure_log` for a non-success outcome. +#[expect( + clippy::too_many_arguments, + reason = "the alternative is a bespoke context struct bundling job_id/frame_sequence/ \ + emit_frame purely to satisfy this lint, for a private helper with exactly one \ + call site; not worth the indirection" +)] fn stream_one_candidate( entry: &CandidateEntry, candidate_id: u64, @@ -189,8 +234,10 @@ fn stream_one_candidate( max_chunk_bytes: u32, counters: &mut RunCounters, failure_log: &mut FailureLogWriter, -) -> io::Result)>> { - let mut out = Vec::new(); + job_id: [u8; 16], + frame_sequence: &mut u64, + emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, +) -> io::Result<()> { let path = WindowsPath::from_str_lossless(&entry.relative_path.to_string_lossy()); let file_begin = FileBegin { @@ -203,51 +250,41 @@ fn stream_one_candidate( attempt_number: 1, content_object_id: None, }; - out.push((FrameType::FileBegin, file_begin.encode())); - - let mut buffered = Vec::with_capacity(usize::try_from(entry.logical_size).unwrap_or(0)); - let mut offset = 0_u64; - let mut chunk_sequence = 0_u64; - let mut read_error = None; + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileBegin, + &file_begin.encode(), + ))?; - while offset < entry.logical_size { - match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { - Ok(bytes) if bytes.is_empty() => break, - Ok(bytes) => { - let read_len = len_as_u64(bytes.len()); - buffered.extend_from_slice(&bytes); - let chunk = ContentChunk { - candidate_id, - chunk_sequence, - logical_offset: offset, - logical_length: read_len, - payload: bytes, - }; - out.push((FrameType::ContentChunk, chunk.encode())); - offset += read_len; - chunk_sequence += 1; - } - Err(err) => { - read_error = Some(err); - break; - } - } - } + let (chunk_count, total_read, content_digest, read_error) = stream_content_chunks( + entry, + candidate_id, + content_source, + max_chunk_bytes, + job_id, + frame_sequence, + emit_frame, + )?; match read_error { None => { - let content_digest = digest(&buffered); let file_end = FileEnd { candidate_id, - total_logical_bytes: len_as_u64(buffered.len()), + total_logical_bytes: total_read, content_digest: Some(content_digest), read_mode: ReadMode::LogicalSnapshot, - chunk_count: chunk_sequence, + chunk_count, elapsed_ms: 0, warning_flags: 0, }; - out.push((FrameType::FileEnd, file_end.encode())); - counters.record_succeeded(len_as_u64(buffered.len())); + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileEnd, + &file_end.encode(), + ))?; + counters.record_succeeded(total_read); } Some(err) => { let os_error_code = err.raw_os_error().map(i64::from); @@ -259,10 +296,15 @@ fn stream_one_candidate( error_code: ErrorCode::ReadIoTransient, os_error_code, retry_class: RetryClass::RetryNewSnapshot, - bytes_emitted_before_failure: len_as_u64(buffered.len()), + bytes_emitted_before_failure: total_read, message: message.clone(), }; - out.push((FrameType::FileFailed, file_failed.encode())); + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileFailed, + &file_failed.encode(), + ))?; counters.record_failed_retryable(); failure_log.append(&FailureRecord::failed( candidate_id, @@ -271,13 +313,68 @@ fn stream_one_candidate( ErrorCode::ReadIoTransient, os_error_code, RetryClass::RetryNewSnapshot, - len_as_u64(buffered.len()), + total_read, message, ))?; } } - Ok(out) + Ok(()) +} + +/// Read and emit every `CONTENT_CHUNK` frame for one candidate, in +/// order, up to `entry.logical_size` or the first read error — never +/// buffering more than one chunk's worth of content at a time (the +/// running digest is incremental; see [`IncrementalDigest`]). +/// +/// Returns `(chunk_count, total_read, digest, read_error)`; `read_error` +/// is `Some` only if a read failed partway, letting the caller decide +/// the candidate's terminal outcome. +fn stream_content_chunks( + entry: &CandidateEntry, + candidate_id: u64, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, + job_id: [u8; 16], + frame_sequence: &mut u64, + emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, +) -> io::Result<(u64, u64, Digest, Option)> { + let mut hasher = IncrementalDigest::new(); + let mut offset = 0_u64; + let mut chunk_sequence = 0_u64; + let mut total_read = 0_u64; + let mut read_error = None; + + while offset < entry.logical_size { + match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { + Ok(bytes) if bytes.is_empty() => break, + Ok(bytes) => { + let read_len = len_as_u64(bytes.len()); + hasher.update(&bytes); + total_read += read_len; + let chunk = ContentChunk { + candidate_id, + chunk_sequence, + logical_offset: offset, + logical_length: read_len, + payload: bytes, + }; + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::ContentChunk, + &chunk.encode(), + ))?; + offset += read_len; + chunk_sequence += 1; + } + Err(err) => { + read_error = Some(err); + break; + } + } + } + Ok((chunk_sequence, total_read, hasher.finalize(), read_error)) } /// Deterministically derives a manifest `source_id` from an arbitrary diff --git a/crates/uffs-content/src/serve/pipe_io.rs b/crates/uffs-content/src/serve/pipe_io.rs index 4442beed0..291cba970 100644 --- a/crates/uffs-content/src/serve/pipe_io.rs +++ b/crates/uffs-content/src/serve/pipe_io.rs @@ -12,10 +12,62 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}; -/// Maximum single framed message size accepted on either pipe — a -/// generous ceiling matching this codebase's other narrow private IPC -/// APIs (the Broker's Snapshot Manager pipe, the content Reader's pipe). -pub(super) const MAX_MESSAGE_BYTES: u32 = 64 * 1024; +/// Fixed overhead every encoded frame carries before its payload: the +/// 48-byte envelope header plus its two `u32` checksums (see +/// `uffs_content_protocol::frame::FrameEnvelope`'s own doc comment). +const FRAME_ENVELOPE_OVERHEAD_BYTES: u32 = 56; + +/// `ContentChunk`'s own fixed fields plus its payload's `u32` length +/// prefix, preceding the chunk payload itself: `candidate_id`(8) + +/// `chunk_sequence`(8) + `logical_offset`(8) + `logical_length`(8) + +/// length-prefix(4) = 36. +const CONTENT_CHUNK_FIXED_OVERHEAD_BYTES: u32 = 36; + +/// `FileBegin`'s own fixed fields plus a `WindowsPath`'s own encoding +/// byte + `u32` length prefix, preceding the path bytes themselves: +/// `candidate_id`(8) + `file_reference`(8) + path-encoding(1) + +/// path-length-prefix(4) + `logical_size`(8) + `mtime`(8) + +/// `read_mode`(1) + `attempt_number`(4) + `content_object_id`(1 + 8 +/// worst case) = 51. +const FILE_BEGIN_FIXED_OVERHEAD_BYTES: u32 = 51; + +/// Extra headroom absorbing a small future addition to any frame's fixed +/// fields (e.g. one more optional field) without silently reintroducing +/// the exact ceiling-vs-payload mismatch this constant's derivation +/// exists to prevent. +const FRAME_SIZE_SAFETY_MARGIN_BYTES: u32 = 4096; + +/// `a` if greater, else `b` — `u32::max` as a `const fn`, spelled out +/// directly rather than relying on `Ord::max`'s const-stability version +/// (this workspace's pinned toolchain predates it becoming reliably +/// const across all the targets this crate builds for). +const fn max_u32(lhs: u32, rhs: u32) -> u32 { + if lhs > rhs { lhs } else { rhs } +} + +/// Maximum single framed message size accepted on either pipe. +/// +/// Must comfortably exceed the largest frame this crate can actually +/// produce, so a spec-compliant consumer reader never rejects a +/// legitimate frame. The two candidates for "largest frame" are a full +/// `CONTENT_CHUNK` (`crate::job::workflow::DEFAULT_MAX_CHUNK_BYTES` +/// payload bytes) and a `FILE_BEGIN` carrying a maximum-length path +/// (`uffs_content_protocol::manifest::MAX_PATH_BYTES`) — this ceiling is +/// derived from both plus a safety margin specifically so the three +/// constants can never silently drift out of sync again. A prior version +/// of this constant was a bare `64 * 1024`, smaller than either worst +/// case by construction — any file whose content reached the (also +/// `64 * 1024`) default max chunk size, or any path near the protocol's +/// own 32,767-UTF-16-code-unit maximum, would have produced a frame a +/// consumer built to this same ceiling would reject outright. +pub(super) const MAX_MESSAGE_BYTES: u32 = max_u32( + FRAME_ENVELOPE_OVERHEAD_BYTES + + CONTENT_CHUNK_FIXED_OVERHEAD_BYTES + + crate::job::workflow::DEFAULT_MAX_CHUNK_BYTES, + FRAME_ENVELOPE_OVERHEAD_BYTES + + FILE_BEGIN_FIXED_OVERHEAD_BYTES + + uffs_content_protocol::manifest::MAX_PATH_BYTES, +) + FRAME_SIZE_SAFETY_MARGIN_BYTES; /// How long to back off before retrying pipe-instance creation after a /// transient failure. @@ -127,3 +179,72 @@ pub(super) fn hex_job_id(job_id: [u8; 16]) -> String { out }) } + +#[cfg(test)] +mod tests { + use uffs_content_protocol::frame::{ + ContentChunk, FileBegin, FrameEnvelope, FrameType, ReadMode, + }; + use uffs_content_protocol::manifest::MAX_PATH_BYTES; + use uffs_content_protocol::path_encoding::WindowsPath; + + use super::MAX_MESSAGE_BYTES; + use crate::job::workflow::DEFAULT_MAX_CHUNK_BYTES; + + fn encoded_len(frame_type: FrameType, payload: &[u8]) -> u32 { + let bytes = FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id: [0; 16], + frame_sequence: 0, + } + .encode(payload); + u32::try_from(bytes.len()).unwrap_or(u32::MAX) + } + + /// Locks the exact bug this constant's derivation replaced: a full + /// `CONTENT_CHUNK` at the current max chunk size must always fit + /// under the pipe's own message ceiling. + #[test] + fn max_message_bytes_fits_a_full_content_chunk() { + let payload = vec![0_u8; DEFAULT_MAX_CHUNK_BYTES as usize]; + let chunk = ContentChunk { + candidate_id: u64::MAX, + chunk_sequence: u64::MAX, + logical_offset: u64::MAX, + logical_length: u64::from(DEFAULT_MAX_CHUNK_BYTES), + payload, + }; + let encoded = encoded_len(FrameType::ContentChunk, &chunk.encode()); + assert!( + encoded <= MAX_MESSAGE_BYTES, + "a full CONTENT_CHUNK frame ({encoded} bytes) must fit under \ + MAX_MESSAGE_BYTES ({MAX_MESSAGE_BYTES} bytes)" + ); + } + + /// Same, for a `FILE_BEGIN` carrying the protocol's own maximum path + /// length — the other worst-case frame this ceiling must cover. + #[test] + fn max_message_bytes_fits_a_file_begin_with_a_maximum_length_path() { + let max_code_units = (MAX_PATH_BYTES / 2) as usize; + let path = WindowsPath::from_code_units(vec![u16::from(b'x'); max_code_units]); + let file_begin = FileBegin { + candidate_id: u64::MAX, + file_reference: u64::MAX, + path, + logical_size: u64::MAX, + mtime: i64::MAX, + read_mode: ReadMode::LogicalSnapshot, + attempt_number: u32::MAX, + content_object_id: Some(u64::MAX), + }; + let encoded = encoded_len(FrameType::FileBegin, &file_begin.encode()); + assert!( + encoded <= MAX_MESSAGE_BYTES, + "a maximum-length-path FILE_BEGIN frame ({encoded} bytes) must fit under \ + MAX_MESSAGE_BYTES ({MAX_MESSAGE_BYTES} bytes)" + ); + } +} diff --git a/crates/uffs-content/src/serve/stream.rs b/crates/uffs-content/src/serve/stream.rs index 1d6cfbcf2..733cb2b16 100644 --- a/crates/uffs-content/src/serve/stream.rs +++ b/crates/uffs-content/src/serve/stream.rs @@ -18,13 +18,23 @@ //! of needing a separate always-on data-pipe server to coordinate with //! whichever job happens to be active. //! +//! # Incremental production, not whole-job materialization +//! +//! [`crate::job::vss_job::run_vss_job`] runs on a dedicated blocking +//! thread and emits each encoded frame through a bounded channel +//! ([`FRAME_CHANNEL_CAPACITY`]) as soon as it exists, instead of +//! returning the whole job's frames in one `Vec` — see that function's +//! (and `workflow::run_job`'s) own doc comments for why. This task is +//! the consumer side of that channel: it classifies each arriving frame +//! into [`Grouped`], sends a candidate's group once the send-window +//! admits it, and evicts a candidate's buffered frames the moment a +//! `FILE_ACK` confirms it's no longer needed for a resend. Combined with +//! the channel's own small bounded capacity, peak memory stays close to +//! the send-window size (a small, fixed budget) rather than growing with +//! the job's total content — never the whole job at once. +//! //! # v1 simplifications, documented rather than silent //! -//! - No incremental production: [`crate::job::vss_job::run_vss_job`] already -//! builds the whole job's frames synchronously before this task starts pacing -//! them out. A future revision that streams while reading would let a very -//! large job start delivering bytes sooner, but does not change the -//! resume/backpressure contract this module implements. //! - Window size is a fixed default, not negotiated per job — see //! [`DEFAULT_WINDOW_BYTES`]. @@ -35,7 +45,7 @@ use std::path::PathBuf; use tokio::net::windows::named_pipe::NamedPipeServer; use tokio::sync::mpsc; use uffs_content_protocol::DATA_PIPE_NAME; -use uffs_content_protocol::frame::{FrameEnvelope, FrameType}; +use uffs_content_protocol::frame::{FrameEnvelope, FrameType, JobBegin}; use super::{ActiveJob, ControlSignal, ServerState, pipe_io}; use crate::job::intake::JobRequest; @@ -48,6 +58,15 @@ use crate::job::window::WindowTracker; /// a fixed, generous default until per-job negotiation is worth adding. const DEFAULT_WINDOW_BYTES: u64 = 16 * 1024 * 1024; +/// How many produced-but-not-yet-classified frames the bounded channel +/// between the blocking producer thread and this streaming task may +/// hold before the producer blocks on send. This is what keeps the +/// producer from running arbitrarily far ahead of a stalled consumer — +/// worst case, a full channel holds this many maximum-size frames +/// (~64 KiB each at the default chunk size), a few MiB, nowhere near an +/// entire job's content. +const FRAME_CHANNEL_CAPACITY: usize = 32; + /// Spawn the streaming task for a freshly submitted job. /// /// The producer, not the consumer, assigns the real `job_id`: @@ -71,25 +90,93 @@ async fn run( request: JobRequest, run_dir: &std::path::Path, ) -> anyhow::Result<()> { + let (frame_tx, mut frame_rx) = mpsc::channel::>(FRAME_CHANNEL_CAPACITY); let run_dir_owned = run_dir.to_path_buf(); - let outcome = tokio::task::spawn_blocking(move || run_vss_job(&request, &run_dir_owned)) - .await - .map_err(|err| anyhow::anyhow!("streaming task panicked: {err}"))??; + let job_task = tokio::task::spawn_blocking(move || { + run_vss_job(&request, &run_dir_owned, move |frame| { + frame_tx.blocking_send(frame).map_err(|_err| { + std::io::Error::other("streaming task's frame receiver was dropped") + }) + }) + }); - let job_id = outcome.job_id; - let candidate_ids: Vec = (1..=outcome.run_summary.candidate_count).collect(); + // JOB_BEGIN is always the first frame `run_job` emits — receive + // (and classify) frames until it arrives, so `job_id` and + // `candidate_count` are known before anything else happens. + let mut grouped = Grouped::default(); + while grouped.job_begin.is_none() { + match frame_rx.recv().await { + Some(bytes) => grouped.classify_one_frame(bytes), + None => break, + } + } + let Some(job_begin_bytes) = grouped.job_begin.clone() else { + return Err(surface_job_task_error( + job_task.await, + "producer finished without emitting JOB_BEGIN", + )); + }; + let (job_id, candidate_count) = decode_job_begin(&job_begin_bytes)?; + + let candidate_ids: Vec = (1..=candidate_count).collect(); state.registry.register(job_id, candidate_ids); let (control_tx, mut control_rx) = mpsc::channel(32); set_active(state, Some(ActiveJob { job_id, control_tx })); - let grouped = group_frames_by_candidate(&outcome.frames); let mut window = WindowTracker::new(DEFAULT_WINDOW_BYTES); + let mut frame_rx_closed = false; - serve_data_pipe(state, job_id, &grouped, &mut control_rx, &mut window).await; + serve_data_pipe( + state, + job_id, + &job_begin_bytes, + &mut grouped, + &mut frame_rx, + &mut frame_rx_closed, + &mut control_rx, + &mut window, + ) + .await; set_active(state, None); state.registry.remove(job_id); - Ok(()) + + match job_task.await { + Ok(Ok(_outcome)) => Ok(()), + Ok(Err(err)) => Err(err), + Err(err) => Err(anyhow::anyhow!("streaming task panicked: {err}")), + } +} + +/// Turn the blocking job task's already-resolved result into the real +/// error that made it finish without ever emitting `JOB_BEGIN`, falling +/// back to `fallback` only if the task itself reports success (a +/// producer bug: finishing cleanly without emitting the one frame every +/// job must start with). +fn surface_job_task_error( + job_task_result: Result< + anyhow::Result, + tokio::task::JoinError, + >, + fallback: &str, +) -> anyhow::Error { + match job_task_result { + Ok(Ok(_outcome)) => anyhow::anyhow!("{fallback}"), + Ok(Err(err)) => err, + Err(err) => anyhow::anyhow!("streaming task panicked: {err}"), + } +} + +/// Decode a `JOB_BEGIN` frame's envelope + payload, returning +/// `(job_id, candidate_count)`. +fn decode_job_begin(frame_bytes: &[u8]) -> anyhow::Result<([u8; 16], u64)> { + let mut reader = uffs_content_protocol::codec::Reader::new(frame_bytes); + let (envelope, payload) = FrameEnvelope::decode(&mut reader, u64::MAX) + .map_err(|err| anyhow::anyhow!("failed to decode JOB_BEGIN envelope: {err}"))?; + let mut payload_reader = uffs_content_protocol::codec::Reader::new(&payload); + let job_begin = JobBegin::decode(&mut payload_reader) + .map_err(|err| anyhow::anyhow!("failed to decode JOB_BEGIN payload: {err}"))?; + Ok((envelope.job_id, job_begin.candidate_count)) } /// Outer accept loop: (re)connect the data pipe and stream `job_id`'s @@ -98,10 +185,21 @@ async fn run( /// mid-connection gets resolved is a fresh connection restarting that /// candidate from its `FILE_BEGIN` (see the module doc's "why the accept /// loop lives with the job" section). +#[expect( + clippy::too_many_arguments, + reason = "every parameter is per-job state that must persist across every reconnect on \ + this job's data pipe; bundling into a context struct would only move the \ + sprawl, since `stream_over_connection`'s own `wait_for_progress` needs several \ + of these as genuinely disjoint borrows inside a `tokio::select!` (a shared \ + context struct would force one exclusive borrow there instead)" +)] async fn serve_data_pipe( state: &Arc, job_id: [u8; 16], - grouped: &Grouped, + job_begin: &[u8], + grouped: &mut Grouped, + frame_rx: &mut mpsc::Receiver>, + frame_rx_closed: &mut bool, control_rx: &mut mpsc::Receiver, window: &mut WindowTracker, ) { @@ -110,14 +208,25 @@ async fn serve_data_pipe( let mut pipe = pipe_io::accept_connection(DATA_PIPE_NAME, &mut first_instance).await; tracing::info!(job_id = %pipe_io::hex_job_id(job_id), "consumer connected on data pipe"); - if pipe_io::write_one_message(&mut pipe, &grouped.job_begin) + if pipe_io::write_one_message(&mut pipe, job_begin) .await .is_err() { continue; } - match stream_over_connection(&mut pipe, state, job_id, grouped, control_rx, window).await { + match stream_over_connection( + &mut pipe, + state, + job_id, + grouped, + frame_rx, + frame_rx_closed, + control_rx, + window, + ) + .await + { ConnectionOutcome::Reconnect => {} ConnectionOutcome::JobComplete | ConnectionOutcome::Terminated => return, } @@ -128,8 +237,9 @@ async fn serve_data_pipe( enum ConnectionOutcome { /// Every candidate was acked and `JOB_END` was sent (best-effort). JobComplete, - /// The consumer cancelled, or the control channel died — nothing - /// further to send or wait for. + /// The consumer cancelled, the control channel died, or the producer + /// ended without ever completing the manifest it committed to — + /// nothing further to send or wait for. Terminated, /// The connection dropped mid-stream; the caller should accept a /// fresh one and resume from wherever the registry says is pending. @@ -138,26 +248,63 @@ enum ConnectionOutcome { /// Stream `job_id`'s not-yet-acked candidates over `pipe` until it /// completes, is terminated, or the connection itself fails. +#[expect( + clippy::too_many_arguments, + reason = "see `serve_data_pipe`'s own reason" +)] async fn stream_over_connection( pipe: &mut NamedPipeServer, state: &Arc, job_id: [u8; 16], - grouped: &Grouped, + grouped: &mut Grouped, + frame_rx: &mut mpsc::Receiver>, + frame_rx_closed: &mut bool, control_rx: &mut mpsc::Receiver, window: &mut WindowTracker, ) -> ConnectionOutcome { loop { if state.registry.is_complete(job_id) == Some(true) { - if let Err(err) = pipe_io::write_one_message(pipe, &grouped.job_end).await { - tracing::warn!(error = %err, job_id = %pipe_io::hex_job_id(job_id), "failed to send JOB_END"); - } - return ConnectionOutcome::JobComplete; + return send_job_end_once_complete( + pipe, + state, + job_id, + grouped, + frame_rx, + frame_rx_closed, + control_rx, + window, + ) + .await; } - let (group, group_bytes) = - match next_group_to_send(state, job_id, grouped, control_rx, window).await { - Ok(pair) => pair, - Err(outcome) => return outcome, - }; + + let candidate_id = match next_pending_candidate_ready_to_send( + state, + job_id, + grouped, + frame_rx, + frame_rx_closed, + control_rx, + window, + ) + .await + { + Ok(id) => id, + Err(outcome) => return outcome, + }; + + let Some(group) = grouped.by_candidate.get(&candidate_id) else { + // Evicted between being selected and being sent — only + // reachable if it was acked without ever being sent, which + // `next_pending_candidate_ready_to_send` never allows; fail + // safe rather than loop forever on it. + tracing::warn!( + candidate_id, + "candidate group vanished before it could be sent" + ); + state.registry.ack(job_id, candidate_id); + continue; + }; + let group_bytes: u64 = group.iter().map(|frame| frame.len() as u64).sum(); if send_group(pipe, group).await.is_err() { tracing::info!( job_id = %pipe_io::hex_job_id(job_id), @@ -169,16 +316,66 @@ async fn stream_over_connection( } } -/// Pick the next not-yet-acked candidate's frame group, waiting on -/// window budget and control signals (`WINDOW_UPDATE`/`FILE_ACK`) for as -/// long as nothing is ready to send yet. -async fn next_group_to_send<'grouped>( +/// Every candidate is acked (the caller already checked +/// `registry.is_complete`) — wait for `JOB_END` itself to have arrived +/// from the producer (it is always the last frame emitted, but the +/// channel may not have delivered it yet) and send it. +#[expect( + clippy::too_many_arguments, + reason = "see `serve_data_pipe`'s own reason" +)] +async fn send_job_end_once_complete( + pipe: &mut NamedPipeServer, state: &Arc, job_id: [u8; 16], - grouped: &'grouped Grouped, + grouped: &mut Grouped, + frame_rx: &mut mpsc::Receiver>, + frame_rx_closed: &mut bool, control_rx: &mut mpsc::Receiver, window: &mut WindowTracker, -) -> Result<(&'grouped [Vec], u64), ConnectionOutcome> { +) -> ConnectionOutcome { + while grouped.job_end.is_none() { + if let Err(outcome) = wait_for_progress( + frame_rx, + frame_rx_closed, + control_rx, + state, + job_id, + window, + grouped, + ) + .await + { + return outcome; + } + if *frame_rx_closed && grouped.job_end.is_none() { + tracing::error!( + job_id = %pipe_io::hex_job_id(job_id), + "producer finished without ever emitting JOB_END" + ); + return ConnectionOutcome::Terminated; + } + } + let job_end = grouped.job_end.clone().unwrap_or_default(); + if let Err(err) = pipe_io::write_one_message(pipe, &job_end).await { + tracing::warn!(error = %err, job_id = %pipe_io::hex_job_id(job_id), "failed to send JOB_END"); + } + ConnectionOutcome::JobComplete +} + +/// Find the next not-yet-acked candidate whose complete frame group has +/// both been produced and fits the current send window, waiting on +/// production and/or control signals for as long as neither condition +/// holds yet. +async fn next_pending_candidate_ready_to_send( + state: &Arc, + job_id: [u8; 16], + grouped: &mut Grouped, + frame_rx: &mut mpsc::Receiver>, + frame_rx_closed: &mut bool, + control_rx: &mut mpsc::Receiver, + window: &mut WindowTracker, +) -> Result { loop { let Some(candidate_id) = state .registry @@ -188,50 +385,104 @@ async fn next_group_to_send<'grouped>( // Nothing left to send this connection (a resume race, or // every candidate already sent) — wait for an ack that // completes the job, or a cancel, before re-checking. - wait_for_progress(control_rx, state, job_id, window).await?; + wait_for_progress( + frame_rx, + frame_rx_closed, + control_rx, + state, + job_id, + window, + grouped, + ) + .await?; continue; }; - let Some(group) = grouped.by_candidate.get(&candidate_id) else { - // A candidate id the manifest never actually produced frames - // for (shouldn't happen — every registered id comes from the - // same manifest — but fail safe rather than looping forever - // on it). - tracing::warn!(candidate_id, "no frame group for pending candidate id"); - state.registry.ack(job_id, candidate_id); + if !grouped.by_candidate.contains_key(&candidate_id) { + // Not produced yet — wait for more frames to arrive. + wait_for_progress( + frame_rx, + frame_rx_closed, + control_rx, + state, + job_id, + window, + grouped, + ) + .await?; + if *frame_rx_closed && !grouped.by_candidate.contains_key(&candidate_id) { + // The producer is done and never produced this + // candidate's frames at all — a producer-side bug + // (every registered candidate id came from the same + // manifest `run_job` itself built), not something to + // spin on forever. + tracing::error!( + candidate_id, + job_id = %pipe_io::hex_job_id(job_id), + "producer finished without ever producing this candidate's frames" + ); + return Err(ConnectionOutcome::Terminated); + } continue; - }; - let group_bytes: u64 = group.iter().map(|frame| frame.len() as u64).sum(); + } + let group_bytes: u64 = grouped.by_candidate.get(&candidate_id).map_or(0, |group| { + group.iter().map(|frame| frame.len() as u64).sum() + }); while !window.can_admit(group_bytes) { - wait_for_progress(control_rx, state, job_id, window).await?; + wait_for_progress( + frame_rx, + frame_rx_closed, + control_rx, + state, + job_id, + window, + grouped, + ) + .await?; } - return Ok((group, group_bytes)); + return Ok(candidate_id); } } -/// Wait for and apply one control signal, collapsing the two "streaming -/// cannot continue" cases ([`SignalOutcome::Cancelled`] and -/// [`SignalOutcome::ControlChannelClosed`]) into a single `Err` the -/// caller propagates via `?`/`return`. +/// Wait for either the next frame from the producer (classifying it into +/// `grouped`) or the next control signal (applying it), whichever +/// arrives first. Once the producer channel has closed, stops selecting +/// on it — a closed [`mpsc::Receiver`] resolves immediately on every +/// poll, which would otherwise starve the control-signal branch in a +/// tight loop — and only waits on `control_rx` from then on. async fn wait_for_progress( + frame_rx: &mut mpsc::Receiver>, + frame_rx_closed: &mut bool, control_rx: &mut mpsc::Receiver, state: &Arc, job_id: [u8; 16], window: &mut WindowTracker, + grouped: &mut Grouped, ) -> Result<(), ConnectionOutcome> { - match apply_next_signal(control_rx, state, job_id, window).await { - SignalOutcome::Applied => Ok(()), - SignalOutcome::Cancelled | SignalOutcome::ControlChannelClosed => { - Err(ConnectionOutcome::Terminated) - } + if *frame_rx_closed { + return match apply_next_signal(control_rx, state, job_id, window, grouped).await { + SignalOutcome::Applied => Ok(()), + SignalOutcome::Cancelled | SignalOutcome::ControlChannelClosed => { + Err(ConnectionOutcome::Terminated) + } + }; } -} - -/// Write every frame in one candidate's group, in order. -async fn send_group(pipe: &mut NamedPipeServer, group: &[Vec]) -> anyhow::Result<()> { - for frame in group { - pipe_io::write_one_message(pipe, frame).await?; + tokio::select! { + frame = frame_rx.recv() => { + match frame { + Some(bytes) => grouped.classify_one_frame(bytes), + None => *frame_rx_closed = true, + } + Ok(()) + } + signal = apply_next_signal(control_rx, state, job_id, window, grouped) => { + match signal { + SignalOutcome::Applied => Ok(()), + SignalOutcome::Cancelled | SignalOutcome::ControlChannelClosed => { + Err(ConnectionOutcome::Terminated) + } + } + } } - Ok(()) } /// What happened when [`apply_next_signal`] waited for and applied one @@ -251,13 +502,16 @@ enum SignalOutcome { /// Block until one [`ControlSignal`] arrives and apply it: a /// `WindowGrant` raises `window`'s ceiling, a `FileAcked` updates the -/// registry, a `Cancel` is reported (not applied here — the caller owns -/// job teardown). +/// registry and evicts that candidate's buffered frames from `grouped` +/// (they're never needed again — an ack is a promise the consumer never +/// needs a resend), a `Cancel` is reported (not applied here — the +/// caller owns job teardown). async fn apply_next_signal( control_rx: &mut mpsc::Receiver, state: &Arc, job_id: [u8; 16], window: &mut WindowTracker, + grouped: &mut Grouped, ) -> SignalOutcome { let Some(signal) = control_rx.recv().await else { return SignalOutcome::ControlChannelClosed; @@ -269,6 +523,7 @@ async fn apply_next_signal( } ControlSignal::FileAcked(candidate_id) => { state.registry.ack(job_id, candidate_id); + grouped.by_candidate.remove(&candidate_id); SignalOutcome::Applied } ControlSignal::Cancel(reason) => { @@ -278,49 +533,61 @@ async fn apply_next_signal( } } -/// Group `frames` into the leading `JOB_BEGIN`, one frame list per -/// candidate (in manifest order), and the trailing `JOB_END` — -/// `run_job`'s own emission order (`push_frame(FrameType::JobBegin, ..)` -/// first, `push_frame(FrameType::JobEnd, ..)` last, every per-candidate -/// frame group in between starting with `FILE_BEGIN`) makes this a -/// single linear pass. +/// Write every frame in one candidate's group, in order. +async fn send_group(pipe: &mut NamedPipeServer, group: &[Vec]) -> anyhow::Result<()> { + for frame in group { + pipe_io::write_one_message(pipe, frame).await?; + } + Ok(()) +} + +/// Incrementally accumulated frame buckets: the leading `JOB_BEGIN`, each +/// not-yet-acked candidate's frames received so far (or complete, in +/// manifest order), and the trailing `JOB_END` — built one frame at a +/// time via [`Grouped::classify_one_frame`] as frames arrive from the +/// producer, rather than all at once from a prebuilt slice (see the +/// module doc comment). +#[derive(Default)] struct Grouped { - /// The job's `JOB_BEGIN` frame, ready to send as-is. - job_begin: Vec, - /// Every other frame, bucketed by the `candidate_id` it belongs to, - /// in manifest emission order. + /// The job's `JOB_BEGIN` frame, once received. + job_begin: Option>, + /// Every other not-yet-evicted frame, bucketed by the `candidate_id` + /// it belongs to, in arrival (= manifest emission) order. A + /// candidate's entry is removed once `FILE_ACK` confirms it's no + /// longer needed for a resend (see [`apply_next_signal`]). by_candidate: HashMap>>, - /// The job's `JOB_END` frame, ready to send as-is. - job_end: Vec, + /// The job's `JOB_END` frame, once received (always the last frame + /// the producer emits). + job_end: Option>, + /// Which candidate a non-`FILE_BEGIN` frame belongs to — carried + /// across calls to [`Self::classify_one_frame`], mirroring the local + /// `current` variable a whole-slice classifier would keep instead. + current_candidate: Option, } -/// Split `frames` (one job's complete, already-produced frame list) into -/// [`Grouped`]'s three buckets: the leading `JOB_BEGIN`, one frame group -/// per candidate, and the trailing `JOB_END`. -fn group_frames_by_candidate(frames: &[Vec]) -> Grouped { - let mut by_candidate: HashMap>> = HashMap::new(); - let mut job_begin = Vec::new(); - let mut job_end = Vec::new(); - let mut current: Option = None; - - for frame_bytes in frames { - let mut reader = uffs_content_protocol::codec::Reader::new(frame_bytes); +impl Grouped { + /// Classify one already-decoded-length frame into this job's + /// buckets, exactly matching `run_job`'s own emission order + /// (`JOB_BEGIN` first, `JOB_END` last, every per-candidate frame + /// group in between starting with `FILE_BEGIN`). + fn classify_one_frame(&mut self, frame_bytes: Vec) { + let mut reader = uffs_content_protocol::codec::Reader::new(&frame_bytes); let Ok((envelope, payload)) = FrameEnvelope::decode(&mut reader, u64::MAX) else { - continue; + return; }; match envelope.frame_type { - FrameType::JobBegin => job_begin.clone_from(frame_bytes), - FrameType::JobEnd => job_end.clone_from(frame_bytes), + FrameType::JobBegin => self.job_begin = Some(frame_bytes), + FrameType::JobEnd => self.job_end = Some(frame_bytes), FrameType::FileBegin => { let mut payload_reader = uffs_content_protocol::codec::Reader::new(&payload); if let Ok(file_begin) = uffs_content_protocol::frame::FileBegin::decode(&mut payload_reader) { - current = Some(file_begin.candidate_id); - by_candidate + self.current_candidate = Some(file_begin.candidate_id); + self.by_candidate .entry(file_begin.candidate_id) .or_default() - .push(frame_bytes.clone()); + .push(frame_bytes); } } FrameType::ContentChunk @@ -334,21 +601,15 @@ fn group_frames_by_candidate(frames: &[Vec]) -> Grouped { | FrameType::WindowUpdate | FrameType::JobResume | FrameType::JobSubmit => { - if let Some(candidate_id) = current { - by_candidate + if let Some(candidate_id) = self.current_candidate { + self.by_candidate .entry(candidate_id) .or_default() - .push(frame_bytes.clone()); + .push(frame_bytes); } } } } - - Grouped { - job_begin, - by_candidate, - job_end, - } } /// Set (or clear) the server's single active-job slot. @@ -359,3 +620,93 @@ fn set_active(state: &Arc, active: Option) { .unwrap_or_else(std::sync::PoisonError::into_inner); *slot = active; } + +#[cfg(test)] +mod tests { + use uffs_content_protocol::frame::{FileBegin, FrameEnvelope, FrameType, JobBegin, ReadMode}; + use uffs_content_protocol::manifest::AuthorizationMode; + use uffs_content_protocol::path_encoding::WindowsPath; + + use super::Grouped; + + const JOB_ID: [u8; 16] = [7; 16]; + + fn encode(frame_sequence: u64, frame_type: FrameType, payload: &[u8]) -> Vec { + FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id: JOB_ID, + frame_sequence, + } + .encode(payload) + } + + fn file_begin_frame(sequence: u64, candidate_id: u64) -> Vec { + let file_begin = FileBegin { + candidate_id, + file_reference: candidate_id, + path: WindowsPath::from_str_lossless("file.bin"), + logical_size: 0, + mtime: 0, + read_mode: ReadMode::LogicalSnapshot, + attempt_number: 1, + content_object_id: None, + }; + encode(sequence, FrameType::FileBegin, &file_begin.encode()) + } + + #[test] + fn classify_one_frame_buckets_job_begin_and_job_end_separately() { + let mut grouped = Grouped::default(); + let job_begin = JobBegin { + job_id: JOB_ID, + source_id: [0; 16], + snapshot_id: Vec::new(), + snapshot_created_at: 0, + manifest_digest: [0; 32], + candidate_count: 0, + authorization_mode: AuthorizationMode::AdminExport, + ordering: uffs_content_protocol::frame::FrameOrdering::None, + content_semantics: uffs_content_protocol::frame::ContentSemantics::UnnamedLogicalStream, + digest_algorithm: uffs_content_protocol::frame::DigestAlgorithm::Blake3, + max_chunk_bytes: 65536, + max_content_delivery_bytes: None, + }; + let job_begin_bytes = encode(0, FrameType::JobBegin, &job_begin.encode()); + grouped.classify_one_frame(job_begin_bytes.clone()); + assert_eq!(grouped.job_begin, Some(job_begin_bytes)); + assert!(grouped.by_candidate.is_empty()); + assert_eq!(grouped.job_end, None); + } + + #[test] + fn classify_one_frame_groups_frames_under_the_most_recent_file_begin() { + let mut grouped = Grouped::default(); + let begin_1 = file_begin_frame(0, 1); + let chunk_1 = encode(1, FrameType::ContentChunk, b"chunk-for-candidate-1"); + let begin_2 = file_begin_frame(2, 2); + let chunk_2 = encode(3, FrameType::ContentChunk, b"chunk-for-candidate-2"); + + for frame in [ + begin_1.clone(), + chunk_1.clone(), + begin_2.clone(), + chunk_2.clone(), + ] { + grouped.classify_one_frame(frame); + } + + assert_eq!(grouped.by_candidate.get(&1), Some(&vec![begin_1, chunk_1])); + assert_eq!(grouped.by_candidate.get(&2), Some(&vec![begin_2, chunk_2])); + } + + #[test] + fn classify_one_frame_ignores_undecodable_bytes() { + let mut grouped = Grouped::default(); + grouped.classify_one_frame(b"not a valid frame".to_vec()); + assert_eq!(grouped.job_begin, None); + assert!(grouped.by_candidate.is_empty()); + assert_eq!(grouped.job_end, None); + } +} diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index bac5f8719..d92c32be4 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -90,11 +90,16 @@ mod tests { query: "*".to_owned(), ..Default::default() }; + let mut frames = Vec::new(); let outcome = run_job( &request, &DirWalkCandidateSource, &FsContentSource, run_dir.path(), + |frame| { + frames.push(frame); + Ok(()) + }, ) .expect("run_job must succeed"); @@ -104,7 +109,7 @@ mod tests { // 4. Decode the actual wire bytes as a real consumer would — this is what // catches a framing bug a structure-passthrough shortcut would miss // entirely. - let consumed = support::test_consumer::consume(&outcome.manifest_bytes, &outcome.frames); + let consumed = support::test_consumer::consume(&outcome.manifest_bytes, &frames); assert_eq!( consumed.candidate_count, outcome.run_summary.candidate_count, "manifest header's candidate_count must match the run summary's" From ac3ce0c52d6216cffd388397e6a1fa827d175888 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:07:47 -0700 Subject: [PATCH 49/98] feat(content): JobRequest supports multiple roots, defaults to all drives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JobRequest::root (single PathBuf) is now JobRequest::roots (Vec). run_vss_job resolves an empty/omitted list to every local NTFS drive (uffs_mft::detect_ntfs_drives — the same auto-discovery uffsd itself falls back to with no --drive flag) before leasing VSS snapshots and spawning the combined ephemeral daemon; a non-empty list is used as given. workflow::run_job now enumerates each resolved root and merges the candidates into one manifest/job, so a single job can span multiple drives (or default to "search everything") instead of touching exactly one drive per job. uffs-content gains a narrowly-scoped uffs-mft dependency for just the detect_ntfs_drives() platform helper (no MFT reading involved), noted in Cargo.toml alongside the crate's existing "no uffs-mft/uffs-core" rationale. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/uffs-content/Cargo.toml | 21 ++++--- crates/uffs-content/src/job/intake.rs | 17 +++-- crates/uffs-content/src/job/self_test.rs | 4 +- crates/uffs-content/src/job/tests.rs | 2 +- crates/uffs-content/src/job/vss_job.rs | 62 +++++++++++++++---- crates/uffs-content/src/job/workflow.rs | 14 +++-- crates/uffs-content/src/main.rs | 4 ++ .../tests/e2e_dir_walk_parity_fake_reader.rs | 4 +- .../tests/e2e_real_vss_content_reader.rs | 2 + 10 files changed, 99 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87949719b..f257f3ca4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4495,6 +4495,7 @@ dependencies = [ "uffs-client", "uffs-content-protocol", "uffs-content-reader-protocol", + "uffs-mft", "uffs-security", "uffs-version", "uuid", diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 1bb8c6fd1..35a8b2d0d 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -75,13 +75,18 @@ uuid.workspace = true # `uffs-broker-protocol`/`uffs-client`, matching `uffs-broker`'s own # rationale for this split (F5 / issue #205). # -# Deliberately NOT depending on `uffs-mft`/`uffs-core` here: this crate -# never reads or queries an MFT itself. It leases a VSS snapshot from -# the Broker, spawns an ephemeral `uffsd --device =` -# instance to do the actual MFT read + query evaluation (the daemon -# already owns all `uffs-mft`/`uffs-core` usage), and talks to it over -# the same RPC protocol `uffs-client` already implements for every other -# UFFS client. +# Deliberately NOT depending on `uffs-core` here, and not on `uffs-mft` +# for MFT reading/parsing/querying: this crate never reads or queries an +# MFT itself. It leases a VSS snapshot from the Broker, spawns an +# ephemeral `uffsd --device =` instance to do the actual +# MFT read + query evaluation (the daemon already owns all +# `uffs-mft`/`uffs-core` usage for that), and talks to it over the same +# RPC protocol `uffs-client` already implements for every other UFFS +# client. The one `uffs-mft` dependency below is narrowly scoped to its +# `detect_ntfs_drives` platform helper (a `GetLogicalDrives`/ +# `GetVolumeInformationW` check, no MFT involved) for the "no roots +# given, default to every local NTFS drive" case — the same helper +# `uffs-daemon` itself uses for its own no-`--drive`-flag default. [target.'cfg(windows)'.dependencies] # Error handling for the pipe client and real VSS-backed CandidateSource # (`?`/`anyhow::bail!`/`anyhow::anyhow!`) — not used anywhere else in @@ -94,6 +99,8 @@ uffs-broker-protocol.workspace = true # Spawns the ephemeral `uffsd` instance and queries it over the same # daemon RPC protocol every other UFFS client uses. uffs-client.workspace = true +# `detect_ntfs_drives()` only — see the block comment above. +uffs-mft.workspace = true # Wire types for the private Coordinator<->Snapshot Reader protocol # (`src/job/reader_client.rs`, `src/job/content_source.rs`'s # `VssContentSource`). diff --git a/crates/uffs-content/src/job/intake.rs b/crates/uffs-content/src/job/intake.rs index 5c40b3cf3..ac106f135 100644 --- a/crates/uffs-content/src/job/intake.rs +++ b/crates/uffs-content/src/job/intake.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; -/// A request to ingest content under `root`. +/// A request to ingest content under `roots`. /// /// This is the local job-submission format — ordinary JSON, unlike the /// Docenta-facing frame protocol, which uses the explicit binary codec @@ -24,7 +24,7 @@ use std::path::PathBuf; /// VSS+MFT-query-backed `super::candidate_source::VssCandidateSource`. /// [`super::candidate_source::DirWalkCandidateSource`] (the fake, /// cross-platform backend) ignores every filter field — it always -/// matches every regular file under `root`, equivalent to `query: "*"` +/// matches every regular file under a root, equivalent to `query: "*"` /// with no other filters set. #[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize)] pub struct JobRequest { @@ -32,8 +32,17 @@ pub struct JobRequest { /// `ManifestHeader::source_id` is derived deterministically from this /// string (see [`super::workflow::run_job`]). pub source_id: String, - /// Root directory to enumerate candidates under. - pub root: PathBuf, + /// Root directories to enumerate candidates under — one job may span + /// multiple drives (`super::vss_orchestrator` already leases one VSS + /// snapshot per distinct drive letter among these and serves them all + /// from a single combined ephemeral daemon). Empty means "every local + /// NTFS drive" — see `super::vss_job::run_vss_job`'s own doc + /// comment for how that default is resolved (Windows-only; the + /// cross-platform fake `DirWalkCandidateSource` path requires an + /// explicit, non-empty list, since "every drive" isn't a concept a + /// plain directory walk has). + #[serde(default)] + pub roots: Vec, /// UFFS name/path pattern to evaluate against the snapshot's MFT /// (e.g. `"*.txt"`); `"*"` matches every regular file. pub query: String, diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 523bfde6c..b2a5c96f5 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -52,7 +52,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { let request = JobRequest { source_id: "uffs-content-self-test".to_owned(), - root: test_dir.to_path_buf(), + roots: vec![test_dir.to_path_buf()], query: unique_name, ..Default::default() }; @@ -142,7 +142,7 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> let request = JobRequest { source_id: "uffs-content-self-test-query".to_owned(), - root: root.to_path_buf(), + roots: vec![root.to_path_buf()], query: "*".to_owned(), ext: Some(extension.to_owned()), ..Default::default() diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index e4b5dcb68..769c2d18b 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -137,7 +137,7 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { let run_dir = tempfile::tempdir().expect("create run temp dir"); let request = JobRequest { source_id: "test-source".to_owned(), - root: source_dir.path().to_path_buf(), + roots: vec![source_dir.path().to_path_buf()], query: "*".to_owned(), ..Default::default() }; diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index bc3ccbf1e..f2cc18b54 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -15,7 +15,7 @@ //! Windows-only: every piece this wires together already is. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; @@ -32,11 +32,17 @@ use super::workflow::{JobOutcome, run_job}; /// produced — see [`run_job`]'s own doc comment for why this is a /// callback rather than a returned `Vec`. /// +/// `request.roots` is used as given if non-empty; if empty, this job +/// defaults to every local NTFS drive (`uffs_mft::detect_ntfs_drives`) — +/// the same auto-discovery `uffsd` itself falls back to when started +/// with no `--drive` flag. +/// /// # Errors -/// Returns an error if any VSS lease, ephemeral daemon spawn, or -/// content Reader spawn step fails, or if the underlying `run_job` call -/// fails. Every resource successfully acquired before a failure is -/// released best-effort before returning. +/// Returns an error if root resolution finds no local NTFS drives to +/// default to, any VSS lease, ephemeral daemon spawn, or content Reader +/// spawn step fails, or if the underlying `run_job` call fails. Every +/// resource successfully acquired before a failure is released +/// best-effort before returning. pub fn run_vss_job(request: &JobRequest, run_dir: &Path, emit_frame: F) -> Result where F: FnMut(Vec) -> std::io::Result<()>, @@ -44,19 +50,26 @@ where let job_id = *uuid::Uuid::new_v4().as_bytes(); let ephemeral_id = uuid::Uuid::new_v4().simple().to_string(); - let resources = vss_orchestrator::prepare_ephemeral_daemon_for_roots( - job_id, - &[request.root.as_path()], - &ephemeral_id, - ) - .context("failed to lease VSS snapshot(s) and spawn the target-selection daemon")?; + let roots = resolve_roots(request)?; + let root_paths: Vec<&Path> = roots.iter().map(PathBuf::as_path).collect(); + + let resources = + vss_orchestrator::prepare_ephemeral_daemon_for_roots(job_id, &root_paths, &ephemeral_id) + .context("failed to lease VSS snapshot(s) and spawn the target-selection daemon")?; let drive_to_lease: HashMap = resources .leases .iter() .map(|lease| (lease.drive_letter, lease.lease_id)) .collect(); - let candidate_source = VssCandidateSource::new(request, &resources.daemon, drive_to_lease); + // The resolved (never-empty) root list is what `run_job`'s own + // enumeration loop must iterate, not whatever `request.roots` + // originally said (which may have been empty, relying on the + // default-to-all-drives resolution above). + let mut resolved_request = request.clone(); + resolved_request.roots = roots; + let candidate_source = + VssCandidateSource::new(&resolved_request, &resources.daemon, drive_to_lease); let devices_for_reader: Vec<(String, u64)> = resources .leases @@ -68,7 +81,7 @@ where let content_source = VssContentSource::new(content_reader); let result = run_job( - request, + &resolved_request, &candidate_source, &content_source, run_dir, @@ -91,3 +104,26 @@ where result } + +/// Resolve `request.roots`: as given if non-empty, else one root per +/// local NTFS drive on this machine — the consumer's "search everything" +/// default, matching `uffsd`'s own no-`--drive`-flag fallback +/// (`uffs_mft::detect_ntfs_drives`). +/// +/// # Errors +/// Returns an error if `request.roots` is empty and no local NTFS drive +/// is found to default to. +fn resolve_roots(request: &JobRequest) -> Result> { + if !request.roots.is_empty() { + return Ok(request.roots.clone()); + } + let drives = uffs_mft::detect_ntfs_drives(); + anyhow::ensure!( + !drives.is_empty(), + "no roots given and no local NTFS drive found to default to" + ); + Ok(drives + .into_iter() + .map(|letter| PathBuf::from(format!("{}:\\", letter.as_char()))) + .collect()) +} diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 62c897467..394258830 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -63,9 +63,12 @@ pub struct JobOutcome { pub run_summary: RunSummary, } -/// Run one job: enumerate `request.root` via `candidate_source`, finalize -/// a manifest, stream every candidate's content via `content_source`, and -/// finalize the run's summary/failure log under `run_dir`. +/// Run one job: enumerate every one of `request.roots` via +/// `candidate_source`. +/// +/// Finalize a manifest, stream every candidate's content via +/// `content_source`, and finalize the run's summary/failure log under +/// `run_dir`. /// /// Every encoded frame (`JOB_BEGIN`, then per-candidate frames, then /// `JOB_END`) is passed to `emit_frame` in emission order as soon as it @@ -94,7 +97,10 @@ where // job is equivalent to a `"*"` query, so its digest is fixed. let query_digest = digest(b"*"); - let entries = candidate_source.enumerate(&request.root)?; + let mut entries = Vec::new(); + for root in &request.roots { + entries.extend(candidate_source.enumerate(root)?); + } let candidate_count = len_as_u64(entries.len()); let built = build_manifest(job_id, source_id, query_digest, &entries) diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index bf2e721d9..6a43a9983 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -65,6 +65,10 @@ use uffs_content_protocol as _; // not by this thin entry point directly. #[cfg(windows)] use uffs_content_reader_protocol as _; +// Used by `uffs_content::job::vss_job` (default-to-all-drives root +// resolution), not by this thin entry point directly. +#[cfg(windows)] +use uffs_mft as _; // Used by `uffs_content::serve`'s named-pipe owner-only DACL helpers, // not by this thin entry point directly. #[cfg(windows)] diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index d92c32be4..29dfca8dc 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -50,6 +50,8 @@ use uffs_client as _; #[cfg(windows)] use uffs_content_reader_protocol as _; #[cfg(windows)] +use uffs_mft as _; +#[cfg(windows)] use uffs_security as _; use uffs_version as _; use uuid as _; @@ -86,7 +88,7 @@ mod tests { let run_dir = tempfile::tempdir().expect("create run temp dir"); let request = JobRequest { source_id: "fixture-source".to_owned(), - root: source_dir.path().to_path_buf(), + roots: vec![source_dir.path().to_path_buf()], query: "*".to_owned(), ..Default::default() }; diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 49f71dc1e..4737090cc 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -64,6 +64,8 @@ use uffs_content_protocol as _; #[cfg(windows)] use uffs_content_reader_protocol as _; #[cfg(windows)] +use uffs_mft as _; +#[cfg(windows)] use uffs_security as _; use uffs_version as _; use uuid as _; From 47d06e068e67cfb0a654667929e65f2488fb8acb Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:15:29 -0700 Subject: [PATCH 50/98] feat(content): add --self-test-reader-benchmark for real content-read throughput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self_test_reader_benchmark(roots, query) runs a real VSS-backed job and measures content-read wall-clock time/throughput specifically, isolated from VSS-lease/ephemeral-daemon/enumeration overhead: the emit_frame callback itself is the observation point (timestamps the first CONTENT_CHUNK frame as the phase boundary), so no timing instrumentation was added to run_job/workflow's own logic. Wired up as `uffs-content --self-test-reader-benchmark [query]` (roots = "all" for every local NTFS drive, or a comma-separated list; query defaults to "*"), matching the existing --self-test-vss-playback/ --self-test-vss-query conventions. This is the baseline-measurement tool for the Reader-parallelism work described in the local content-engine architecture doc — run it before and after to validate the parallel reader design actually helps, on real hardware, before/after each change. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/self_test.rs | 107 +++++++++++++++++++++++ crates/uffs-content/src/main.rs | 87 ++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index b2a5c96f5..3af4382aa 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -195,6 +195,113 @@ pub fn self_test_vss_query_metadata(root: &Path, extension: &str) -> Result<()> Ok(()) } +/// One [`self_test_reader_benchmark`] run's measured results. +#[derive(Debug, Clone, Copy)] +pub struct ReaderBenchmarkReport { + /// Total candidates the manifest committed to. + pub candidate_count: u64, + /// Candidates that reached a successful terminal outcome. + pub succeeded_count: u64, + /// Sum of every `CONTENT_CHUNK.payload.len()` actually streamed. + pub content_bytes: u64, + /// Wall-clock time from job start to the first `CONTENT_CHUNK` frame: + /// VSS lease + ephemeral daemon spawn + enumeration + manifest + /// finalization, milliseconds. + pub enumeration_ms: u128, + /// Wall-clock time from the first `CONTENT_CHUNK` frame to the job + /// finishing — the number this benchmark exists to measure, + /// milliseconds. + pub content_read_ms: u128, + /// `content_bytes` / `content_read_ms`, in MiB/s. `0.0` if + /// `content_read_ms` is `0` (nothing to divide by — e.g. a job with + /// no content-bearing candidates). + pub throughput_mib_per_sec: f64, +} + +/// Run a real VSS-backed job against `roots` (empty = every local NTFS +/// drive — see [`super::vss_job::run_vss_job`]) evaluating `query`, and +/// report content-read wall-clock time and throughput. +/// +/// This is the baseline-measurement tool for judging Reader-parallelism +/// work (see the local-only content-engine architecture doc): it +/// deliberately isolates the *content-read phase* from VSS-lease/ +/// ephemeral-daemon/enumeration overhead by using `emit_frame` itself as +/// the observation point — the moment the first `CONTENT_CHUNK` frame +/// arrives marks the enumeration/content-read phase boundary — rather +/// than adding timing instrumentation to `run_job`/`workflow` itself. +/// +/// # Errors +/// Returns an error if the run directory can't be created or +/// `run_vss_job` fails. +pub fn self_test_reader_benchmark( + roots: &[std::path::PathBuf], + query: &str, +) -> Result { + let run_dir = std::env::temp_dir().join(format!( + "uffs-content-reader-benchmark-{}", + uuid::Uuid::new_v4().simple() + )); + std::fs::create_dir_all(&run_dir) + .with_context(|| format!("failed to create run dir {}", run_dir.display()))?; + + let request = JobRequest { + source_id: "uffs-content-reader-benchmark".to_owned(), + roots: roots.to_vec(), + query: query.to_owned(), + ..Default::default() + }; + + let start = std::time::Instant::now(); + let mut first_content_chunk_at: Option = None; + let mut content_bytes: u64 = 0; + + let outcome = run_vss_job(&request, &run_dir, |frame_bytes| { + let mut reader = WireReader::new(&frame_bytes); + if let Ok((envelope, payload)) = FrameEnvelope::decode(&mut reader, u64::MAX) + && envelope.frame_type == FrameType::ContentChunk + { + first_content_chunk_at.get_or_insert_with(std::time::Instant::now); + let mut payload_reader = WireReader::new(&payload); + if let Ok(chunk) = ContentChunk::decode(&mut payload_reader, u32::MAX) { + content_bytes += u64::try_from(chunk.payload.len()).unwrap_or(u64::MAX); + } + } + Ok(()) + }) + .context("run_vss_job failed")?; + + let end = std::time::Instant::now(); + let content_start = first_content_chunk_at.unwrap_or(end); + let enumeration_ms = content_start.duration_since(start).as_millis(); + let content_read_ms = end.duration_since(content_start).as_millis(); + #[expect( + clippy::cast_precision_loss, + reason = "diagnostic-only throughput number for a benchmark report, not a value \ + anything downstream computes against — losing precision above 2^53 bytes \ + (8+ petabytes) or milliseconds is not a real concern here" + )] + #[expect( + clippy::float_arithmetic, + reason = "diagnostic-only throughput ratio for a benchmark report — same precision \ + posture as uffs-daemon's own EMA rate arithmetic (drive_stats.rs)" + )] + let throughput_mib_per_sec = if content_read_ms > 0 { + (content_bytes as f64 / (1_024.0_f64 * 1_024.0_f64)) + / (content_read_ms as f64 / 1_000.0_f64) + } else { + 0.0_f64 + }; + + Ok(ReaderBenchmarkReport { + candidate_count: outcome.run_summary.candidate_count, + succeeded_count: outcome.run_summary.succeeded_count, + content_bytes, + enumeration_ms, + content_read_ms, + throughput_mib_per_sec, + }) +} + /// Independent ground truth for [`self_test_vss_query_metadata`]: walk /// `root` live via `std::fs` (bypassing VSS/the daemon entirely) and sum /// the size of every regular file whose extension case-insensitively diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 6a43a9983..410944df3 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -29,6 +29,11 @@ //! # extension-filtered query against //! # an existing directory, verified //! # against a ground-truth disk walk +//! uffs-content --self-test-reader-benchmark [query] # Elevated: measure real +//! # content-read throughput. is +//! # "all" (every local NTFS drive) or a +//! # comma-separated list; [query] defaults +//! # to "*" //! ``` // Reserved for the wire types the bin will emit once job intake is wired @@ -96,6 +101,9 @@ fn main() { if let Some((root, extension)) = self_test_vss_query_args(&args) { std::process::exit(run_self_test_vss_query(&root, &extension)); } + if let Some((roots, query)) = self_test_reader_benchmark_args(&args) { + std::process::exit(run_self_test_reader_benchmark(&roots, &query)); + } if args.iter().any(|arg| arg == "--serve") { std::process::exit(run_serve()); } @@ -239,3 +247,82 @@ fn run_self_test_vss_query(root: &std::path::Path, extension: &str) -> i32 { const fn run_self_test_vss_query(_root: &std::path::Path, _extension: &str) -> i32 { 1 } + +/// Return the `(roots, query)` arguments following +/// `--self-test-reader-benchmark`, if present. `roots` is `all` (case +/// insensitive, resolved to an empty `Vec` — every local NTFS drive, see +/// [`uffs_content::job::vss_job::run_vss_job`]) or a comma-separated +/// path list; `query` defaults to `"*"` if omitted. +#[cfg(windows)] +fn self_test_reader_benchmark_args(args: &[String]) -> Option<(Vec, String)> { + let flag_index = args + .iter() + .position(|arg| arg == "--self-test-reader-benchmark")?; + let roots_arg = args.get(flag_index + 1)?; + let roots = if roots_arg.eq_ignore_ascii_case("all") { + Vec::new() + } else { + roots_arg.split(',').map(std::path::PathBuf::from).collect() + }; + let query = args + .get(flag_index + 2) + .cloned() + .unwrap_or_else(|| "*".to_owned()); + Some((roots, query)) +} + +/// Non-Windows stub: `--self-test-reader-benchmark` needs a real VSS +/// snapshot, which doesn't exist on this platform. +#[cfg(not(windows))] +const fn self_test_reader_benchmark_args( + _args: &[String], +) -> Option<(Vec, String)> { + None +} + +/// Run [`uffs_content::job::self_test::self_test_reader_benchmark`] and +/// print the measured content-read throughput. Returns the process exit +/// code (`0` pass, `1` fail). +#[cfg(windows)] +#[expect( + clippy::print_stderr, + reason = "one-shot CLI diagnostic invoked before any tracing subscriber exists" +)] +fn run_self_test_reader_benchmark(roots: &[std::path::PathBuf], query: &str) -> i32 { + match uffs_content::job::self_test::self_test_reader_benchmark(roots, query) { + Ok(report) => { + #[expect( + clippy::cast_precision_loss, + reason = "diagnostic-only display value, not computed against further" + )] + #[expect( + clippy::float_arithmetic, + reason = "diagnostic-only unit conversion for a printed benchmark report" + )] + let content_mib = report.content_bytes as f64 / (1_024.0_f64 * 1_024.0_f64); + eprintln!( + "PASS: {} candidates ({} succeeded) — {:.2} MiB content-read in {} ms \ + ({:.2} MiB/s); enumeration+manifest: {} ms", + report.candidate_count, + report.succeeded_count, + content_mib, + report.content_read_ms, + report.throughput_mib_per_sec, + report.enumeration_ms, + ); + 0 + } + Err(err) => { + eprintln!("FAIL: {err:#}"); + 1 + } + } +} + +/// Non-Windows stub, matching [`self_test_reader_benchmark_args`] always +/// returning `None` there (so this is unreachable in practice, but kept +/// for a symmetrical `#[cfg]` shape). +#[cfg(not(windows))] +const fn run_self_test_reader_benchmark(_roots: &[std::path::PathBuf], _query: &str) -> i32 { + 1 +} From 2cd57e45097384c5fbb883ffd69118f83be879b2 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:20:42 -0700 Subject: [PATCH 51/98] feat(content-reader): serve multiple concurrent pipe connections pipe_server.rs previously accepted exactly one Coordinator connection and exited once it closed; every request's blocking disk read also ran inline on the pipe server's own current-thread runtime, so reads serialized fully regardless of how many drives a job touched. Now the accept loop runs for the process's whole lifetime (this process is killed externally by the Coordinator once a job finishes, so there's nothing to detect "last connection closed" for), spawning one task per connection, and each request's disk I/O runs via spawn_blocking. This is what lets the next step (a connection per leased drive on the Coordinator side) actually achieve concurrent reads across drives instead of everything queueing behind one connection. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-reader/src/main.rs | 5 + .../src/reader/pipe_server.rs | 122 ++++++++++++++---- 2 files changed, 101 insertions(+), 26 deletions(-) diff --git a/crates/uffs-content-reader/src/main.rs b/crates/uffs-content-reader/src/main.rs index 92bb85b4b..f69bb43ed 100644 --- a/crates/uffs-content-reader/src/main.rs +++ b/crates/uffs-content-reader/src/main.rs @@ -17,6 +17,11 @@ //! uffs-content-reader --device = [--device ...] //! ``` +// `reader::pipe_server` needs `alloc::sync::Arc`, so bring the crate +// into scope (Windows-only, matching `uffs-broker`'s own convention). +#[cfg(windows)] +extern crate alloc; + // `reader::read_plan` is cross-platform (pure logic, no I/O — see its // own doc comment); the rest of `reader` (`logical`, `pipe_server`, // dispatch) is `#[cfg(windows)]`-gated within the module itself, so diff --git a/crates/uffs-content-reader/src/reader/pipe_server.rs b/crates/uffs-content-reader/src/reader/pipe_server.rs index 696bba9ef..ddaf48607 100644 --- a/crates/uffs-content-reader/src/reader/pipe_server.rs +++ b/crates/uffs-content-reader/src/reader/pipe_server.rs @@ -3,23 +3,38 @@ //! Named-pipe server for [`READER_PIPE_NAME`]. //! -//! Accepts exactly one client connection (the Coordinator that spawned -//! this process) and serves framed `ReadRequest`/`ReadResponse` -//! messages on it until the Coordinator disconnects, then this process -//! exits — mirrors the one-Reader-per-job lifecycle -//! `uffs-ingest-implementation-plan.md` describes. +//! Accepts every connection the Coordinator opens — one per leased +//! drive, for read parallelism (see the local-only content-engine +//! architecture doc's Reader-parallelism section) — and serves framed +//! `ReadRequest`/`ReadResponse` messages on each independently, until +//! that connection's own peer disconnects. Each request's disk I/O runs +//! via [`tokio::task::spawn_blocking`], so concurrent connections' +//! reads actually execute in parallel instead of serializing behind one +//! current-thread runtime. +//! +//! This process has no "last connection closed" lifecycle logic of its +//! own to worry about: the Coordinator kills it directly +//! (`ContentReader::shutdown`) once the job is done, mirroring +//! `uffs-ingest-implementation-plan.md`'s one-Reader-per-job lifecycle — +//! the accept loop below just runs for as long as the process does, +//! same shape as `uffs-content::serve`'s own command-pipe accept loop. +use alloc::sync::Arc; use std::collections::HashMap; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe::{NamedPipeServer, PipeMode, ServerOptions}; -use uffs_content_reader_protocol::{READER_PIPE_NAME, ReadRequest, ReadResponse}; +use uffs_content_reader_protocol::{READER_PIPE_NAME, ReadRequest, ReadResponse, ReaderErrorCode}; /// Matches the Coordinator-side `MAX_REQUEST_BYTES`-style bound used /// for the Broker's Snapshot Manager pipe — a generous ceiling for this /// small, narrow API. const MAX_REQUEST_BYTES: u32 = 64 * 1024; +/// How long to back off before retrying pipe-instance creation after a +/// transient failure. +const PIPE_RETRY_BACKOFF: core::time::Duration = core::time::Duration::from_millis(100); + /// Run the Reader's pipe server for the process's whole lifetime. /// /// # Errors @@ -31,48 +46,103 @@ pub(crate) fn run(devices: &HashMap) -> anyhow::Result<()> { rt.block_on(serve(devices)) } -/// Bind, accept one connection, and serve requests on it until the -/// Coordinator disconnects — the async body of [`run`]. +/// Accept every connection the Coordinator opens, spawning one task per +/// connection so multiple drives' reads run concurrently — the async +/// body of [`run`]. +#[expect( + clippy::infinite_loop, + reason = "process-lifetime accept loop: this process is killed externally by the \ + Coordinator once the job is done, matching uffs-content::serve's own \ + command-pipe accept loop" +)] async fn serve(devices: &HashMap) -> anyhow::Result<()> { let pipe_name = uffs_security::pipe::PipeName::parse(READER_PIPE_NAME) .map_err(|err| anyhow::anyhow!("invalid READER_PIPE_NAME: {err}"))?; let sd = uffs_security::pipe::OwnerOnlySd::for_current_user() .map_err(|err| anyhow::anyhow!("owner-only DACL build failed: {err}"))?; + let shared_devices = Arc::new(devices.clone()); - let mut server = create_server(&pipe_name, &sd, /* first= */ true)?; - tracing::info!(pipe = READER_PIPE_NAME, "Reader pipe listening"); - server.connect().await?; - tracing::info!("Coordinator connected"); + let mut first_instance = true; + loop { + let mut server = match create_server(&pipe_name, &sd, first_instance) { + Ok(server) => server, + Err(err) => { + tracing::warn!(error = %err, "pipe instance unavailable; retrying shortly"); + tokio::time::sleep(PIPE_RETRY_BACKOFF).await; + continue; + } + }; + first_instance = false; + if server.connect().await.is_err() { + continue; + } + tracing::info!("Coordinator connected"); - serve_requests(&mut server, devices).await + let devices_for_connection = Arc::clone(&shared_devices); + tokio::spawn(async move { + serve_requests(&mut server, &devices_for_connection).await; + }); + } } -/// Drain requests off `server` until the Coordinator disconnects (or -/// sends a malformed request, which also ends the connection — see -/// [`read_one_request`]). Extracted from [`serve`] to keep it under -/// clippy's cognitive-complexity budget. -async fn serve_requests( - server: &mut NamedPipeServer, - devices: &HashMap, -) -> anyhow::Result<()> { +/// Drain requests off `server` until the Coordinator disconnects that +/// connection (or sends a malformed request, which also ends it — see +/// [`read_one_request`]). Every request's blocking disk I/O runs via +/// `spawn_blocking`, so a slow read on one connection never blocks any +/// other connection. +async fn serve_requests(server: &mut NamedPipeServer, devices: &Arc>) { loop { match read_one_request(server).await { Ok(Some(request)) => { - let response = super::dispatch_request(&request, devices); - write_one_response(server, &response).await?; + if !respond_to_one_request(server, request, devices).await { + return; + } } Ok(None) => { - tracing::info!("Coordinator disconnected — exiting"); - return Ok(()); + tracing::info!("Coordinator disconnected this connection"); + return; } Err(err) => { tracing::warn!(error = %err, "malformed request; closing connection"); - return Ok(()); + return; } } } } +/// Dispatch one request and write its response. Returns `false` if the +/// connection should close (a write failure — the read side already +/// handles its own EOF/malformed-request cases in [`serve_requests`]). +async fn respond_to_one_request( + server: &mut NamedPipeServer, + request: ReadRequest, + devices: &Arc>, +) -> bool { + let response = dispatch_request_blocking(request, Arc::clone(devices)).await; + if let Err(err) = write_one_response(server, &response).await { + tracing::warn!(error = %err, "failed to write response; closing connection"); + return false; + } + true +} + +/// Run [`super::dispatch_request`]'s blocking disk I/O on tokio's +/// blocking thread pool, turning a panic there into a +/// [`ReaderErrorCode::InternalError`] response rather than propagating +/// it (a single request's panic must not tear down the whole +/// connection, matching `dispatch_request`'s own never-panics contract). +async fn dispatch_request_blocking( + request: ReadRequest, + devices: Arc>, +) -> ReadResponse { + tokio::task::spawn_blocking(move || super::dispatch_request(&request, &devices)) + .await + .unwrap_or_else(|join_err| ReadResponse::Error { + code: ReaderErrorCode::InternalError, + message: format!("read task panicked: {join_err}"), + }) +} + /// Read one `[u32 LE length][payload]`-framed [`ReadRequest`], or `Ok(None)` /// on a clean EOF (the Coordinator disconnected between requests). async fn read_one_request(server: &mut NamedPipeServer) -> anyhow::Result> { From 3cd5c6501d852ee0ec506b9c0ab222036626d339 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:24:57 -0700 Subject: [PATCH 52/98] feat(content): reader client opens one connection per leased drive ContentReader::spawn previously opened a single connection shared for the whole job's content-reading phase; every read (regardless of which drive its candidate lived on) serialized behind one Mutex. It now opens one connection per (device_path, lease_id) pair and routes each read_at() call to the connection matching its snapshot_lease_id, so reads for different drives never contend on the same mutex. Paired with the previous commit (Reader server now serves multiple concurrent connections via spawn_blocking), this is what actually lets a multi-drive job's content reads run in parallel across drives instead of serializing behind one shared connection. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/reader_client.rs | 69 ++++++++++++++------ 1 file changed, 48 insertions(+), 21 deletions(-) diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index ae1eed0d0..69833d24a 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -5,10 +5,22 @@ //! //! Spawns `uffs-content-reader --device = ...` //! once per job — mirrors [`super::ephemeral_daemon`]'s spawn model, but -//! for the content-reading phase rather than target selection — connects -//! to its fixed `READER_PIPE_NAME`, and sends framed -//! `ReadRequest`/`ReadResponse` messages over that one persistent -//! connection for the whole job. +//! for the content-reading phase rather than target selection — and +//! opens **one persistent connection per leased drive** to its fixed +//! `READER_PIPE_NAME`, sending framed `ReadRequest`/`ReadResponse` +//! messages over whichever connection matches a read's +//! `snapshot_lease_id`. +//! +//! One connection per drive, not one shared connection for the whole +//! job: a `Mutex`-guarded connection serializes every read that uses +//! it, so a single shared connection would serialize reads for +//! genuinely independent physical drives behind each other for no +//! reason. Keying connections by `snapshot_lease_id` means reads for +//! different drives never contend on the same mutex, while reads for +//! the *same* drive still serialize behind that drive's own +//! connection (reasonable — extending to more than one connection per +//! drive is a small follow-up if a single volume's own queue depth +//! turns out to matter). //! //! Mirrors [`super::snapshot_client`]'s connect style (plain //! `std::fs::OpenOptions` + `Read`/`Write`) and wire framing @@ -17,6 +29,7 @@ use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; +use std::collections::HashMap; use std::io::{Read as _, Write as _}; use std::process::{Child, Command, Stdio}; use std::sync::Mutex; @@ -36,19 +49,21 @@ const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(10); /// Delay between connect retries. const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(50); -/// A running `uffs-content-reader` process + its live pipe connection, -/// held for the whole job's content-reading phase. +/// A running `uffs-content-reader` process + its live pipe connections +/// (one per leased drive), held for the whole job's content-reading +/// phase. pub(crate) struct ContentReader { /// The spawned `uffs-content-reader` child process. Killed on /// [`Self::shutdown`]/[`Drop`] — this process spawned it, so a /// direct kill is simplest and correct (mirrors /// [`super::ephemeral_daemon::EphemeralDaemon::shutdown`]). child: Child, - /// The one persistent pipe connection this job's whole - /// content-reading phase shares. `Mutex`-guarded so `read_at` can - /// take `&self` (the `ContentSource` trait's shape) while still - /// mutating the connection. - pipe: Mutex, + /// One persistent pipe connection per leased drive, keyed by + /// `snapshot_lease_id` — see the module doc comment for why this is + /// per-drive rather than one shared connection. `Mutex`-guarded so + /// `read_at` can take `&self` (the `ContentSource` trait's shape) + /// while still mutating a connection. + connections: HashMap>, /// This job's id, echoed into every `ReadRequest`. job_id: [u8; 16], /// Monotonically increasing nonce for request/response correlation. @@ -57,12 +72,13 @@ pub(crate) struct ContentReader { impl ContentReader { /// Spawn `uffs-content-reader --device = - /// ...` for every pair in `devices`, and connect to it. + /// ...` for every pair in `devices`, and open one connection per + /// device. /// /// # Errors /// Returns an error if `devices` is empty, the binary can't be - /// spawned, or the pipe never comes up within - /// [`CONNECT_RETRY_BUDGET`]. + /// spawned, or any of the `devices.len()` connections never comes up + /// within [`CONNECT_RETRY_BUDGET`]. pub(crate) fn spawn(job_id: [u8; 16], devices: &[(String, u64)]) -> Result { anyhow::ensure!( !devices.is_empty(), @@ -84,11 +100,16 @@ impl ContentReader { .spawn() .with_context(|| format!("failed to spawn {}", exe.display()))?; - let pipe = connect_with_retry()?; + let mut connections = HashMap::with_capacity(devices.len()); + for (_device_path, lease_id) in devices { + let pipe = connect_with_retry() + .with_context(|| format!("failed to open a connection for lease {lease_id}"))?; + connections.insert(*lease_id, Mutex::new(pipe)); + } Ok(Self { child, - pipe: Mutex::new(pipe), + connections, job_id, next_nonce: AtomicU64::new(1), }) @@ -129,7 +150,7 @@ impl ContentReader { request_nonce: self.next_nonce.fetch_add(1, Ordering::Relaxed), }; - match self.round_trip(&request)? { + match self.round_trip(snapshot_lease_id, &request)? { ReadResponse::Bytes { payload, .. } => Ok(payload), ReadResponse::Error { code, message } => { anyhow::bail!("Reader rejected read: {code:?}: {message}") @@ -138,10 +159,16 @@ impl ContentReader { } /// Send one framed [`ReadRequest`] and read back one framed - /// [`ReadResponse`], over this job's one persistent connection. - fn round_trip(&self, request: &ReadRequest) -> Result { - let Ok(mut pipe) = self.pipe.lock() else { - anyhow::bail!("content reader pipe mutex poisoned"); + /// [`ReadResponse`], over `snapshot_lease_id`'s own connection — + /// never contending with reads for a different drive. + fn round_trip(&self, snapshot_lease_id: u64, request: &ReadRequest) -> Result { + let connection = self.connections.get(&snapshot_lease_id).ok_or_else(|| { + anyhow::anyhow!( + "no content reader connection for snapshot_lease_id {snapshot_lease_id}" + ) + })?; + let Ok(mut pipe) = connection.lock() else { + anyhow::bail!("content reader pipe mutex poisoned (lease {snapshot_lease_id})"); }; write_framed_message(&mut pipe, &request.encode())?; let response_bytes = read_framed_message(&mut pipe)?; From f923b736da225d44c4397c3cab3e58552df37061 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:34:03 -0700 Subject: [PATCH 53/98] feat(content): read candidates concurrently, one batch per drive count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow::run_job previously read every candidate's content strictly one at a time on a single thread, so the two previous commits' reader parallelism (multi-connection server, per-drive client connections) had nothing to actually run concurrently against. run_job now takes a concurrency parameter (run_vss_job passes the number of leased drives) and reads candidates concurrency-at-a-time: each batch's candidates are read on their own std::thread::scope thread (content_source is now required to be Sync), but every batch's frames are still emitted strictly in original candidate order on the caller's own thread. emit_frame/frame_sequence/counters/failure_log are therefore untouched by the concurrency — no synchronization needed — and the overall frame order is byte-for-byte identical to the fully sequential (concurrency == 1) case, so crate::serve::stream::Grouped needs no changes: it never sees interleaved candidates. job::tests and the e2e dir-walk parity test now run at concurrency > 1 so the batching path itself gets real coverage, not just concurrency == 1. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/content_source.rs | 8 +- crates/uffs-content/src/job/tests.rs | 4 + crates/uffs-content/src/job/vss_job.rs | 5 + crates/uffs-content/src/job/workflow.rs | 356 ++++++++++++------ .../tests/e2e_dir_walk_parity_fake_reader.rs | 4 + 5 files changed, 255 insertions(+), 122 deletions(-) diff --git a/crates/uffs-content/src/job/content_source.rs b/crates/uffs-content/src/job/content_source.rs index 1659abdeb..848e680f1 100644 --- a/crates/uffs-content/src/job/content_source.rs +++ b/crates/uffs-content/src/job/content_source.rs @@ -17,7 +17,13 @@ use super::candidate_source::CandidateEntry; /// reads the live file directly with `std::fs`. See /// [`super::candidate_source::CandidateSource`] for why that's the right /// trade-off for this crate's own fast, cross-platform test harness. -pub trait ContentSource { +/// +/// `Sync`: `workflow::run_job` reads several candidates' content +/// concurrently (one `std::thread::scope` thread each, sharing one +/// `&dyn ContentSource` — see that module's "Concurrent reads, +/// sequential emission" doc section), so any implementation must +/// tolerate concurrent `read_at` calls from different threads. +pub trait ContentSource: Sync { /// Read up to `max_len` bytes starting at `offset` from `candidate`. /// /// `candidate_id` is the same id `manifest_builder::build_manifest` diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 769c2d18b..c46b7c452 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -148,6 +148,10 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { &DirWalkCandidateSource, &FsContentSource, run_dir.path(), + // >1 so this test also exercises the concurrent-read batching + // path (`read_candidate_batch`), not just the fully-sequential + // (`concurrency == 1`) case. + 4, |frame| { frames.push(frame); Ok(()) diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index f2cc18b54..70fa84ec9 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -80,11 +80,16 @@ where .context("failed to spawn the content reader")?; let content_source = VssContentSource::new(content_reader); + // One concurrent content-read per leased drive — see + // `workflow::run_job`'s "Concurrent reads, sequential emission" doc + // section for why this is safe/correct at any value. + let concurrency = resources.leases.len(); let result = run_job( &resolved_request, &candidate_source, &content_source, run_dir, + concurrency, emit_frame, ) .context("run_job failed"); diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 394258830..fb08faa2f 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -9,6 +9,22 @@ //! those traits' docs for what "swappable" means today (a real vs. fake //! backing). //! +//! # Concurrent reads, sequential emission +//! +//! Candidates are read `concurrency`-at-a-time (see [`run_job`]): each +//! batch's candidates are read on their own `std::thread::scope` thread +//! — concurrently, so reads for candidates on different drives (each +//! routed to its own connection by `reader_client::ContentReader`, see +//! that module's doc comment) actually overlap instead of serializing — +//! but every batch's frames are still *emitted* strictly in original +//! candidate order, on the caller's own thread, exactly matching the +//! fully-sequential emission order this function has always produced. +//! `emit_frame`/`frame_sequence`/`counters`/`failure_log` are therefore +//! still only ever touched from one thread; no synchronization was +//! added to any of them, and downstream consumers of the frame stream +//! (`crate::serve::stream::Grouped`) see exactly the same per-candidate- +//! contiguous ordering as the fully-sequential (`concurrency == 1`) case. +//! //! # Why `emit_frame` is a callback, not a returned `Vec` //! //! Earlier revisions of this function collected every emitted frame into @@ -74,6 +90,14 @@ pub struct JobOutcome { /// `JOB_END`) is passed to `emit_frame` in emission order as soon as it /// exists — see the module doc comment. /// +/// `concurrency` is how many candidates are read concurrently per batch +/// (see the module doc's "Concurrent reads, sequential emission" +/// section) — clamped to at least `1`. Pass the number of drives a job +/// actually leased (or `1` for the fully-sequential, deterministic-order +/// behavior tests rely on) — this function has no way to know that +/// itself, since drive leasing happens in the caller +/// (`super::vss_job::run_vss_job`). +/// /// # Errors /// Returns an [`io::Error`] for any filesystem failure enumerating /// candidates, writing the failure log, or finalizing the summary, or @@ -86,11 +110,13 @@ pub fn run_job( candidate_source: &dyn CandidateSource, content_source: &dyn ContentSource, run_dir: &Path, + concurrency: usize, mut emit_frame: F, ) -> io::Result where F: FnMut(Vec) -> io::Result<()>, { + let batch_size = concurrency.max(1_usize); let job_id = *uuid::Uuid::new_v4().as_bytes(); let source_id = source_id_bytes(&request.source_id); // No query filtering is wired up yet (see `JobRequest` docs) — every @@ -134,21 +160,82 @@ where let failures_path = run_dir.join(format!("run-{run_id}.failures.jsonl")); let mut failure_log = FailureLogWriter::open(&failures_path)?; - for (entry, &candidate_id) in entries.iter().zip(&built.candidate_ids) { - stream_one_candidate( - entry, - candidate_id, - content_source, - DEFAULT_MAX_CHUNK_BYTES, - &mut counters, - &mut failure_log, - job_id, - &mut frame_sequence, - &mut emit_frame, - )?; + let candidates: Vec<(&CandidateEntry, u64)> = entries + .iter() + .zip(built.candidate_ids.iter().copied()) + .collect(); + for batch in candidates.chunks(batch_size) { + let read_results = read_candidate_batch(batch, content_source, DEFAULT_MAX_CHUNK_BYTES); + for ((entry, candidate_id), read_result) in batch.iter().copied().zip(read_results) { + emit_candidate( + entry, + candidate_id, + read_result, + &mut counters, + &mut failure_log, + job_id, + &mut frame_sequence, + &mut emit_frame, + )?; + } } drop(failure_log); + emit_job_end( + &counters, + built.manifest_digest, + candidate_count, + &failures_path, + job_id, + &mut frame_sequence, + &mut emit_frame, + )?; + + let now_ms = unix_ms_now(); + let summary_path = run_dir.join(format!("run-{run_id}.summary.json")); + let run_summary = counters + .finalize(run_id, now_ms, now_ms) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + run_summary.finalize_to_disk(&summary_path)?; + + Ok(JobOutcome { + job_id, + manifest_bytes: built.bytes, + run_summary, + }) +} + +/// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and +/// advancing the next `frame_sequence`. +fn encode_frame( + job_id: [u8; 16], + frame_sequence: &mut u64, + frame_type: FrameType, + payload: &[u8], +) -> Vec { + let envelope = FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id, + frame_sequence: *frame_sequence, + }; + *frame_sequence += 1; + envelope.encode(payload) +} + +/// Build and emit `JOB_END`: `job_status` is derived from `counters`, +/// `failure_bucket_id`/`outcome_ledger_digest` from the failure log file +/// at `failures_path`. +fn emit_job_end( + counters: &RunCounters, + manifest_digest: Digest, + candidate_count: u64, + failures_path: &Path, + job_id: [u8; 16], + frame_sequence: &mut u64, + emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, +) -> io::Result<()> { let job_status = if counters.failed_retryable_count == 0 && counters.failed_terminal_count == 0 && counters.deferred_manual_count == 0 @@ -158,7 +245,7 @@ where JobStatus::CompletedWithFailures }; - let failure_log_bytes = std::fs::read(&failures_path).unwrap_or_default(); + let failure_log_bytes = std::fs::read(failures_path).unwrap_or_default(); let failure_bucket_id = failures_path .file_name() .map(|name| name.to_string_lossy().into_owned().into_bytes()) @@ -175,7 +262,7 @@ where acknowledged_success_count: counters.succeeded_count, logical_bytes_succeeded: counters.logical_bytes_succeeded, failure_bucket_id, - manifest_digest: built.manifest_digest, + manifest_digest, outcome_ledger_digest: digest(&failure_log_bytes), job_status, }; @@ -184,60 +271,142 @@ where .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; emit_frame(encode_frame( job_id, - &mut frame_sequence, + frame_sequence, FrameType::JobEnd, &job_end_bytes, - ))?; + )) +} - let now_ms = unix_ms_now(); - let summary_path = run_dir.join(format!("run-{run_id}.summary.json")); - let run_summary = counters - .finalize(run_id, now_ms, now_ms) - .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; - run_summary.finalize_to_disk(&summary_path)?; +/// One candidate's content, fully read into memory by +/// [`read_candidate_batch`]/[`read_one_candidate`] and consumed by +/// [`emit_candidate`]. Bounded to exactly one candidate's content per +/// instance — never the whole batch or job — since the batch itself +/// bounds how many of these exist in memory at once (see the module +/// doc's "Concurrent reads, sequential emission" section). +struct CandidateContent { + /// Every `CONTENT_CHUNK` this candidate's content produced, in order. + chunks: Vec, + /// Sum of every chunk's payload length. + total_read: u64, + /// BLAKE3 digest over every chunk's payload, in order. + digest: Digest, + /// Set only if a read failed partway through; `None` means every + /// byte up to `entry.logical_size` was read successfully. + read_error: Option, +} - Ok(JobOutcome { - job_id, - manifest_bytes: built.bytes, - run_summary, +/// Read every candidate in `batch` concurrently — one +/// [`std::thread::scope`] thread each — returning each candidate's +/// [`CandidateContent`] in the same order as `batch` itself. Bounds how +/// far ahead of frame emission reading can get to `batch.len()` +/// candidates' content, never the whole job's. +fn read_candidate_batch( + batch: &[(&CandidateEntry, u64)], + content_source: &dyn ContentSource, + max_chunk_bytes: u32, +) -> Vec { + std::thread::scope(|scope| { + // The intermediate `Vec` is semantically required, not needless: + // it forces every thread to be spawned before any is joined. + // Fusing this into one `.map(spawn).map(join)` chain would join + // each thread immediately after spawning it, one at a time — + // exactly the sequential behavior this function exists to avoid. + #[expect( + clippy::needless_collect, + reason = "see the comment above — collecting here is what makes every spawn \ + happen before any join, not an accident" + )] + let handles: Vec<_> = batch + .iter() + .map(|&(entry, candidate_id)| { + scope.spawn(move || { + read_one_candidate(entry, candidate_id, content_source, max_chunk_bytes) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| { + handle + .join() + .unwrap_or_else(|panic_payload| CandidateContent { + chunks: Vec::new(), + total_read: 0, + digest: IncrementalDigest::new().finalize(), + read_error: Some(io::Error::other(format!( + "content-read thread panicked: {panic_payload:?}" + ))), + }) + }) + .collect() }) } -/// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and -/// advancing the next `frame_sequence`. -fn encode_frame( - job_id: [u8; 16], - frame_sequence: &mut u64, - frame_type: FrameType, - payload: &[u8], -) -> Vec { - let envelope = FrameEnvelope { - protocol_version: 2, - frame_type, - flags: 0, - job_id, - frame_sequence: *frame_sequence, - }; - *frame_sequence += 1; - envelope.encode(payload) +/// Read one candidate's content into memory, up to `entry.logical_size` +/// or the first read error. Never touches `emit_frame`/`frame_sequence`/ +/// `counters`/`failure_log` — those stay single-threaded, touched only +/// by [`emit_candidate`] afterward. +fn read_one_candidate( + entry: &CandidateEntry, + candidate_id: u64, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, +) -> CandidateContent { + let mut hasher = IncrementalDigest::new(); + let mut offset = 0_u64; + let mut chunk_sequence = 0_u64; + let mut total_read = 0_u64; + let mut chunks = Vec::new(); + let mut read_error = None; + + while offset < entry.logical_size { + match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { + Ok(bytes) if bytes.is_empty() => break, + Ok(bytes) => { + let read_len = len_as_u64(bytes.len()); + hasher.update(&bytes); + total_read += read_len; + chunks.push(ContentChunk { + candidate_id, + chunk_sequence, + logical_offset: offset, + logical_length: read_len, + payload: bytes, + }); + offset += read_len; + chunk_sequence += 1; + } + Err(err) => { + read_error = Some(err); + break; + } + } + } + + CandidateContent { + chunks, + total_read, + digest: hasher.finalize(), + read_error, + } } -/// Streams one candidate's content, emitting `FILE_BEGIN`, zero or more -/// `CONTENT_CHUNK`s, and exactly one of `FILE_END`/`FILE_FAILED` through -/// `emit_frame` as each is produced — never buffering more than one -/// chunk's worth of this candidate's content at a time. Updates -/// `counters` and appends to `failure_log` for a non-success outcome. +/// Emit one already-read candidate's `FILE_BEGIN`, its `CONTENT_CHUNK`s, +/// and its terminal frame (`FILE_END`/`FILE_FAILED`), in that order, on +/// the caller's own thread — see the module doc's "Concurrent reads, +/// sequential emission" section for why this step is never +/// parallelized. Updates `counters` and appends to `failure_log` for a +/// non-success outcome. #[expect( clippy::too_many_arguments, reason = "the alternative is a bespoke context struct bundling job_id/frame_sequence/ \ emit_frame purely to satisfy this lint, for a private helper with exactly one \ call site; not worth the indirection" )] -fn stream_one_candidate( +fn emit_candidate( entry: &CandidateEntry, candidate_id: u64, - content_source: &dyn ContentSource, - max_chunk_bytes: u32, + content: CandidateContent, counters: &mut RunCounters, failure_log: &mut FailureLogWriter, job_id: [u8; 16], @@ -263,22 +432,22 @@ fn stream_one_candidate( &file_begin.encode(), ))?; - let (chunk_count, total_read, content_digest, read_error) = stream_content_chunks( - entry, - candidate_id, - content_source, - max_chunk_bytes, - job_id, - frame_sequence, - emit_frame, - )?; + let chunk_count = len_as_u64(content.chunks.len()); + for chunk in &content.chunks { + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::ContentChunk, + &chunk.encode(), + ))?; + } - match read_error { + match content.read_error { None => { let file_end = FileEnd { candidate_id, - total_logical_bytes: total_read, - content_digest: Some(content_digest), + total_logical_bytes: content.total_read, + content_digest: Some(content.digest), read_mode: ReadMode::LogicalSnapshot, chunk_count, elapsed_ms: 0, @@ -290,7 +459,7 @@ fn stream_one_candidate( FrameType::FileEnd, &file_end.encode(), ))?; - counters.record_succeeded(total_read); + counters.record_succeeded(content.total_read); } Some(err) => { let os_error_code = err.raw_os_error().map(i64::from); @@ -302,7 +471,7 @@ fn stream_one_candidate( error_code: ErrorCode::ReadIoTransient, os_error_code, retry_class: RetryClass::RetryNewSnapshot, - bytes_emitted_before_failure: total_read, + bytes_emitted_before_failure: content.total_read, message: message.clone(), }; emit_frame(encode_frame( @@ -319,7 +488,7 @@ fn stream_one_candidate( ErrorCode::ReadIoTransient, os_error_code, RetryClass::RetryNewSnapshot, - total_read, + content.total_read, message, ))?; } @@ -328,61 +497,6 @@ fn stream_one_candidate( Ok(()) } -/// Read and emit every `CONTENT_CHUNK` frame for one candidate, in -/// order, up to `entry.logical_size` or the first read error — never -/// buffering more than one chunk's worth of content at a time (the -/// running digest is incremental; see [`IncrementalDigest`]). -/// -/// Returns `(chunk_count, total_read, digest, read_error)`; `read_error` -/// is `Some` only if a read failed partway, letting the caller decide -/// the candidate's terminal outcome. -fn stream_content_chunks( - entry: &CandidateEntry, - candidate_id: u64, - content_source: &dyn ContentSource, - max_chunk_bytes: u32, - job_id: [u8; 16], - frame_sequence: &mut u64, - emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, -) -> io::Result<(u64, u64, Digest, Option)> { - let mut hasher = IncrementalDigest::new(); - let mut offset = 0_u64; - let mut chunk_sequence = 0_u64; - let mut total_read = 0_u64; - let mut read_error = None; - - while offset < entry.logical_size { - match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { - Ok(bytes) if bytes.is_empty() => break, - Ok(bytes) => { - let read_len = len_as_u64(bytes.len()); - hasher.update(&bytes); - total_read += read_len; - let chunk = ContentChunk { - candidate_id, - chunk_sequence, - logical_offset: offset, - logical_length: read_len, - payload: bytes, - }; - emit_frame(encode_frame( - job_id, - frame_sequence, - FrameType::ContentChunk, - &chunk.encode(), - ))?; - offset += read_len; - chunk_sequence += 1; - } - Err(err) => { - read_error = Some(err); - break; - } - } - } - Ok((chunk_sequence, total_read, hasher.finalize(), read_error)) -} - /// Deterministically derives a manifest `source_id` from an arbitrary /// caller-supplied string, truncating a BLAKE3 digest to 16 bytes (this /// avoids requiring the `uuid` crate's `v5` feature workspace-wide for diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index 29dfca8dc..c1e6d9086 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -98,6 +98,10 @@ mod tests { &DirWalkCandidateSource, &FsContentSource, run_dir.path(), + // >1, and smaller than the fixture's own file count, so this + // parity check also exercises multiple concurrent-read + // batches (`read_candidate_batch`), not just one. + 3, |frame| { frames.push(frame); Ok(()) From 62a8f51c29f21f9501703a950fe5dda154f87b4c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:16:03 -0700 Subject: [PATCH 54/98] fix(content): skip VSS-unsupported drives instead of aborting the job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real hardware testing hit VSS_E_VOLUME_NOT_SUPPORTED (0x8004230C) on a USB drive during an "all drives" run, which aborted the whole job before drives later in enumeration order (including ones that would have succeeded) ever got a lease attempt. Add a real hresult field to SnapshotManagerResponse::Error (and VssError::CreateFailed upstream of it) so the Broker's actual VSS failure code reaches the Coordinator as structured data instead of being embedded only in free-text message. vss_orchestrator now downcasts create_lease's error, and for VSS_E_VOLUME_NOT_SUPPORTED specifically it warn-logs and skips that one drive instead of releasing every lease and failing the job; any other lease failure still aborts as before. Non-NTFS drives are unaffected — they're already filtered out earlier by uffs_mft::detect_ntfs_drives. Co-Authored-By: Claude Sonnet 5 --- .../src/snapshot_manager/codec.rs | 32 +++++++++++ .../src/snapshot_manager/mod.rs | 23 ++++++-- .../src/snapshot_manager/tests.rs | 13 +++++ .../src/broker/snapshot_manager/mod.rs | 13 ++++- .../src/broker/snapshot_manager/vss_helper.rs | 54 +++++++++++-------- crates/uffs-broker/src/snapshot_lease.rs | 13 ++++- .../uffs-broker/src/snapshot_lease/tests.rs | 10 +++- .../uffs-content/src/job/snapshot_client.rs | 52 ++++++++++++++++-- .../uffs-content/src/job/vss_orchestrator.rs | 48 ++++++++++++++--- 9 files changed, 219 insertions(+), 39 deletions(-) diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs b/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs index 3c7241357..04148bd6b 100644 --- a/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs +++ b/crates/uffs-broker-protocol/src/snapshot_manager/codec.rs @@ -124,6 +124,21 @@ impl<'a> Reader<'a> { Ok(self.read_u64_le()?.cast_signed()) } + /// Read a little-endian `i32`. + pub(crate) fn read_i32_le(&mut self) -> Result { + Ok(self.read_u32_le()?.cast_signed()) + } + + /// Read a presence-byte-prefixed optional `i32`: `0` means absent, + /// `1` means present followed by a little-endian `i32`. + pub(crate) fn read_optional_i32(&mut self) -> Result, SnapshotProtocolError> { + if self.read_u8()? == 0 { + Ok(None) + } else { + Ok(Some(self.read_i32_le()?)) + } + } + /// Read a `u32`-length-prefixed byte string, rejecting (before any /// allocation) a declared length exceeding `max_len` or the bytes /// actually remaining. @@ -183,6 +198,23 @@ pub(crate) fn write_i64_le(out: &mut Vec, value: i64) { out.extend_from_slice(&value.cast_unsigned().to_le_bytes()); } +/// Append a little-endian `i32` to `out`. +pub(crate) fn write_i32_le(out: &mut Vec, value: i32) { + out.extend_from_slice(&value.cast_unsigned().to_le_bytes()); +} + +/// Append a presence-byte-prefixed optional `i32` to `out`: `0` for +/// `None`, or `1` followed by the little-endian `i32` for `Some`. +pub(crate) fn write_optional_i32(out: &mut Vec, value: Option) { + match value { + None => out.push(0), + Some(present) => { + out.push(1); + write_i32_le(out, present); + } + } +} + /// Append a `u32`-length-prefixed byte string to `out`. pub(crate) fn write_bytes_u32_prefixed(out: &mut Vec, bytes: &[u8]) { #[expect( diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs b/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs index 8614ee7eb..6ce0d7129 100644 --- a/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs +++ b/crates/uffs-broker-protocol/src/snapshot_manager/mod.rs @@ -22,7 +22,7 @@ mod codec; mod messages; pub use codec::SnapshotProtocolError; -use codec::{Reader, write_i64_le, write_string_u16_prefixed}; +use codec::{Reader, write_i64_le, write_optional_i32, write_string_u16_prefixed}; pub use messages::{ CreateSnapshotLease, CreateSnapshotLeaseResult, DuplicateSnapshotHandle, QuerySnapshotLease, ReleaseSnapshotLease, RenewSnapshotLease, SnapshotLeaseState, SnapshotLeaseStatus, @@ -149,6 +149,13 @@ pub enum SnapshotManagerResponse { Error { /// Stable error code. code: SnapshotManagerErrorCode, + /// The underlying `HRESULT`, when the failure came from a VSS + /// call and one is available (e.g. `VSS_E_VOLUME_NOT_SUPPORTED` + /// for a [`SnapshotManagerErrorCode::SnapshotCreateFailed`] on a + /// volume VSS doesn't support, such as removable media) — lets + /// callers distinguish specific, permanent VSS failure reasons + /// from `message`'s free text instead of string-matching it. + hresult: Option, /// Human-readable diagnostic message. message: String, }, @@ -192,9 +199,14 @@ impl SnapshotManagerResponse { out.push(response_tag::STATUS); out.extend_from_slice(&status.encode()); } - Self::Error { code, message } => { + Self::Error { + code, + hresult, + message, + } => { out.push(response_tag::ERROR); out.push(code.encode()); + write_optional_i32(&mut out, *hresult); write_string_u16_prefixed(&mut out, message); } } @@ -226,8 +238,13 @@ impl SnapshotManagerResponse { value: u64::from(byte), } })?; + let hresult = reader.read_optional_i32()?; let message = reader.read_string_u16_prefixed("message", MAX_MESSAGE_BYTES)?; - Ok(Self::Error { code, message }) + Ok(Self::Error { + code, + hresult, + message, + }) } other => Err(SnapshotProtocolError::UnknownDiscriminant { field: "response_tag", diff --git a/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs b/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs index 21b95ea07..3ad134edd 100644 --- a/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs +++ b/crates/uffs-broker-protocol/src/snapshot_manager/tests.rs @@ -153,6 +153,7 @@ fn status_response_round_trips_for_every_state() { fn error_response_round_trips() { let wrapped = SnapshotManagerResponse::Error { code: SnapshotManagerErrorCode::LeaseNotFound, + hresult: None, message: "no such lease".to_owned(), }; let bytes = wrapped.encode(); @@ -160,6 +161,18 @@ fn error_response_round_trips() { assert_eq!(decoded, wrapped); } +#[test] +fn error_response_with_hresult_round_trips() { + let wrapped = SnapshotManagerResponse::Error { + code: SnapshotManagerErrorCode::SnapshotCreateFailed, + hresult: Some(0x8004_230C_u32.cast_signed()), + message: "stage=6 hresult=0x8004230c: AddToSnapshotSet failed".to_owned(), + }; + let bytes = wrapped.encode(); + let decoded = SnapshotManagerResponse::decode(&bytes).unwrap(); + assert_eq!(decoded, wrapped); +} + #[test] fn request_decode_rejects_unknown_tag() { let bytes = vec![0xFF]; diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index 21edc6033..c10d111a7 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -180,6 +180,7 @@ fn handle_one_request(pipe: HANDLE, manager: &SnapshotLeaseManager dispatch_request(request, manager), Err(decode_err) => SnapshotManagerResponse::Error { code: SnapshotManagerErrorCode::InternalError, + hresult: None, message: format!("malformed request: {decode_err}"), }, }; @@ -290,6 +291,7 @@ fn handle_duplicate( else { return SnapshotManagerResponse::Error { code: SnapshotManagerErrorCode::ReaderIdentityRejected, + hresult: None, message: "could not open the approved reader process".to_owned(), }; }; @@ -297,6 +299,7 @@ fn handle_duplicate( if !verify_reader_identity(reader_exe.as_deref()) { return SnapshotManagerResponse::Error { code: SnapshotManagerErrorCode::ReaderIdentityRejected, + hresult: None, message: "reader process failed identity verification".to_owned(), }; } @@ -305,6 +308,7 @@ fn handle_duplicate( Ok(()) => SnapshotManagerResponse::Duplicated, Err(err) => SnapshotManagerResponse::Error { code: SnapshotManagerErrorCode::InternalError, + hresult: None, message: err.to_string(), }, } @@ -369,13 +373,20 @@ fn lease_error_response(err: &LeaseError) -> SnapshotManagerResponse { LeaseError::Vss(VssError::InvalidVolume(_)) => { SnapshotManagerErrorCode::VolumeValidationFailed } - LeaseError::Vss(VssError::CreateFailed(_)) => { + LeaseError::Vss(VssError::CreateFailed { .. }) => { SnapshotManagerErrorCode::SnapshotCreateFailed } LeaseError::Vss(VssError::DeleteFailed(_)) => SnapshotManagerErrorCode::InternalError, }; + let hresult = match err { + LeaseError::Vss(VssError::CreateFailed { hresult, .. }) => *hresult, + LeaseError::NotFound + | LeaseError::NotActive + | LeaseError::Vss(VssError::InvalidVolume(_) | VssError::DeleteFailed(_)) => None, + }; SnapshotManagerResponse::Error { code, + hresult, message: err.to_string(), } } diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index fd4777a5c..52910bb45 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -294,12 +294,14 @@ impl WindowsVssProvider { stage, hresult, message, - } => Err(VssError::CreateFailed(format!( - "stage={stage} hresult={hresult:#x}: {message}" - ))), - HelperEvent::Released | HelperEvent::Pong => Err(VssError::CreateFailed( - "unexpected event from helper before Ready".to_owned(), - )), + } => Err(VssError::CreateFailed { + hresult: Some(hresult), + message: format!("stage={stage} hresult={hresult:#x}: {message}"), + }), + HelperEvent::Released | HelperEvent::Pong => Err(VssError::CreateFailed { + hresult: None, + message: "unexpected event from helper before Ready".to_owned(), + }), } } } @@ -317,9 +319,10 @@ fn wait_for_helper_ready( tracing::info!("vss: waiting for helper to connect to the control pipe"); if let Err(err) = connect_pipe(pipe_handle) { close_pipe_handle(pipe_handle); - return Err(VssError::CreateFailed(format!( - "helper did not connect to control pipe: {err}" - ))); + return Err(VssError::CreateFailed { + hresult: None, + message: format!("helper did not connect to control pipe: {err}"), + }); } tracing::info!("vss: helper connected"); @@ -328,16 +331,21 @@ fn wait_for_helper_ready( let pipe_file = unsafe { File::from_raw_handle(pipe_handle.0.cast::()) }; let writer = pipe_file .try_clone() - .map_err(|err| VssError::CreateFailed(format!("failed to clone pipe handle: {err}")))?; + .map_err(|err| VssError::CreateFailed { + hresult: None, + message: format!("failed to clone pipe handle: {err}"), + })?; let mut reader = BufReader::new(pipe_file); tracing::info!("vss: waiting for Ready/Failed from helper"); let event = read_helper_event(&mut reader) - .map_err(|err| VssError::CreateFailed(format!("failed to read helper event: {err}")))? - .ok_or_else(|| { - VssError::CreateFailed( - "helper closed the control pipe before reporting readiness".to_owned(), - ) + .map_err(|err| VssError::CreateFailed { + hresult: None, + message: format!("failed to read helper event: {err}"), + })? + .ok_or_else(|| VssError::CreateFailed { + hresult: None, + message: "helper closed the control pipe before reporting readiness".to_owned(), })?; Ok((reader, writer, event)) @@ -357,14 +365,18 @@ impl VssProvider for WindowsVssProvider { let pipe_name = format!(r"\\.\pipe\uffs-vss-requestor-{pipe_id:016x}"); tracing::info!(volume = %volume_path, pipe = %pipe_name, "vss: creating control pipe"); - let pipe_handle = create_control_pipe(&pipe_name).map_err(|err| { - VssError::CreateFailed(format!("failed to create control pipe: {err}")) - })?; + let pipe_handle = + create_control_pipe(&pipe_name).map_err(|err| VssError::CreateFailed { + hresult: None, + message: format!("failed to create control pipe: {err}"), + })?; tracing::info!(volume = %volume_path, "vss: spawning uffs-vss-requestor"); - let pending = spawn_helper(&pipe_name, &volume_path).map_err(|err| { - VssError::CreateFailed(format!("failed to spawn uffs-vss-requestor: {err}")) - })?; + let pending = + spawn_helper(&pipe_name, &volume_path).map_err(|err| VssError::CreateFailed { + hresult: None, + message: format!("failed to spawn uffs-vss-requestor: {err}"), + })?; let (reader, writer, event) = wait_for_helper_ready(pipe_handle)?; self.finish_create_snapshot(pending, reader, writer, event) diff --git a/crates/uffs-broker/src/snapshot_lease.rs b/crates/uffs-broker/src/snapshot_lease.rs index e4c480630..afa39e08e 100644 --- a/crates/uffs-broker/src/snapshot_lease.rs +++ b/crates/uffs-broker/src/snapshot_lease.rs @@ -62,8 +62,17 @@ pub(crate) enum VssError { #[error("volume validation failed: {0}")] InvalidVolume(String), /// Snapshot creation failed. - #[error("snapshot creation failed: {0}")] - CreateFailed(String), + #[error("snapshot creation failed: {message}")] + CreateFailed { + /// The underlying `HRESULT`, when the failure came from a real + /// VSS call and one is available — lets callers (the Snapshot + /// Manager's wire layer) distinguish specific, permanent + /// failure reasons (e.g. `VSS_E_VOLUME_NOT_SUPPORTED` for + /// removable media) from this message's free text. + hresult: Option, + /// Diagnostic message. + message: String, + }, /// Snapshot deletion failed. #[error("snapshot deletion failed: {0}")] DeleteFailed(String), diff --git a/crates/uffs-broker/src/snapshot_lease/tests.rs b/crates/uffs-broker/src/snapshot_lease/tests.rs index 2b581de28..b8ad7dd34 100644 --- a/crates/uffs-broker/src/snapshot_lease/tests.rs +++ b/crates/uffs-broker/src/snapshot_lease/tests.rs @@ -54,7 +54,10 @@ impl VssProvider for FakeVssProvider { _requested_root: &[u8], ) -> Result { if self.fail_create { - return Err(VssError::CreateFailed("forced test failure".to_owned())); + return Err(VssError::CreateFailed { + hresult: None, + message: "forced test failure".to_owned(), + }); } let id = self.next_snapshot_id.fetch_add(1, Ordering::Relaxed); Ok(SnapshotHandle { @@ -105,7 +108,10 @@ fn create_failure_propagates_vss_error() { let err = manager .create_lease(&sample_volume(), b"C:\\data", 300, 0) .expect_err("create must fail"); - assert!(matches!(err, LeaseError::Vss(VssError::CreateFailed(_)))); + assert!(matches!( + err, + LeaseError::Vss(VssError::CreateFailed { .. }) + )); } #[test] diff --git a/crates/uffs-content/src/job/snapshot_client.rs b/crates/uffs-content/src/job/snapshot_client.rs index 6a85b6fd4..cb2ac30f5 100644 --- a/crates/uffs-content/src/job/snapshot_client.rs +++ b/crates/uffs-content/src/job/snapshot_client.rs @@ -27,9 +27,46 @@ use std::io::{Read as _, Write as _}; use anyhow::Context as _; use uffs_broker_protocol::snapshot_manager::{ CreateSnapshotLease, CreateSnapshotLeaseResult, ReleaseSnapshotLease, SNAPSHOT_PIPE_NAME, - SnapshotManagerRequest, SnapshotManagerResponse, VolumeIdentity, + SnapshotManagerErrorCode, SnapshotManagerRequest, SnapshotManagerResponse, VolumeIdentity, }; +/// A structured `Create` rejection from the Broker, as opposed to a +/// transport-level failure (pipe unreachable, malformed response, …). +/// +/// Kept separate from a plain `anyhow::bail!` string so callers (see +/// [`super::vss_orchestrator::prepare_ephemeral_daemon_for_roots`]) can +/// `downcast_ref` and branch on `code`/`hresult` — e.g. skipping a +/// drive VSS permanently refuses (`VSS_E_VOLUME_NOT_SUPPORTED` for +/// removable media) instead of string-matching `message`. +#[derive(Debug)] +pub(crate) struct BrokerRejectedCreate { + /// Stable error code the Broker reported. + pub(crate) code: SnapshotManagerErrorCode, + /// The underlying `HRESULT`, when the Broker's failure came from a + /// VSS call and one was available. + pub(crate) hresult: Option, + /// Human-readable diagnostic message. + pub(crate) message: String, +} + +impl core::fmt::Display for BrokerRejectedCreate { + #[expect( + clippy::use_debug, + reason = "SnapshotManagerErrorCode has no Display impl (it's a wire enum, not \ + user-facing text) — Debug is the only formatting available, and this \ + is itself a diagnostic-only error message" + )] + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!( + f, + "Broker rejected Create: {:?}: {}", + self.code, self.message + ) + } +} + +impl core::error::Error for BrokerRejectedCreate {} + /// Matches the Broker's own `MAX_REQUEST_BYTES` — a response this large /// would indicate a protocol desync, not a legitimate reply. const MAX_RESPONSE_BYTES: u32 = 64 * 1024; @@ -91,9 +128,16 @@ pub(crate) fn create_lease( snapshot_created_at_unix_ms, expires_at_unix_ms, }), - SnapshotManagerResponse::Error { code, message } => { - anyhow::bail!("Broker rejected Create: {code:?}: {message}") + SnapshotManagerResponse::Error { + code, + hresult, + message, + } => Err(BrokerRejectedCreate { + code, + hresult, + message, } + .into()), other @ (SnapshotManagerResponse::Duplicated | SnapshotManagerResponse::Renewed { .. } | SnapshotManagerResponse::Released @@ -112,7 +156,7 @@ pub(crate) fn release_lease(snapshot_lease_id: u64) -> anyhow::Result<()> { let request = SnapshotManagerRequest::Release(ReleaseSnapshotLease { snapshot_lease_id }); match round_trip(&request)? { SnapshotManagerResponse::Released => Ok(()), - SnapshotManagerResponse::Error { code, message } => { + SnapshotManagerResponse::Error { code, message, .. } => { anyhow::bail!("Broker rejected Release: {code:?}: {message}") } other @ (SnapshotManagerResponse::Created(_) diff --git a/crates/uffs-content/src/job/vss_orchestrator.rs b/crates/uffs-content/src/job/vss_orchestrator.rs index 1c12c3729..ed2260d1c 100644 --- a/crates/uffs-content/src/job/vss_orchestrator.rs +++ b/crates/uffs-content/src/job/vss_orchestrator.rs @@ -13,10 +13,18 @@ use std::collections::HashSet; use std::path::Path; use anyhow::{Context as _, Result}; -use uffs_broker_protocol::snapshot_manager::VolumeIdentity; +use uffs_broker_protocol::snapshot_manager::{SnapshotManagerErrorCode, VolumeIdentity}; use super::ephemeral_daemon::EphemeralDaemon; -use super::snapshot_client; +use super::snapshot_client::{self, BrokerRejectedCreate}; + +/// `VSS_E_VOLUME_NOT_SUPPORTED` — VSS permanently refuses to snapshot +/// this volume (observed in practice on removable/USB media). Distinct +/// from `uffs-vss-requestor`'s `RETRYABLE_HRESULTS`: this is +/// deliberately *not* in that list, and a job should skip the drive +/// rather than fail outright, since it will never become supported by +/// retrying. +const VSS_E_VOLUME_NOT_SUPPORTED: i32 = 0x8004_230C_u32.cast_signed(); /// Default VSS snapshot lease lifetime. /// @@ -89,11 +97,18 @@ impl EphemeralJobResources { /// Coordinator already knows which drive it's snapshotting, per the /// user's explicit correction during design. /// +/// A drive VSS permanently refuses to snapshot (`VSS_E_VOLUME_NOT_SUPPORTED` +/// — seen in practice on removable/USB media) is skipped, not fatal: it is +/// warn-logged and left out of the returned leases/daemon devices, so the +/// rest of a multi-drive "all drives" job still completes. Any other lease +/// failure still aborts the whole job. +/// /// # Errors -/// Returns an error if any root has no drive-letter prefix, any lease -/// request fails, or the ephemeral daemon fails to spawn or become -/// ready. On error, any leases already taken out are released -/// best-effort before returning. +/// Returns an error if any root has no drive-letter prefix, a lease +/// request fails for a reason other than `VSS_E_VOLUME_NOT_SUPPORTED`, +/// or the ephemeral daemon fails to spawn or become ready. On error, +/// any leases already taken out are released best-effort before +/// returning. pub(crate) fn prepare_ephemeral_daemon_for_roots( job_id: [u8; 16], roots: &[&Path], @@ -128,6 +143,14 @@ pub(crate) fn prepare_ephemeral_daemon_for_roots( let lease = match lease_result { Ok(lease) => lease, + Err(err) if is_volume_not_supported(&err) => { + tracing::warn!( + drive = %letter, + "skipping drive: VSS does not support snapshotting this volume \ + (VSS_E_VOLUME_NOT_SUPPORTED — typically removable/USB media)" + ); + continue; + } Err(err) => { release_all_leases(&lease_ids(&leases)); return Err( @@ -156,6 +179,19 @@ pub(crate) fn prepare_ephemeral_daemon_for_roots( } } +/// Whether `err` is a [`BrokerRejectedCreate`] specifically reporting +/// `VSS_E_VOLUME_NOT_SUPPORTED` for a snapshot-creation failure — the +/// one lease-failure reason a multi-drive job should skip past rather +/// than abort on (see [`prepare_ephemeral_daemon_for_roots`]'s doc +/// comment). +fn is_volume_not_supported(err: &anyhow::Error) -> bool { + err.downcast_ref::() + .is_some_and(|rejected| { + rejected.code == SnapshotManagerErrorCode::SnapshotCreateFailed + && rejected.hresult == Some(VSS_E_VOLUME_NOT_SUPPORTED) + }) +} + /// Extract just the lease ids from `leases`, for [`release_all_leases`]. fn lease_ids(leases: &[LeasedDrive]) -> Vec { leases.iter().map(|lease| lease.lease_id).collect() From 28ce05a637c5e9cd6295fa160bc505bd4b1d541a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:46:37 -0700 Subject: [PATCH 55/98] feat(content): wire a real tracing subscriber + instrument the job pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uffs-content had no tracing subscriber installed anywhere — every tracing:: call in the job pipeline (there were only 2, both in vss_orchestrator) was silently dropped, so a real-hardware failure (e.g. "run_job failed: I/O error ... os error 995") gave no way to tell which phase, drive, or candidate it happened in. Meanwhile uffs-broker's own foreground log is detailed step by step, which is what exposed the gap. - main.rs: init_tracing() (fmt(), with_target(false), INFO) installed at the top of main(), mirroring uffs-broker/uffs-content-reader's own init exactly, so every --self-test-*/--serve run now produces the same kind of log uffs-broker --run already does. - Added info/warn-level events at every phase boundary: VSS lease request/success/skip, ephemeral daemon spawn/ready, candidate enumeration connect/search-request/response (per root — the most likely site of an enumeration-phase abort), content reader spawn/connect-per-lease, and read_at/round_trip failures (previously silent even for the per-candidate FAILED_RETRYABLE case). - uffs-broker: the previously-silent HelperEvent::Failed case in vss_helper.rs now warn-logs volume/stage/hresult/message instead of the Broker's own log going quiet between "waiting for Ready/Failed" and the next drive. - Extracted vss_orchestrator::lease_one_drive() to keep prepare_ephemeral_daemon_for_roots's cognitive complexity under the workspace lint threshold after the added logging. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + .../src/broker/snapshot_manager/vss_helper.rs | 43 ++++++-- crates/uffs-content/Cargo.toml | 19 +++- .../uffs-content/src/job/candidate_source.rs | 24 ++-- .../uffs-content/src/job/ephemeral_daemon.rs | 8 ++ crates/uffs-content/src/job/reader_client.rs | 31 +++++- .../uffs-content/src/job/vss_orchestrator.rs | 104 +++++++++++------- crates/uffs-content/src/job/workflow.rs | 24 ++++ crates/uffs-content/src/lib.rs | 4 + crates/uffs-content/src/main.rs | 36 +++++- .../tests/e2e_dir_walk_parity_fake_reader.rs | 5 +- .../tests/e2e_real_vss_content_reader.rs | 6 +- 12 files changed, 237 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f257f3ca4..745c9a093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4491,6 +4491,7 @@ dependencies = [ "tempfile", "tokio", "tracing", + "tracing-subscriber", "uffs-broker-protocol", "uffs-client", "uffs-content-protocol", diff --git a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs index 52910bb45..9532a664c 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/vss_helper.rs @@ -260,6 +260,7 @@ impl WindowsVssProvider { /// split out to keep that function's cognitive complexity down. fn finish_create_snapshot( &self, + volume_path: &str, pending: PendingSpawn, reader: BufReader, writer: File, @@ -294,14 +295,38 @@ impl WindowsVssProvider { stage, hresult, message, - } => Err(VssError::CreateFailed { - hresult: Some(hresult), - message: format!("stage={stage} hresult={hresult:#x}: {message}"), - }), - HelperEvent::Released | HelperEvent::Pong => Err(VssError::CreateFailed { - hresult: None, - message: "unexpected event from helper before Ready".to_owned(), - }), + } => { + // Kept at `warn!` (not silently propagated): without this, + // an operator watching the Broker's own log sees "waiting + // for Ready/Failed from helper" and then nothing for this + // volume at all — the failure only became visible on the + // Coordinator side, several process hops away. See + // `uffs-content`'s `vss_orchestrator` for how + // `VSS_E_VOLUME_NOT_SUPPORTED` specifically is handled + // (skipped, not fatal) once it reaches that side. + tracing::warn!( + volume = %volume_path, + stage, + hresult = format!("{hresult:#x}"), + message = %message, + "vss: snapshot creation failed" + ); + Err(VssError::CreateFailed { + hresult: Some(hresult), + message: format!("stage={stage} hresult={hresult:#x}: {message}"), + }) + } + HelperEvent::Released | HelperEvent::Pong => { + tracing::warn!( + volume = %volume_path, + ?event, + "vss: unexpected event from helper before Ready" + ); + Err(VssError::CreateFailed { + hresult: None, + message: "unexpected event from helper before Ready".to_owned(), + }) + } } } } @@ -379,7 +404,7 @@ impl VssProvider for WindowsVssProvider { })?; let (reader, writer, event) = wait_for_helper_ready(pipe_handle)?; - self.finish_create_snapshot(pending, reader, writer, event) + self.finish_create_snapshot(&volume_path, pending, reader, writer, event) } fn delete_snapshot(&self, snapshot_id: &[u8]) -> Result<(), VssError> { diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 35a8b2d0d..ebeb035b1 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -65,6 +65,14 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true # Job/run identifiers (`ManifestHeader::job_id`, etc.) — see `src/job/`. uuid.workspace = true +# Structured logging: `workflow::run_job`'s per-candidate content-read +# failure log (`src/job/workflow.rs`) runs in both the cross-platform +# fake-reader pipeline and the Windows-only real VSS pipeline, so this +# stays unconditional even though most other job-pipeline logging below +# is Windows-only. The macros themselves have no OS-specific behavior; +# only installing a subscriber to consume them (`tracing-subscriber`, +# Windows-only below) is Windows-specific. +tracing.workspace = true # Windows-only deps: the real Snapshot Manager pipe client # (`src/job/snapshot_client.rs`) and the real VSS+MFT-query @@ -105,10 +113,13 @@ uffs-mft.workspace = true # (`src/job/reader_client.rs`, `src/job/content_source.rs`'s # `VssContentSource`). uffs-content-reader-protocol.workspace = true -# Warn-logs best-effort lease-release failures during ephemeral-daemon -# teardown (`src/job/vss_orchestrator.rs`) — not used anywhere else in -# this crate today. -tracing.workspace = true +# Installs the subscriber consuming this crate's `tracing` events (see +# the unconditional `tracing` dependency above), for the +# `--self-test-*`/`--serve` entry points (`src/main.rs::init_tracing`) — +# matches `uffs-broker`/`uffs-content-reader`'s own `fmt()` init. Scoped +# to Windows only: every entry point that calls `init_tracing()` is +# itself Windows-only (real VSS/Broker/ephemeral-daemon machinery). +tracing-subscriber.workspace = true # Named-pipe server for the two-pipe transport (`src/serve/`) — every # other feature this crate needs (`rt`/`sync`/`time`/`io-util`) is # already unconditional at workspace scope; only `net` (named pipes) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index ece387073..f08a612c2 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -191,10 +191,11 @@ impl<'a> VssCandidateSource<'a> { #[cfg(windows)] impl CandidateSource for VssCandidateSource<'_> { fn enumerate(&self, root: &Path) -> io::Result> { - let mut client = self - .daemon - .connect() - .map_err(|err| io::Error::other(err.to_string()))?; + tracing::info!(root = %root.display(), "candidate enumeration: connecting to ephemeral daemon"); + let mut client = self.daemon.connect().map_err(|err| { + tracing::warn!(root = %root.display(), error = %err, "candidate enumeration: connect failed"); + io::Error::other(err.to_string()) + })?; // Scope the search to this job's root: `path_contains` is a // directory-path glob matched against each record's directory @@ -215,12 +216,21 @@ impl CandidateSource for VssCandidateSource<'_> { attr: self.attr.clone(), ..Default::default() }; - let response = client - .search(¶ms) - .map_err(|err| io::Error::other(err.to_string()))?; + tracing::info!(root = %root.display(), "candidate enumeration: sending search request"); + let response = client.search(¶ms).map_err(|err| { + tracing::warn!(root = %root.display(), error = %err, "candidate enumeration: search request failed"); + io::Error::other(err.to_string()) + })?; let rows = resolve_rows(response.payload)?; + let row_count = rows.len(); let deduped = dedup_rows_by_file_reference_and_path(rows); + tracing::info!( + root = %root.display(), + rows = row_count, + deduped = deduped.len(), + "candidate enumeration: search response received" + ); let mut entries = Vec::with_capacity(deduped.len()); for row in deduped { diff --git a/crates/uffs-content/src/job/ephemeral_daemon.rs b/crates/uffs-content/src/job/ephemeral_daemon.rs index 5d6dfa7a2..3c4b1031d 100644 --- a/crates/uffs-content/src/job/ephemeral_daemon.rs +++ b/crates/uffs-content/src/job/ephemeral_daemon.rs @@ -78,15 +78,23 @@ impl EphemeralDaemon { .arg(format!("{device_path}={letter}")); } + tracing::info!( + exe = %exe.display(), + device_count = devices.len(), + "ephemeral daemon: spawning uffsd" + ); let child = command .spawn() .with_context(|| format!("failed to spawn {}", exe.display()))?; + let pid = child.id(); let instance = Self { child, endpoint: ephemeral_endpoint(ephemeral_id), }; + tracing::info!(pid, endpoint = %instance.endpoint, "ephemeral daemon: waiting for Ready"); instance.await_ready()?; + tracing::info!(pid, "ephemeral daemon: Ready"); Ok(instance) } diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index 69833d24a..2cb3b5757 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -96,14 +96,21 @@ impl ContentReader { .arg("--device") .arg(format!("{device_path}={lease_id}")); } + tracing::info!( + exe = %exe.display(), + device_count = devices.len(), + "content reader: spawning uffs-content-reader" + ); let child = command .spawn() .with_context(|| format!("failed to spawn {}", exe.display()))?; + tracing::info!(pid = child.id(), "content reader: process spawned"); let mut connections = HashMap::with_capacity(devices.len()); for (_device_path, lease_id) in devices { let pipe = connect_with_retry() .with_context(|| format!("failed to open a connection for lease {lease_id}"))?; + tracing::info!(lease_id, "content reader: connection established"); connections.insert(*lease_id, Mutex::new(pipe)); } @@ -150,11 +157,29 @@ impl ContentReader { request_nonce: self.next_nonce.fetch_add(1, Ordering::Relaxed), }; - match self.round_trip(snapshot_lease_id, &request)? { - ReadResponse::Bytes { payload, .. } => Ok(payload), - ReadResponse::Error { code, message } => { + match self.round_trip(snapshot_lease_id, &request) { + Ok(ReadResponse::Bytes { payload, .. }) => Ok(payload), + Ok(ReadResponse::Error { code, message }) => { + tracing::warn!( + snapshot_lease_id, + candidate_id, + logical_offset, + ?code, + message = %message, + "content reader: read rejected" + ); anyhow::bail!("Reader rejected read: {code:?}: {message}") } + Err(err) => { + tracing::warn!( + snapshot_lease_id, + candidate_id, + logical_offset, + error = %err, + "content reader: round trip failed" + ); + Err(err) + } } } diff --git a/crates/uffs-content/src/job/vss_orchestrator.rs b/crates/uffs-content/src/job/vss_orchestrator.rs index ed2260d1c..2dab2b7dd 100644 --- a/crates/uffs-content/src/job/vss_orchestrator.rs +++ b/crates/uffs-content/src/job/vss_orchestrator.rs @@ -124,45 +124,14 @@ pub(crate) fn prepare_ephemeral_daemon_for_roots( continue; // already leased this drive for an earlier root } - let requested_root = utf16le_bytes(&format!("{letter}:\\")); - let lease_result = snapshot_client::create_lease( - job_id, - VolumeIdentity { - // Presently inert: the Broker's real `create_snapshot` - // path derives the volume to snapshot from - // `requested_root`, not this struct (confirmed via - // direct source read) — populate a real serial/GUID - // once the Broker actually validates against it. - volume_serial: 0, - volume_guid: Vec::new(), - }, - requested_root, - DEFAULT_LEASE_LIFETIME_SECS, - DEFAULT_POLICY_ID, - ); - - let lease = match lease_result { - Ok(lease) => lease, - Err(err) if is_volume_not_supported(&err) => { - tracing::warn!( - drive = %letter, - "skipping drive: VSS does not support snapshotting this volume \ - (VSS_E_VOLUME_NOT_SUPPORTED — typically removable/USB media)" - ); - continue; - } + match lease_one_drive(job_id, letter) { + Ok(Some(lease)) => leases.push(lease), + Ok(None) => {} // VSS_E_VOLUME_NOT_SUPPORTED — skip, already warn-logged Err(err) => { release_all_leases(&lease_ids(&leases)); - return Err( - err.context(format!("failed to lease a VSS snapshot for drive {letter}")) - ); + return Err(err); } - }; - leases.push(LeasedDrive { - device_path: lease.snapshot_device_identity, - drive_letter: letter, - lease_id: lease.snapshot_lease_id, - }); + } } let devices: Vec<(String, char)> = leases @@ -170,15 +139,76 @@ pub(crate) fn prepare_ephemeral_daemon_for_roots( .map(|lease| (lease.device_path.clone(), lease.drive_letter)) .collect(); + tracing::info!(drive_count = devices.len(), "spawning ephemeral daemon"); match EphemeralDaemon::spawn(ephemeral_id, &devices) { - Ok(daemon) => Ok(EphemeralJobResources { daemon, leases }), + Ok(daemon) => { + tracing::info!("ephemeral daemon ready"); + Ok(EphemeralJobResources { daemon, leases }) + } Err(err) => { + tracing::warn!(error = %err, "ephemeral daemon spawn failed"); release_all_leases(&lease_ids(&leases)); Err(err) } } } +/// Lease a VSS snapshot for drive `letter`, split out of +/// [`prepare_ephemeral_daemon_for_roots`]'s loop to keep that function's +/// cognitive complexity down. +/// +/// `Ok(None)` means VSS specifically reported +/// `VSS_E_VOLUME_NOT_SUPPORTED` for this volume — skip it, not fatal +/// (already warn-logged here) — see the caller's doc comment. +/// +/// # Errors +/// Returns any other lease failure reason. +fn lease_one_drive(job_id: [u8; 16], letter: char) -> Result> { + tracing::info!(drive = %letter, "leasing VSS snapshot"); + let requested_root = utf16le_bytes(&format!("{letter}:\\")); + let lease_result = snapshot_client::create_lease( + job_id, + VolumeIdentity { + // Presently inert: the Broker's real `create_snapshot` path + // derives the volume to snapshot from `requested_root`, not + // this struct (confirmed via direct source read) — populate + // a real serial/GUID once the Broker actually validates + // against it. + volume_serial: 0, + volume_guid: Vec::new(), + }, + requested_root, + DEFAULT_LEASE_LIFETIME_SECS, + DEFAULT_POLICY_ID, + ); + + let lease = match lease_result { + Ok(lease) => lease, + Err(err) if is_volume_not_supported(&err) => { + tracing::warn!( + drive = %letter, + "skipping drive: VSS does not support snapshotting this volume \ + (VSS_E_VOLUME_NOT_SUPPORTED — typically removable/USB media)" + ); + return Ok(None); + } + Err(err) => { + return Err(err.context(format!("failed to lease a VSS snapshot for drive {letter}"))); + } + }; + tracing::info!( + drive = %letter, + lease_id = lease.snapshot_lease_id, + device = %lease.snapshot_device_identity, + "VSS snapshot leased" + ); + Ok(Some(LeasedDrive { + device_path: lease.snapshot_device_identity, + drive_letter: letter, + lease_id: lease.snapshot_lease_id, + })) +} + /// Whether `err` is a [`BrokerRejectedCreate`] specifically reporting /// `VSS_E_VOLUME_NOT_SUPPORTED` for a snapshot-creation failure — the /// one lease-failure reason a multi-drive job should skip past rather diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index fb08faa2f..19adfd955 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -123,11 +123,21 @@ where // job is equivalent to a `"*"` query, so its digest is fixed. let query_digest = digest(b"*"); + tracing::info!( + job_id = %uuid::Uuid::from_bytes(job_id), + root_count = request.roots.len(), + concurrency = batch_size, + "job: starting candidate enumeration" + ); let mut entries = Vec::new(); for root in &request.roots { entries.extend(candidate_source.enumerate(root)?); } let candidate_count = len_as_u64(entries.len()); + tracing::info!( + candidate_count, + "job: enumeration complete, building manifest" + ); let built = build_manifest(job_id, source_id, query_digest, &entries) .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; @@ -181,6 +191,13 @@ where } drop(failure_log); + tracing::info!( + succeeded = counters.succeeded_count, + failed_retryable = counters.failed_retryable_count, + failed_terminal = counters.failed_terminal_count, + deferred_manual = counters.deferred_manual_count, + "job: content reads complete, finalizing" + ); emit_job_end( &counters, built.manifest_digest, @@ -377,6 +394,13 @@ fn read_one_candidate( chunk_sequence += 1; } Err(err) => { + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + offset, + error = %err, + "content read failed" + ); read_error = Some(err); break; } diff --git a/crates/uffs-content/src/lib.rs b/crates/uffs-content/src/lib.rs index eb88ddb7c..ffb27c4cc 100644 --- a/crates/uffs-content/src/lib.rs +++ b/crates/uffs-content/src/lib.rs @@ -55,6 +55,10 @@ pub(crate) mod serve; // unit tests. #[cfg(test)] use blake3 as _; +// Installed by `main.rs::init_tracing()` (the bin target), not used +// directly by this library crate. +#[cfg(windows)] +use tracing_subscriber as _; use uffs_version as _; /// Whether the production, VSS-snapshot-backed pipeline is wired up. diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 410944df3..3bb4ac726 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -55,9 +55,10 @@ use tempfile as _; // thin entry point directly. #[cfg(windows)] use tokio as _; -// Used by `uffs_content::job::vss_orchestrator` (best-effort -// lease-release warnings), not by this thin entry point directly. -#[cfg(windows)] +// Used directly by `init_tracing()` on Windows; on every other platform +// that function doesn't exist, so `tracing` (an unconditional +// dependency — see `Cargo.toml`) goes unused by this bin directly. +#[cfg(not(windows))] use tracing as _; #[cfg(windows)] use uffs_broker_protocol as _; @@ -82,11 +83,31 @@ use uffs_security as _; // directly. use uuid as _; +/// Install the `tracing` subscriber every `--self-test-*`/`--serve` entry +/// point relies on for diagnostic output (job/lease/daemon/reader +/// lifecycle events across `uffs_content::job`) — mirrors +/// `uffs-broker`/`uffs-content-reader`'s own `fmt()` init exactly +/// (`with_target(false)`, `INFO` by default) so a foreground `--serve` +/// run's log looks the same shape as the Broker's. +/// +/// Uses `try_init` so a test harness embedding this crate that already +/// installed a subscriber doesn't panic. +#[cfg(windows)] +fn init_tracing() { + let init_result = tracing_subscriber::fmt() + .with_target(false) + .with_max_level(tracing::Level::INFO) + .with_writer(std::io::stderr) + .try_init(); + drop(init_result); +} + #[expect( clippy::print_stderr, - reason = "scaffold only: no tracing subscriber exists yet, so this is the \ - only way the operator sees the status. Replace with `tracing::info!` \ - once job intake wires up a subscriber, matching uffsd/uffs-broker." + reason = "the final ready/scaffold status lines below run whether or not a job intake \ + flag matched, and are plain one-line user-facing status text rather than \ + diagnostic logging — every diagnostic-logging path (self-test/benchmark/serve) \ + now has a real tracing subscriber via init_tracing()" )] fn main() { // `--version` / `-V` is handled here, before any job dispatch, so it @@ -94,6 +115,9 @@ fn main() { // `uffsd` so the self-update version probe can parse it uniformly. uffs_version::handle_version!("uffs-content"); + #[cfg(windows)] + init_tracing(); + let args: Vec = std::env::args().collect(); if let Some(test_dir) = self_test_vss_playback_dir(&args) { std::process::exit(run_self_test_vss_playback(&test_dir)); diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index c1e6d9086..ac13bff77 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -41,9 +41,12 @@ use serde as _; use serde_json as _; #[cfg(windows)] use tokio as _; -#[cfg(windows)] +// Unconditional dependency of `uffs-content` (see that crate's +// `Cargo.toml`) — not named directly by this cross-platform test. use tracing as _; #[cfg(windows)] +use tracing_subscriber as _; +#[cfg(windows)] use uffs_broker_protocol as _; #[cfg(windows)] use uffs_client as _; diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 4737090cc..5a29c99ee 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -52,9 +52,13 @@ use serde_json as _; use tempfile as _; #[cfg(windows)] use tokio as _; -#[cfg(windows)] +// Unconditional dependency of `uffs-content` (see that crate's +// `Cargo.toml`) — reached transitively on every platform, not named +// directly by this thin test on either. use tracing as _; #[cfg(windows)] +use tracing_subscriber as _; +#[cfg(windows)] use uffs_broker_protocol as _; #[cfg(windows)] use uffs_client as _; From a4085f7b81e39753040ef1426a9848c5d62a327d Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:27:48 -0700 Subject: [PATCH 56/98] fix(logging): surface ephemeral uffsd's own logs + version-at-startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause found for the "os error 995" content-benchmark abort: uffsd's own search RPC for D:\ took over the client's 60s DEFAULT_RPC_DEADLINE_SECS, so uffs-client's Windows deadline watchdog cancelled the in-flight I/O — never a VSS/broker bug. But EphemeralDaemon::spawn redirected the ephemeral uffsd's stdout/stderr to Stdio::null(), so none of its own tracing output (which would show why that search was slow) was ever visible, no matter how much the Coordinator side logged. - ephemeral_daemon.rs: pass --log-file/--log-level to the spawned uffsd instead of discarding its output, and log the resulting file path so it's discoverable from the Coordinator's own log. - uffs-daemon handler: log search-request start (pattern/drives), ensure_drives_loaded duration, and search_or_diff duration + row count — moved the two new helpers into handler_diff.rs (alongside search_or_diff, which they wrap) to stay under the file-size policy ceiling handler.rs was about to cross. - uffs-broker/uffs-content: log the running binary's version+sha at startup (uffs-broker --run, uffs-content's --self-test-*/--serve entry points) — there was no way to tell which build was actually running after a rebuild. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-broker/src/broker.rs | 1 + .../uffs-content/src/job/ephemeral_daemon.rs | 15 +++++ crates/uffs-content/src/main.rs | 6 ++ crates/uffs-daemon/src/handler.rs | 39 +++++------ crates/uffs-daemon/src/handler_diff.rs | 67 ++++++++++++++++++- 5 files changed, 104 insertions(+), 24 deletions(-) diff --git a/crates/uffs-broker/src/broker.rs b/crates/uffs-broker/src/broker.rs index 1ed795bad..41fa977e3 100644 --- a/crates/uffs-broker/src/broker.rs +++ b/crates/uffs-broker/src/broker.rs @@ -192,6 +192,7 @@ fn run_foreground() -> anyhow::Result<()> { init_tracing(); tracing::info!( pid = std::process::id(), + version = %uffs_version::version_short!("uffs-broker"), "uffs-broker starting (foreground mode)" ); warn_if_not_elevated(); diff --git a/crates/uffs-content/src/job/ephemeral_daemon.rs b/crates/uffs-content/src/job/ephemeral_daemon.rs index 3c4b1031d..5b81a09bd 100644 --- a/crates/uffs-content/src/job/ephemeral_daemon.rs +++ b/crates/uffs-content/src/job/ephemeral_daemon.rs @@ -65,10 +65,24 @@ impl EphemeralDaemon { ); let exe = find_daemon_exe(); + // `--stdout/--stderr(Stdio::null())` used to discard every one of + // uffsd's own `tracing::info!` events (it defaults to logging to + // stdout at `info` level — see `uffs-daemon::log_init`) — meaning + // none of its internal timing (e.g. a slow `search` against a + // freshly-loaded, uncached device source) was ever visible, + // however much this crate's own logging improved. `--log-file` + // routes it to a discoverable file instead, and `--log-level` + // pins the level explicitly rather than relying on uffsd's own + // default matching ours. + let log_file = std::env::temp_dir().join(format!("uffsd-ephemeral-{ephemeral_id}.log")); let mut command = Command::new(&exe); command .arg("--ephemeral-id") .arg(ephemeral_id) + .arg("--log-level") + .arg("info") + .arg("--log-file") + .arg(&log_file) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); @@ -81,6 +95,7 @@ impl EphemeralDaemon { tracing::info!( exe = %exe.display(), device_count = devices.len(), + log_file = %log_file.display(), "ephemeral daemon: spawning uffsd" ); let child = command diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 3bb4ac726..9d1931168 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -117,6 +117,12 @@ fn main() { #[cfg(windows)] init_tracing(); + #[cfg(windows)] + tracing::info!( + pid = std::process::id(), + version = %uffs_version::version_short!("uffs-content"), + "uffs-content starting" + ); let args: Vec = std::env::args().collect(); if let Some(test_dir) = self_test_vss_playback_dir(&args) { diff --git a/crates/uffs-daemon/src/handler.rs b/crates/uffs-daemon/src/handler.rs index 211365a98..20223ad7c 100644 --- a/crates/uffs-daemon/src/handler.rs +++ b/crates/uffs-daemon/src/handler.rs @@ -103,33 +103,30 @@ impl RequestHandler { Ok(params) => params, Err(parse_err) => return parse_err.to_rpc_error_json(id), }; + let search_started = std::time::Instant::now(); + tracing::info!( + pattern = %search_params.pattern, + drives = ?search_params.drives, + path_contains = ?search_params.path_contains, + "search: request received" + ); - // Auto-load missing drives from data_dir before searching. - if !search_params.drives.is_empty() { - let missing = self - .index - .ensure_drives_loaded(&search_params.drives, false) - .await; - if !missing.is_empty() { - tracing::warn!( - missing_drives = ?missing, - "Some requested drives could not be auto-loaded" - ); - } - } + self.auto_load_missing_drives(&search_params.drives).await; // A `--diff ` routes to the snapshot-diff path (which may // early-return a JSON-RPC error); everything else is a live search. - let mut response = match self.search_or_diff(id, &search_params).await { - Ok(resp) => resp, + // Row count is captured up-front (by `run_search_or_diff`) for + // logging and threshold dispatch: both blob-packing and + // shmem-rows routing may replace the payload variant in-place, + // at which point `response.payload.row_count_hint()` returns + // `None` (for blob variants) or a stale value (for consumed rows). + let (mut response, row_count) = match self + .run_search_or_diff(id, &search_params, search_started) + .await + { + Ok(result) => result, Err(error_json) => return error_json, }; - // Row count captured up-front for logging and threshold - // dispatch: both blob-packing and shmem-rows routing may - // replace the payload variant in-place, at which point - // `response.payload.row_count_hint()` returns `None` (for - // blob variants) or a stale value (for consumed rows). - let row_count = response.payload.row_count_hint().unwrap_or(0); // Path-only single-buffer fast path (see `try_pack_paths_blob`). // May replace `response.payload` with `InlineBlob` or `ShmemBlob`. diff --git a/crates/uffs-daemon/src/handler_diff.rs b/crates/uffs-daemon/src/handler_diff.rs index 35addce61..7ebdc86dc 100644 --- a/crates/uffs-daemon/src/handler_diff.rs +++ b/crates/uffs-daemon/src/handler_diff.rs @@ -1,12 +1,15 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. -//! Snapshot-diff error mapping for [`super::RequestHandler`]. +//! Snapshot-diff error mapping for [`super::RequestHandler`], plus the +//! logged `search`-request helpers built on top of +//! [`RequestHandler::search_or_diff`]. //! //! Lifted out of `handler.rs` to keep that file under the 800-line policy //! ceiling. Re-attached via `#[path = "handler_diff.rs"] mod diff_handler;`, so -//! `diff_search_response` stays an `impl RequestHandler` method the search -//! handler calls as `self.diff_search_response(...)`. +//! every item stays an `impl RequestHandler` method the search handler calls +//! as `self.diff_search_response(...)` / `self.auto_load_missing_drives(...)` +//! / `self.run_search_or_diff(...)`. //! //! The diff itself lives in `IndexManager::diff_search` (`crate::index::diff`); //! this only maps its [`crate::index::diff::DiffError`] setup failures onto the @@ -36,6 +39,64 @@ impl RequestHandler { } } + /// Auto-load `drives` from `data_dir` before a search, timing the + /// load and warn-logging any drive that couldn't be auto-loaded — + /// split out of `handler.rs::handle_search` to keep that function's + /// cognitive complexity down. + pub(super) async fn auto_load_missing_drives( + &self, + drives: &[uffs_mft::platform::DriveLetter], + ) { + if drives.is_empty() { + return; + } + let load_started = std::time::Instant::now(); + let missing = self.index.ensure_drives_loaded(drives, false).await; + tracing::info!( + ?drives, + elapsed_ms = load_started.elapsed().as_millis(), + "search: ensure_drives_loaded complete" + ); + if !missing.is_empty() { + tracing::warn!( + missing_drives = ?missing, + "Some requested drives could not be auto-loaded" + ); + } + } + + /// Run [`Self::search_or_diff`] for `search_params`, logging its + /// elapsed time and outcome (row count on success) — split out of + /// `handler.rs::handle_search` to keep that function's cognitive + /// complexity down. `started` is `handle_search`'s own request-start + /// timer, so the logged elapsed time covers the drive auto-load step + /// too, not just this call. + pub(super) async fn run_search_or_diff( + &self, + id: u64, + search_params: &SearchParams, + started: std::time::Instant, + ) -> Result<(SearchResponse, usize), String> { + match self.search_or_diff(id, search_params).await { + Ok(response) => { + let row_count = response.payload.row_count_hint().unwrap_or(0); + tracing::info!( + row_count, + elapsed_ms = started.elapsed().as_millis(), + "search: search_or_diff complete" + ); + Ok((response, row_count)) + } + Err(error_json) => { + tracing::warn!( + elapsed_ms = started.elapsed().as_millis(), + "search: search_or_diff failed" + ); + Err(error_json) + } + } + } + /// Run a snapshot-diff search, returning the response or a pre-serialized /// JSON-RPC error string for the setup failures (no drive / drive not /// loaded / baseline unreadable). From 064e8f297ea6f2ef4c12437cf87a9164fcb1e687 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:50:18 -0700 Subject: [PATCH 57/98] feat(cli): --self-test-reader-benchmark uses --drive, not a positional root Matches uffs.exe's own --drive convention instead of the previous positional "all"/comma-path-list argument, which was also silently swallowing any trailing flags (a stray --drive F after it was simply ignored, still running every drive). uffs-content --self-test-reader-benchmark [query] [--drive C,D,E] --drive accepts a comma-separated list, each entry in C or C: form (parse_drive_list, mirroring uffs-cli's own tolerant --drive parsing). Omitting --drive still defaults to every local NTFS drive. This makes it possible to isolate a single drive (e.g. --drive S) instead of always running the full multi-drive job. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/main.rs | 69 +++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 9d1931168..f5bb96667 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -29,11 +29,13 @@ //! # extension-filtered query against //! # an existing directory, verified //! # against a ground-truth disk walk -//! uffs-content --self-test-reader-benchmark [query] # Elevated: measure real -//! # content-read throughput. is -//! # "all" (every local NTFS drive) or a -//! # comma-separated list; [query] defaults -//! # to "*" +//! uffs-content --self-test-reader-benchmark [query] [--drive C,D,E] # Elevated: +//! # measure real content-read throughput. +//! # [query] defaults to "*"; --drive takes +//! # a comma-separated list (C or C: form, +//! # matching uffs.exe's own --drive flag) +//! # and defaults to every local NTFS drive +//! # when omitted //! ``` // Reserved for the wire types the bin will emit once job intake is wired @@ -279,26 +281,53 @@ const fn run_self_test_vss_query(_root: &std::path::Path, _extension: &str) -> i } /// Return the `(roots, query)` arguments following -/// `--self-test-reader-benchmark`, if present. `roots` is `all` (case -/// insensitive, resolved to an empty `Vec` — every local NTFS drive, see -/// [`uffs_content::job::vss_job::run_vss_job`]) or a comma-separated -/// path list; `query` defaults to `"*"` if omitted. +/// `--self-test-reader-benchmark`, if present. `query` is the one bare +/// (non-`--drive`) positional argument, defaulting to `"*"` if omitted. +/// `--drive` takes a comma-separated drive-letter list — each entry in +/// `C` or `C:` form, exactly matching `uffs.exe`'s own `--drive` flag +/// (see [`parse_drive_list`]) — resolved to `:\\` roots. +/// Omitting `--drive` resolves to an empty `Vec` — every local NTFS +/// drive, see [`uffs_content::job::vss_job::run_vss_job`]. #[cfg(windows)] fn self_test_reader_benchmark_args(args: &[String]) -> Option<(Vec, String)> { let flag_index = args .iter() .position(|arg| arg == "--self-test-reader-benchmark")?; - let roots_arg = args.get(flag_index + 1)?; - let roots = if roots_arg.eq_ignore_ascii_case("all") { - Vec::new() - } else { - roots_arg.split(',').map(std::path::PathBuf::from).collect() - }; - let query = args - .get(flag_index + 2) - .cloned() - .unwrap_or_else(|| "*".to_owned()); - Some((roots, query)) + let rest = args.get(flag_index + 1..)?; + + let mut roots: Vec = Vec::new(); + let mut query: Option = None; + let mut rest_iter = rest.iter(); + while let Some(arg) = rest_iter.next() { + if arg == "--drive" { + if let Some(value) = rest_iter.next() { + roots.extend(parse_drive_list(value)); + } + } else if query.is_none() { + query = Some(arg.clone()); + } + } + Some((roots, query.unwrap_or_else(|| "*".to_owned()))) +} + +/// Parse a comma-separated drive-letter list (each entry `C` or `C:`, +/// case-insensitive, matching `uffs.exe`'s own `--drive` flag) into +/// `:\\` roots. Entries that aren't exactly one ASCII letter +/// (once a trailing `:` is stripped) are silently skipped, matching +/// `uffs.exe`'s own tolerant `--drive` parsing. +#[cfg(windows)] +fn parse_drive_list(value: &str) -> Vec { + value + .split(',') + .filter_map(|part| { + let trimmed = part.trim(); + let letter = trimmed.strip_suffix(':').unwrap_or(trimmed); + let mut chars = letter.chars(); + let ch = chars.next()?; + (chars.next().is_none() && ch.is_ascii_alphabetic()) + .then(|| std::path::PathBuf::from(format!("{}:\\", ch.to_ascii_uppercase()))) + }) + .collect() } /// Non-Windows stub: `--self-test-reader-benchmark` needs a real VSS From b747df72b2aa06d24b657bf89f639fa013c5f56a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:04:36 -0700 Subject: [PATCH 58/98] fix(cli): reject a second bare positional in --self-test-reader-benchmark Silently keeping only the first bare argument as [query] let a leftover "all" from the pre---drive syntax get used as the actual search pattern while the real query was dropped without any warning - exactly what happened testing this on real hardware just now (the run "passed" but had silently searched for the substring "all" on drive F, not "*.asm"). Now a second positional is a hard error: tracing::error! listing every extra argument found, then exit(1), rather than a wrong result that still reports PASS. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/main.rs | 52 ++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index f5bb96667..216522c69 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -133,8 +133,20 @@ fn main() { if let Some((root, extension)) = self_test_vss_query_args(&args) { std::process::exit(run_self_test_vss_query(&root, &extension)); } - if let Some((roots, query)) = self_test_reader_benchmark_args(&args) { - std::process::exit(run_self_test_reader_benchmark(&roots, &query)); + match self_test_reader_benchmark_args(&args) { + Some(Ok((roots, query))) => { + std::process::exit(run_self_test_reader_benchmark(&roots, &query)); + } + Some(Err(positionals)) => { + tracing::error!( + ?positionals, + "--self-test-reader-benchmark takes exactly one bare [query] argument; \ + got more than one (this flag no longer takes an \"all\"/roots positional — \ + use --drive instead, or omit --drive entirely for every local NTFS drive)" + ); + std::process::exit(1); + } + None => {} } if args.iter().any(|arg| arg == "--serve") { std::process::exit(run_serve()); @@ -280,6 +292,11 @@ const fn run_self_test_vss_query(_root: &std::path::Path, _extension: &str) -> i 1 } +/// Success case: `(roots, query)`. Error case: every bare positional +/// argument found, when there was more than the one `[query]` this flag +/// accepts — see [`self_test_reader_benchmark_args`]'s doc comment. +type ReaderBenchmarkArgs = Result<(Vec, String), Vec>; + /// Return the `(roots, query)` arguments following /// `--self-test-reader-benchmark`, if present. `query` is the one bare /// (non-`--drive`) positional argument, defaulting to `"*"` if omitted. @@ -288,26 +305,43 @@ const fn run_self_test_vss_query(_root: &std::path::Path, _extension: &str) -> i /// (see [`parse_drive_list`]) — resolved to `:\\` roots. /// Omitting `--drive` resolves to an empty `Vec` — every local NTFS /// drive, see [`uffs_content::job::vss_job::run_vss_job`]. +/// +/// A *second* bare positional argument (e.g. a leftover `all` from the +/// pre-`--drive` syntax this flag used to have) is a usage error, not +/// silently dropped: `Some(Err(_))` tells [`main`] to `tracing::error!` +/// and exit `1` itself (this function must not call `std::process::exit` +/// directly — `clippy::exit` reserves that to `main`), rather than +/// quietly running the wrong query, which is exactly what used to +/// happen here. #[cfg(windows)] -fn self_test_reader_benchmark_args(args: &[String]) -> Option<(Vec, String)> { +fn self_test_reader_benchmark_args(args: &[String]) -> Option { let flag_index = args .iter() .position(|arg| arg == "--self-test-reader-benchmark")?; let rest = args.get(flag_index + 1..)?; let mut roots: Vec = Vec::new(); - let mut query: Option = None; + let mut positionals: Vec = Vec::new(); let mut rest_iter = rest.iter(); while let Some(arg) = rest_iter.next() { if arg == "--drive" { if let Some(value) = rest_iter.next() { roots.extend(parse_drive_list(value)); } - } else if query.is_none() { - query = Some(arg.clone()); + } else { + positionals.push(arg.clone()); } } - Some((roots, query.unwrap_or_else(|| "*".to_owned()))) + + if positionals.len() > 1 { + return Some(Err(positionals)); + } + + let query = positionals + .into_iter() + .next() + .unwrap_or_else(|| "*".to_owned()); + Some(Ok((roots, query))) } /// Parse a comma-separated drive-letter list (each entry `C` or `C:`, @@ -333,9 +367,7 @@ fn parse_drive_list(value: &str) -> Vec { /// Non-Windows stub: `--self-test-reader-benchmark` needs a real VSS /// snapshot, which doesn't exist on this platform. #[cfg(not(windows))] -const fn self_test_reader_benchmark_args( - _args: &[String], -) -> Option<(Vec, String)> { +const fn self_test_reader_benchmark_args(_args: &[String]) -> Option { None } From 276c12f4ecda2b4b18b41b5ad4b2bbc129f81f28 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:33:22 -0700 Subject: [PATCH 59/98] feat(content): log the exact search JSON + first 20 raw rows per root Investigating the row-count-inflation bug (real hardware: some drives' search results are 20x the true file count, in ways the existing exact-duplicate dedup doesn't catch) needs to see the actual wire data, not just aggregate counts. Logs the full SearchParams JSON sent per root, and the first 20 raw SearchRow objects exactly as the daemon returned them (before dedup, one full JSON row per log line so duplicates/corruption are visible by eye). Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index f08a612c2..916b64e15 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -216,7 +216,11 @@ impl CandidateSource for VssCandidateSource<'_> { attr: self.attr.clone(), ..Default::default() }; - tracing::info!(root = %root.display(), "candidate enumeration: sending search request"); + tracing::info!( + root = %root.display(), + params = %serde_json::to_string(¶ms).unwrap_or_else(|err| format!("")), + "candidate enumeration: sending search request" + ); let response = client.search(¶ms).map_err(|err| { tracing::warn!(root = %root.display(), error = %err, "candidate enumeration: search request failed"); io::Error::other(err.to_string()) @@ -224,6 +228,7 @@ impl CandidateSource for VssCandidateSource<'_> { let rows = resolve_rows(response.payload)?; let row_count = rows.len(); + log_first_rows(root, &rows); let deduped = dedup_rows_by_file_reference_and_path(rows); tracing::info!( root = %root.display(), @@ -257,6 +262,26 @@ impl CandidateSource for VssCandidateSource<'_> { } } +/// Log the first 20 raw rows exactly as the daemon returned them — +/// *before* [`dedup_rows_by_file_reference_and_path`] touches anything — +/// one full JSON row per log line so duplicates/corruption are visible +/// by eye without reconstructing them from a single giant blob. +/// Diagnostic aid for the row-count-inflation investigation (real +/// hardware has shown search returning far more rows than the true file +/// count for some drives, in ways the existing exact-duplicate dedup +/// doesn't catch). +#[cfg(windows)] +fn log_first_rows(root: &Path, rows: &[uffs_client::protocol::response::SearchRow]) { + for (index, row) in rows.iter().take(20).enumerate() { + tracing::info!( + root = %root.display(), + index, + row = %serde_json::to_string(row).unwrap_or_else(|err| format!("")), + "candidate enumeration: raw row" + ); + } +} + /// Resolve a `SearchPayload` into its `SearchRow` list, reading a /// shmem-backed result set from disk if the daemon chose that delivery /// channel — a job-scoped query is usually small enough to stay inline, From 0e694ba92883d3a8dfad0f5306d178f2bfa4fdac Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:57:21 -0700 Subject: [PATCH 60/98] fix(mft): read_all_index no longer silently reads the live volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_all_index_live and read_index_with_progress each spawn_blocking'd a closure that called VolumeHandle::open(volume) fresh — always the live \\.\: path — completely discarding whatever handle the MftReader was actually constructed with. For a reader built via open_device_path (every VSS-snapshot-backed job: uffs-content's ephemeral daemon, this whole session's benchmark), that meant candidate enumeration silently read the *live* drive instead of the frozen VSS snapshot the job leased, every single time — a point-in-time consistency violation the VSS design exists specifically to prevent. Fixed the same way the Access Broker's own handle vending already works: duplicate the reader's existing handle (VolumeHandle::duplicate, DuplicateHandle + DUPLICATE_SAME_ACCESS — generalized from the broker-only duplicate_broker_handle) before entering spawn_blocking, carry the duplicate across the thread boundary as a plain u64 (expose_provenance/with_exposed_provenance_mut, HANDLE isn't Send — same pattern persistence_capture.rs already uses), and reconstruct it on the other side via the new VolumeHandle::from_duplicated_handle instead of re-opening by drive letter. Marks the reconstructed handle broker_backed so open_overlapped_handle duplicates it further rather than ever falling back to a live \\.\: open. uffs-mft::reader::multi_drive's own VolumeHandle::open(drive) call sites are unaffected — those are the resident daemon's live drive-letter loading path and were never routed through a device path. This is independent of the still-open exact-2x row-duplication investigation; it does not appear to explain that one. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-mft/src/platform/volume.rs | 68 +++++++++++++++++++----- crates/uffs-mft/src/reader/index_read.rs | 24 +++++++-- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index 12e7fe6e1..42e72eb94 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -663,14 +663,47 @@ impl VolumeHandle { Self::from_adopted_handle(handle, volume) } - /// Build a broker-backed `VolumeHandle` from an already-duplicated, - /// caller-owned volume `handle` (the output of - /// [`duplicate_registered_handle`] / [`try_adopt_broker_handle`]). + /// Reconstruct a `VolumeHandle` from a raw handle this process already + /// owns an independent duplicate of — the `u64` produced by + /// [`Self::duplicate`] on the calling thread, carried across a + /// `spawn_blocking` boundary (a `HANDLE` isn't `Send`; `expose_provenance`/ + /// `with_exposed_provenance_mut` is this codebase's established way to + /// smuggle one across as a plain integer — see `persistence_capture.rs`). + /// + /// This is the fix for the async + /// `read_all_index`/`read_index_with_progress` entry points + /// (`reader/index_read.rs`), which used to call [`Self::open`] fresh + /// inside their `spawn_blocking` closure — silently re-opening the + /// *live* `\\.\:` volume even when the original reader was + /// constructed via [`Self::open_device_path`] against a VSS + /// snapshot device, defeating point-in-time consistency entirely. + /// + /// # Errors + /// + /// Returns [`MftError`] if the volume descriptor cannot be read from + /// the reconstructed handle. + #[cfg(windows)] + pub(crate) fn from_duplicated_handle( + raw_handle: u64, + volume: super::DriveLetter, + ) -> Result { + let handle = HANDLE(core::ptr::with_exposed_provenance_mut::( + usize::try_from(raw_handle).unwrap_or(0), + )); + Self::from_adopted_handle(handle, volume) + } + + /// Build a `VolumeHandle` from an already-duplicated, caller-owned + /// volume `handle` (the output of [`duplicate_registered_handle`] / + /// [`try_adopt_broker_handle`] / [`Self::from_duplicated_handle`]). /// /// Reads the volume descriptor from the handle and marks the result /// `broker_backed` so [`Self::open_overlapped_handle`] duplicates the - /// handle rather than re-opening `\\.\X:`. Shared by [`Self::open`]'s - /// fast-path and [`Self::from_broker_handle`] so the descriptor read + + /// handle rather than re-opening `\\.\X:` — correct regardless of + /// whether the handle actually came from the broker or was duplicated + /// from this same process's own earlier `open`/`open_device_path`. + /// Shared by [`Self::open`]'s fast-path, [`Self::from_broker_handle`], + /// and [`Self::from_duplicated_handle`] so the descriptor read + /// `broker_backed` construction live in one place. /// /// # Errors @@ -680,7 +713,7 @@ impl VolumeHandle { #[cfg(windows)] fn from_adopted_handle(handle: HANDLE, volume: super::DriveLetter) -> Result { let volume_data = Self::get_ntfs_volume_data(handle, volume)?; - tracing::info!(drive = %volume, "Adopted Access Broker volume handle for MFT read"); + tracing::info!(drive = %volume, "Adopted an already-open volume handle for MFT read"); Ok(Self { handle, volume, @@ -799,7 +832,7 @@ impl VolumeHandle { // access-denied). The broker handle is already overlapped, so hand // back an independent duplicate the caller can close on its own. if self.broker_backed { - return self.duplicate_broker_handle(); + return self.duplicate(); } let volume_path: Vec = format!("\\\\.\\{volume}:") @@ -828,16 +861,23 @@ impl VolumeHandle { }) } - /// Duplicate the adopted broker handle into a fresh, independently-owned - /// overlapped handle for the bulk MFT read path. + /// Duplicate this handle into a fresh, independently-owned handle with + /// the same access rights and mode (`DUPLICATE_SAME_ACCESS`) — + /// `self.handle` stays intact (for the volume-data queries and for + /// `Drop`), and the caller owns the returned duplicate. /// - /// Same-process `DuplicateHandle` with `DUPLICATE_SAME_ACCESS` clones the - /// access rights and the `FILE_FLAG_OVERLAPPED` mode of the broker handle; - /// the caller closes the returned handle, leaving `self.handle` intact for - /// the volume-data queries and for `Drop`. + /// Used by [`Self::open_overlapped_handle`]'s broker-backed branch, + /// and by the async `read_all_index`/`read_index_with_progress` + /// entry points (`reader/index_read.rs`) to carry an already-open + /// handle — e.g. a VSS snapshot device handle from + /// [`Self::open_device_path`] — across a `spawn_blocking` boundary. + /// Re-opening `\\.\:` fresh inside that closure (the + /// previous approach) silently read the *live* volume even when + /// this reader was constructed from a snapshot device, defeating + /// the whole point of a point-in-time read. #[cfg(windows)] #[expect(unsafe_code, reason = "FFI: DuplicateHandle / GetCurrentProcess")] - fn duplicate_broker_handle(&self) -> Result { + pub(crate) fn duplicate(&self) -> Result { use windows::Win32::Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle}; use windows::Win32::System::Threading::GetCurrentProcess; diff --git a/crates/uffs-mft/src/reader/index_read.rs b/crates/uffs-mft/src/reader/index_read.rs index 933b55dba..073cf3f06 100644 --- a/crates/uffs-mft/src/reader/index_read.rs +++ b/crates/uffs-mft/src/reader/index_read.rs @@ -102,9 +102,19 @@ impl MftReader { let parse_workers = self.parse_workers; let forensic = self.forensic; + // Duplicate this reader's already-open handle — whatever it + // points at, a live `\\.\:` open or a VSS snapshot + // device from `open_device_path` — rather than opening + // `\\.\:` fresh inside `spawn_blocking` below. `HANDLE` + // isn't `Send`, so the duplicate crosses the boundary as a + // plain `u64` (`expose_provenance`), reconstructed on the other + // side by `VolumeHandle::from_duplicated_handle`. + let handle_ptr = + u64::try_from(self.require_handle()?.duplicate()?.0.expose_provenance()).unwrap_or(0); + let result = tokio::task::spawn_blocking(move || { trace!(volume = %volume, "read_all_index: INSIDE spawn_blocking"); - let handle = VolumeHandle::open(volume)?; + let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume)?; let reader = Self { volume, source: super::MftSource::LiveVolume(Box::new(handle)), @@ -244,9 +254,17 @@ impl MftReader { let parse_workers = self.parse_workers; let forensic = self.forensic; + // See `read_all_index_live`'s matching comment: duplicate this + // reader's already-open handle instead of opening + // `\\.\:` fresh in the blocking closure below, which + // used to silently bypass a VSS snapshot device and read the + // live volume instead. + let handle_ptr = + u64::try_from(self.require_handle()?.duplicate()?.0.expose_provenance()).unwrap_or(0); + tokio::task::spawn_blocking(move || { - // Create a new reader in the blocking thread - let handle = VolumeHandle::open(volume)?; + // Reconstruct the duplicated handle in the blocking thread + let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume)?; let reader = Self { volume, source: super::MftSource::LiveVolume(Box::new(handle)), From a36eb1ce7d863afc9fe757e41c75df07feb235b1 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:24:58 -0700 Subject: [PATCH 61/98] fix(mft): get_mft_extents no longer reads the live $MFT's layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent of the read_all_index handle bug already fixed: VolumeHandle::get_mft_extents() unconditionally tried CreateFileW("{volume}:\$MFT") first — the live volume's $MFT, by drive letter — regardless of what self.handle actually pointed at. For a handle opened via open_device_path (a VSS snapshot device), this "fast path" still succeeds for any elevated caller (which every VSS-snapshot job already is, per open_device_path's own contract), so the extent map used to compute every subsequent read offset came from the *live* $MFT's retrieval pointers, not the snapshot's — a second, separate volume-identity bypass, this time corrupting read offsets rather than skipping the wrong drive. This is the leading suspect for the S: drive's 100% OpenFileById failures (every record's offset computed against a layout that may not match the frozen snapshot's actual bytes). Added VolumeHandle::is_live_letter (true only for a genuine \\.\: open, or a broker/duplicated handle known to point at the live volume — false for open_device_path). get_mft_extents now skips the live-$MFT fast path entirely when is_live_letter is false, going straight to the handle-based FRS-0 bootstrap that already reads correctly through self.handle regardless of what it points at. Threaded the flag through every VolumeHandle constructor (open, open_device_path, from_broker_handle, from_adopted_handle) and through from_duplicated_handle (read off the original handle before duplicating, since duplication preserves what a handle points at, not how it was opened). This does not yet explain the still-open exact-2x row-duplication bug on F:/M: — that investigation continues separately. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-mft/src/platform/volume.rs | 135 +++++++++++++++++------ crates/uffs-mft/src/reader/index_read.rs | 16 ++- 2 files changed, 111 insertions(+), 40 deletions(-) diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index 42e72eb94..45f8e8e66 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -441,6 +441,18 @@ pub struct VolumeHandle { /// elevated, overlapped volume handle, so [`Self::open_overlapped_handle`] /// duplicates it instead of re-opening `\\.\X:` (which would need admin). broker_backed: bool, + /// `true` when `handle` corresponds to the *live* volume — opened via + /// `\\.\:` (directly, or adopted/duplicated from a broker + /// handle that itself points at the live volume) — as opposed to an + /// arbitrary device path (e.g. a VSS snapshot device from + /// [`Self::open_device_path`]). + /// + /// [`Self::get_mft_extents`] uses this to decide whether re-deriving + /// `"{volume}:\$MFT"` from the drive letter is even valid: for a live + /// handle it's the fast, elevated-only path; for a snapshot device + /// handle it would silently read the *live* `$MFT`'s layout instead + /// of the snapshot's, corrupting every offset computed from it. + is_live_letter: bool, } #[expect( @@ -549,7 +561,8 @@ impl VolumeHandle { // stays so later opens in the same load succeed (see // `try_adopt_broker_handle`). if let Some(handle) = try_adopt_broker_handle(volume)? { - return Self::from_adopted_handle(handle, volume); + // The broker vends a handle to the *live* volume for `volume`. + return Self::from_adopted_handle(handle, volume, true); } // `DriveLetter` is already validated (`A..=Z`), so no fallible @@ -559,7 +572,7 @@ impl VolumeHandle { .encode_utf16() .chain(core::iter::once(0)) .collect(); - Self::open_raw_path(&volume_path, volume) + Self::open_raw_path(&volume_path, volume, true) } /// Opens an arbitrary device path for direct MFT reading — e.g. a VSS @@ -589,14 +602,21 @@ impl VolumeHandle { .encode_utf16() .chain(core::iter::once(0)) .collect(); - Self::open_raw_path(&wide_path, volume) + Self::open_raw_path(&wide_path, volume, false) } /// `CreateFileW` + `FSCTL_GET_NTFS_VOLUME_DATA` against an already /// NUL-terminated UTF-16 `path` — the shared body of [`Self::open`] /// (after its Access Broker fast-path) and [`Self::open_device_path`]. + /// `is_live_letter` records which of those two callers this is — see + /// the field's own doc comment on why [`Self::get_mft_extents`] needs + /// to know. #[expect(unsafe_code, reason = "FFI: windows API (CreateFileW)")] - fn open_raw_path(path: &[u16], volume: super::DriveLetter) -> Result { + fn open_raw_path( + path: &[u16], + volume: super::DriveLetter, + is_live_letter: bool, + ) -> Result { // SAFETY: `path` is UTF-16 and NUL-terminated for the duration of // the call, optional pointers are passed as `None`, and on success the // returned handle is owned by this function. @@ -637,6 +657,7 @@ impl VolumeHandle { volume, volume_data, broker_backed: false, + is_live_letter, }) } @@ -660,7 +681,8 @@ impl VolumeHandle { #[cfg(windows)] pub fn from_broker_handle(volume: super::DriveLetter, raw_handle: u64) -> Result { let handle = duplicate_registered_handle(raw_handle, volume)?; - Self::from_adopted_handle(handle, volume) + // The broker only ever vends handles to the *live* volume. + Self::from_adopted_handle(handle, volume, true) } /// Reconstruct a `VolumeHandle` from a raw handle this process already @@ -678,6 +700,11 @@ impl VolumeHandle { /// constructed via [`Self::open_device_path`] against a VSS /// snapshot device, defeating point-in-time consistency entirely. /// + /// `is_live_letter` must be the *original* handle's own + /// [`Self::is_live_letter`] (read by the caller before duplicating) — + /// duplication preserves what the handle points at, not what it was + /// opened as, so this can't be re-derived here. + /// /// # Errors /// /// Returns [`MftError`] if the volume descriptor cannot be read from @@ -686,11 +713,12 @@ impl VolumeHandle { pub(crate) fn from_duplicated_handle( raw_handle: u64, volume: super::DriveLetter, + is_live_letter: bool, ) -> Result { let handle = HANDLE(core::ptr::with_exposed_provenance_mut::( usize::try_from(raw_handle).unwrap_or(0), )); - Self::from_adopted_handle(handle, volume) + Self::from_adopted_handle(handle, volume, is_live_letter) } /// Build a `VolumeHandle` from an already-duplicated, caller-owned @@ -711,7 +739,11 @@ impl VolumeHandle { /// Returns [`MftError`] if the volume descriptor cannot be read from /// `handle`. #[cfg(windows)] - fn from_adopted_handle(handle: HANDLE, volume: super::DriveLetter) -> Result { + fn from_adopted_handle( + handle: HANDLE, + volume: super::DriveLetter, + is_live_letter: bool, + ) -> Result { let volume_data = Self::get_ntfs_volume_data(handle, volume)?; tracing::info!(drive = %volume, "Adopted an already-open volume handle for MFT read"); Ok(Self { @@ -719,6 +751,7 @@ impl VolumeHandle { volume, volume_data, broker_backed: true, + is_live_letter, }) } @@ -819,6 +852,18 @@ impl VolumeHandle { self.handle } + /// Whether this handle corresponds to the *live* volume, as opposed + /// to an arbitrary device path (e.g. a VSS snapshot device from + /// [`Self::open_device_path`]) — see the field's own doc comment. + /// Callers that need to carry this handle across a boundary that + /// loses the original open-site context (e.g. + /// [`Self::from_duplicated_handle`]'s caller in `spawn_blocking`) + /// must read this *before* duplicating. + #[must_use] + pub const fn is_live_letter(&self) -> bool { + self.is_live_letter + } + /// Opens a new handle to the same volume with `FILE_FLAG_OVERLAPPED`. /// /// # Errors @@ -1077,36 +1122,56 @@ impl VolumeHandle { reason = "FFI: windows API (CreateFileW, DeviceIoControl, CloseHandle)" )] pub fn get_mft_extents(&self) -> Result> { - let mft_path: Vec = format!("{}:\\$MFT", self.volume) - .encode_utf16() - .chain(core::iter::once(0)) - .collect(); - - // Fast path (elevated): open $MFT and ask the kernel for its retrieval - // pointers. A non-elevated daemon can't open $MFT at all, so this open - // fails and we bootstrap the real layout from FRS 0 below. - // SAFETY: `mft_path` is UTF-16 + NUL-terminated for the call; optional - // pointers are `None`; any returned handle is wrapped in `HandleGuard`. - if let Ok(mft_handle) = unsafe { - CreateFileW( - PCWSTR::from_raw(mft_path.as_ptr()), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - None, - OPEN_EXISTING, - FILE_FLAGS_AND_ATTRIBUTES(0), - None, - ) - } { - let _guard = HandleGuard(mft_handle); - return get_retrieval_pointers(mft_handle); + // The `"{volume}:\$MFT"` fast path below only makes sense when + // `self.handle` actually corresponds to the *live* volume: it + // re-derives a live drive-letter path from `self.volume` (a bare + // label) and asks the kernel for *that* $MFT's retrieval + // pointers, entirely independent of `self.handle`. For a VSS + // snapshot device handle (`is_live_letter == false`), an + // elevated caller (which every VSS-snapshot job is, by + // `open_device_path`'s own contract) would have this open + // succeed anyway — silently returning the *live* $MFT's extent + // layout to be used against the *snapshot's* on-disk bytes, + // corrupting every offset computed from it. Skip straight to + // the handle-based bootstrap in that case. + if self.is_live_letter { + let mft_path: Vec = format!("{}:\\$MFT", self.volume) + .encode_utf16() + .chain(core::iter::once(0)) + .collect(); + + // Fast path (elevated): open $MFT and ask the kernel for its + // retrieval pointers. A non-elevated daemon can't open $MFT at + // all, so this open fails and we bootstrap the real layout from + // FRS 0 below. + // SAFETY: `mft_path` is UTF-16 + NUL-terminated for the call; + // optional pointers are `None`; any returned handle is wrapped + // in `HandleGuard`. + if let Ok(mft_handle) = unsafe { + CreateFileW( + PCWSTR::from_raw(mft_path.as_ptr()), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + } { + let _guard = HandleGuard(mft_handle); + return get_retrieval_pointers(mft_handle); + } } - // Non-elevated (broker-backed) path: `$MFT` can't be opened directly. - // The old fallback was a SINGLE assumed-contiguous extent — silently - // wrong on a fragmented MFT (it reads the wrong physical region past the - // first fragment, producing a partial index). Bootstrap the REAL - // extents from FRS 0's `$DATA` runlist, read through our volume handle. + // Non-elevated (broker-backed) path, or any non-live-letter + // handle (VSS snapshot device): `$MFT` can't be opened directly + // by drive letter, or doing so wouldn't reflect this handle's + // actual volume. The old fallback was a SINGLE assumed-contiguous + // extent — silently wrong on a fragmented MFT (it reads the wrong + // physical region past the first fragment, producing a partial + // index). Bootstrap the REAL extents from FRS 0's `$DATA` + // runlist, read through our volume handle — correct regardless + // of what `self.handle` actually points at. Ok(self.mft_extents_from_frs0()) } diff --git a/crates/uffs-mft/src/reader/index_read.rs b/crates/uffs-mft/src/reader/index_read.rs index 073cf3f06..bd682d6f0 100644 --- a/crates/uffs-mft/src/reader/index_read.rs +++ b/crates/uffs-mft/src/reader/index_read.rs @@ -108,13 +108,17 @@ impl MftReader { // `\\.\:` fresh inside `spawn_blocking` below. `HANDLE` // isn't `Send`, so the duplicate crosses the boundary as a // plain `u64` (`expose_provenance`), reconstructed on the other - // side by `VolumeHandle::from_duplicated_handle`. + // side by `VolumeHandle::from_duplicated_handle`. `is_live_letter` + // must be read off the *original* handle before duplicating — + // duplication preserves what it points at, not how it was opened. + let existing_handle = self.require_handle()?; + let is_live_letter = existing_handle.is_live_letter(); let handle_ptr = - u64::try_from(self.require_handle()?.duplicate()?.0.expose_provenance()).unwrap_or(0); + u64::try_from(existing_handle.duplicate()?.0.expose_provenance()).unwrap_or(0); let result = tokio::task::spawn_blocking(move || { trace!(volume = %volume, "read_all_index: INSIDE spawn_blocking"); - let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume)?; + let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume, is_live_letter)?; let reader = Self { volume, source: super::MftSource::LiveVolume(Box::new(handle)), @@ -259,12 +263,14 @@ impl MftReader { // `\\.\:` fresh in the blocking closure below, which // used to silently bypass a VSS snapshot device and read the // live volume instead. + let existing_handle = self.require_handle()?; + let is_live_letter = existing_handle.is_live_letter(); let handle_ptr = - u64::try_from(self.require_handle()?.duplicate()?.0.expose_provenance()).unwrap_or(0); + u64::try_from(existing_handle.duplicate()?.0.expose_provenance()).unwrap_or(0); tokio::task::spawn_blocking(move || { // Reconstruct the duplicated handle in the blocking thread - let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume)?; + let handle = VolumeHandle::from_duplicated_handle(handle_ptr, volume, is_live_letter)?; let reader = Self { volume, source: super::MftSource::LiveVolume(Box::new(handle)), From a770164cd0db33597f4a435f7ac6cb581b6dbe89 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:03:17 -0700 Subject: [PATCH 62/98] fix(mft): gate two more live-volume-path accesses on is_live_letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_mft_bitmap_internal() and open_overlapped_handle() both re-derived a live "\\.\:"-style path from self.volume instead of using the already-open self.handle, the same anti-pattern already fixed in read_all_index_live/read_index_with_progress and get_mft_extents. For a VSS-snapshot-backed VolumeHandle this silently queried/opened the live volume instead of the frozen snapshot. - get_mft_bitmap_internal: skip the live $MFT::$BITMAP query entirely when !is_live_letter and return an all-valid bitmap instead. The bitmap is advisory-only (chunking always reads full chunks for correctness), so this is exactly as correct as a genuine snapshot bitmap would be. - open_overlapped_handle: duplicate the existing handle instead of re-opening "\\.\:" whenever broker_backed || !is_live_letter, not just broker_backed. A fifth instance, open_unbuffered_handle (used only by read_write_protect_fallback's IOCP-failure fallback), has the same anti-pattern but is left unfixed here: it isn't reachable in current VSS testing (primary IOCP reads succeed), and a correct fix needs the actual device-path string, which VolumeHandle doesn't store today — duplicate() wouldn't preserve the FILE_FLAG_NO_BUFFERING semantics that fallback specifically needs. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-mft/src/platform/volume.rs | 38 +++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index 45f8e8e66..bf77e3afc 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -873,10 +873,18 @@ impl VolumeHandle { pub fn open_overlapped_handle(&self) -> Result { let volume = self.volume; - // Broker-backed: `\\.\X:` can't be re-opened here (non-elevated → - // access-denied). The broker handle is already overlapped, so hand - // back an independent duplicate the caller can close on its own. - if self.broker_backed { + // Duplicate instead of re-opening `\\.\:` whenever that + // would be wrong or unsafe to do: + // - `broker_backed`: this process isn't elevated enough to `CreateFileW` the + // volume itself (non-elevated → access-denied); the broker/adopted handle is + // already overlapped, so hand back an independent duplicate the caller can + // close on its own. + // - `!is_live_letter`: this handle doesn't correspond to the live volume at all + // (e.g. a VSS snapshot device from `open_device_path`) — re-deriving + // `\\.\:` here would silently open the *live* volume instead, the + // same class of bug already fixed in + // `get_mft_extents`/`get_mft_bitmap_internal`. + if self.broker_backed || !self.is_live_letter { return self.duplicate(); } @@ -1287,6 +1295,28 @@ impl VolumeHandle { FILE_BEGIN, GetFileSizeEx, ReadFile, SYNCHRONIZE, SetFilePointerEx, }; + // Same rationale as `get_mft_extents`: `"{volume}:\$MFT::$BITMAP"` + // re-derives a *live* drive-letter path from `self.volume` (a bare + // label), entirely independent of `self.handle` — for a VSS + // snapshot device handle this would silently query the *live* + // volume's current allocation bitmap instead of the snapshot's. + // The bitmap is advisory-only (chunk generation always reads full + // chunks regardless — see `chunking.rs`'s "reading full chunk for + // correctness" comments), so skipping straight to the safe + // all-valid fallback is exactly as correct as a genuine snapshot + // bitmap would be, without the live-volume query at all. + if !self.is_live_letter { + if verbose { + tracing::info!( + volume = %self.volume, + "Non-live-letter handle: skipping live $BITMAP query, using all-valid bitmap" + ); + } + return Ok(MftBitmap::new_all_valid(frs_to_usize( + self.estimated_record_count(), + ))); + } + let bitmap_path_str = format!("{}:\\$MFT::$BITMAP", self.volume); let bitmap_path: Vec = bitmap_path_str .encode_utf16() From 5c0ff5d200a5d451f11120cc761d568f10d0ccfb Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:58:23 -0700 Subject: [PATCH 63/98] fix(daemon): ephemeral instances no longer auto-discover live drives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the exact-2x row duplication seen on real hardware for every VSS content-export drive (F: 542/271, M: 2024/1012, and S: with divergent file_reference values for touched files): uffs-content spawns each ephemeral uffsd with `--device =` and never `--drive`. resolve_drive_list() auto-discovers every local NTFS drive whenever `--drive` is empty (the resident-daemon default), which also ran for ephemeral instances — loading the same letter twice: once live via auto-discovery, once from the VSS snapshot via the explicit --device source. Both got registered and searched, doubling every result. For files untouched since the live load this produced two byte- identical rows (same file_reference), silently absorbed by the existing dedup_rows_by_file_reference_and_path guard. For files touched in between, the two loads captured different NTFS sequence- number generations: the live/cached copy's file_reference doesn't exist in the frozen snapshot, so uffs-content-reader's OpenFileById against the snapshot device failed for exactly half the affected rows. Fix: resolve_drive_list() now skips the auto-discover fallback entirely when the instance is ephemeral (--ephemeral-id), returning just the explicit --drive list (empty in uffs-content's case) instead. An ephemeral, job-scoped instance exists to serve only the sources it was spawned with, same principle already applied to skipping its USN journal loops and index-cache reads/writes. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-daemon/src/startup.rs | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/uffs-daemon/src/startup.rs b/crates/uffs-daemon/src/startup.rs index 201578d5e..6dee25723 100644 --- a/crates/uffs-daemon/src/startup.rs +++ b/crates/uffs-daemon/src/startup.rs @@ -226,10 +226,32 @@ pub(crate) fn drive_letter_matches( /// On Windows, an empty `--drive` triggers auto-discovery; non-empty /// respects the explicit list. Always empty on non-Windows since /// live MFT scanning is Windows-only. +/// +/// An ephemeral instance (`--ephemeral-id`, always paired with one or +/// more `--device` VSS-snapshot sources) never auto-discovers: it is a +/// job-scoped instance that exists to serve exactly the device sources +/// it was spawned with, never anything a resident daemon would also be +/// scanning. Auto-discovering here — the same fallback the resident +/// daemon uses when `--drive` is omitted — used to also load every +/// local NTFS drive *live*, including the very letter(s) already +/// covered by `--device`. Both copies got registered and searched, +/// silently doubling every search result for that letter (identical +/// `file_reference` for files untouched since the live load; divergent +/// by whole NTFS-sequence-number generations for anything touched +/// in between, which is what broke `OpenFileById` against the VSS +/// device for half the rows on real hardware). `uffs-content` never +/// passes `--drive` alongside `--device`, so without this guard every +/// ephemeral spawn hit the auto-discover branch. #[cfg(windows)] pub(crate) fn resolve_drive_list(config: &DaemonConfig) -> Vec { let explicit = config.drives.clone(); if explicit.is_empty() { + if config.ephemeral_id.is_some() { + tracing::info!( + "Ephemeral instance: skipping live-drive auto-discovery (device sources only)" + ); + return Vec::new(); + } let auto_drives = uffs_mft::detect_ntfs_drives(); tracing::info!( count = auto_drives.len(), @@ -254,3 +276,60 @@ pub(crate) const fn resolve_drive_list( ) -> Vec { Vec::new() } + +#[cfg(test)] +#[cfg(windows)] +mod tests { + use super::resolve_drive_list; + use crate::DaemonConfig; + + /// Minimal `DaemonConfig` builder for `resolve_drive_list` tests — + /// every field but `drives`/`device_sources`/`ephemeral_id` is + /// irrelevant to drive-list resolution. + fn config( + drives: Vec, + device_sources: Vec<(String, uffs_mft::platform::DriveLetter)>, + ephemeral_id: Option<&str>, + ) -> DaemonConfig { + DaemonConfig { + mft_files: Vec::new(), + data_dir: None, + drives, + device_sources, + idle_timeout: 0, + no_retire: false, + no_cache: false, + log_level: "info".to_owned(), + log_file: None, + ephemeral_id: ephemeral_id.map(str::to_owned), + } + } + + /// An ephemeral instance spawned with `--device` but no `--drive` + /// (`uffs-content`'s only spawn shape) must never fall through to + /// live-drive auto-discovery — that used to double-load the same + /// letter the `--device` source already covers. + #[test] + fn ephemeral_with_device_sources_skips_auto_discovery() { + let letter = uffs_mft::platform::DriveLetter::parse('F').expect("valid letter"); + let cfg = config( + Vec::new(), + vec![( + "\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy1=F".to_owned(), + letter, + )], + Some("job-1"), + ); + assert_eq!(resolve_drive_list(&cfg), Vec::new()); + } + + /// An ephemeral instance with an explicit `--drive` still respects + /// it — the guard only suppresses the *auto-discover* fallback, not + /// a caller's explicit request. + #[test] + fn ephemeral_with_explicit_drives_keeps_them() { + let letter = uffs_mft::platform::DriveLetter::parse('D').expect("valid letter"); + let cfg = config(vec![letter], Vec::new(), Some("job-2")); + assert_eq!(resolve_drive_list(&cfg), vec![letter]); + } +} From 0d7592bca3af57dfb74a97280641cfd05e8b0b4e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:42:56 -0700 Subject: [PATCH 64/98] perf(content): drive-type-aware content-read concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware benchmarking showed a full multi-drive --self-test-reader-benchmark run spending 43.6s of 165s reading 5266 tiny files at ~0.78 MiB/s, with "6 drives concurrently" barely faster than one drive alone (8.3ms/candidate vs 10.7ms/candidate solo). Root cause: ContentReader opened exactly one connection per leased drive, and every read is a strict, unpipelined request/response round trip — so run_job's read concurrency (previously hardcoded to resources.leases.len()) had no real effect once more than one thread targeted the same drive; extra threads just queued on that drive's single connection mutex. Naively raising concurrency uniformly would have made things worse: four of the six drives benchmarked (D:, E:, M:, S:) are HDDs, where seek time dominates. Candidates are collected in roughly MFT/on-disk order, so reading them strictly one at a time approximates sequential access; racing concurrent reads against the same spinning disk instead scatters the head across every in-flight read's location. Fix is drive-type-aware, not a flat number: - reader_client::ContentReader now opens a per-drive connection pool (a bounded crossbeam-channel of already-open pipe connections, checked out/back in per read; a connection that errors mid-round-trip is dropped rather than reused, since its framing state is unknown). - vss_job.rs sizes each lease's pool via uffs_mft::platform:: detect_drive_type: CONNECTIONS_PER_DRIVE (8) for NVMe/SSD, exactly 1 for HDD/removable/virtual/unknown. - workflow::run_job takes a new ReadConcurrency (per-lease concurrency map, defaulting to a flat value for the cross-platform fake/test harness) instead of one global usize, and batches candidates by lease boundary so an HDD's own candidates are never batched together even if a job spans multiple drives. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/uffs-content/Cargo.toml | 5 + crates/uffs-content/src/job/reader_client.rs | 176 +++++++++++++----- crates/uffs-content/src/job/tests.rs | 4 +- crates/uffs-content/src/job/vss_job.rs | 58 ++++-- crates/uffs-content/src/job/workflow.rs | 163 +++++++++++++--- crates/uffs-content/src/main.rs | 4 + .../tests/e2e_dir_walk_parity_fake_reader.rs | 6 +- .../tests/e2e_real_vss_content_reader.rs | 2 + 9 files changed, 333 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 745c9a093..9fba791be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4486,6 +4486,7 @@ version = "0.6.27" dependencies = [ "anyhow", "blake3", + "crossbeam-channel", "serde", "serde_json", "tempfile", diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index ebeb035b1..0b4af3e02 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -129,6 +129,11 @@ tokio = { workspace = true, features = ["net"] } # transport server (`src/serve/pipe_io.rs`) — the same primitives # `uffs-daemon`'s and `uffs-content-reader`'s named-pipe servers use. uffs-security.workspace = true +# Per-drive content-reader connection pool (`src/job/reader_client.rs`): +# a bounded channel of open pipe connections doubles as a checkout/ +# check-in pool with free blocking-wait-for-availability semantics. +# Already a workspace dependency (`uffs-mft` uses it the same way). +crossbeam-channel.workspace = true [build-dependencies] uffs-version = { workspace = true, features = ["build"] } diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index 2cb3b5757..45dddda28 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -6,21 +6,26 @@ //! Spawns `uffs-content-reader --device = ...` //! once per job — mirrors [`super::ephemeral_daemon`]'s spawn model, but //! for the content-reading phase rather than target selection — and -//! opens **one persistent connection per leased drive** to its fixed -//! `READER_PIPE_NAME`, sending framed `ReadRequest`/`ReadResponse` -//! messages over whichever connection matches a read's -//! `snapshot_lease_id`. +//! opens **a pool of persistent connections per leased drive** to its +//! fixed `READER_PIPE_NAME`, sending framed `ReadRequest`/`ReadResponse` +//! messages over whichever pool matches a read's `snapshot_lease_id`. //! -//! One connection per drive, not one shared connection for the whole -//! job: a `Mutex`-guarded connection serializes every read that uses -//! it, so a single shared connection would serialize reads for -//! genuinely independent physical drives behind each other for no -//! reason. Keying connections by `snapshot_lease_id` means reads for -//! different drives never contend on the same mutex, while reads for -//! the *same* drive still serialize behind that drive's own -//! connection (reasonable — extending to more than one connection per -//! drive is a small follow-up if a single volume's own queue depth -//! turns out to matter). +//! Each pool's *size* is chosen by the caller (`vss_job.rs`), not fixed +//! here — see `CONNECTIONS_PER_DRIVE`'s doc comment for why an +//! HDD-backed drive gets exactly one connection (real read concurrency +//! of 1, preserving the enumeration/MFT order candidates were collected +//! in) while an NVMe/SSD-backed drive gets many. Keying pools by +//! `snapshot_lease_id` means reads for different drives never contend +//! on each other's pool regardless of size. +//! +//! Each pool is a bounded [`crossbeam_channel`] of already-open +//! connections: checking one out is a blocking `recv` (waits for a +//! connection to free up rather than erroring), and a connection that +//! survives its round trip unscathed is sent back for reuse. A +//! connection that errors mid-round-trip (frame desync, pipe reset) is +//! deliberately *not* returned — better to shrink that drive's pool by +//! one than serve subsequent reads over a connection in an unknown +//! framing state. //! //! Mirrors [`super::snapshot_client`]'s connect style (plain //! `std::fs::OpenOptions` + `Read`/`Write`) and wire framing @@ -32,10 +37,10 @@ use core::time::Duration; use std::collections::HashMap; use std::io::{Read as _, Write as _}; use std::process::{Child, Command, Stdio}; -use std::sync::Mutex; use std::time::Instant; use anyhow::{Context as _, Result}; +use crossbeam_channel::{Receiver, Sender}; use uffs_content_reader_protocol::codec::Reader as WireReader; use uffs_content_reader_protocol::{ MAX_RESPONSE_PAYLOAD_BYTES, READER_PIPE_NAME, ReadRequest, ReadResponse, RequestedReadMode, @@ -49,8 +54,30 @@ const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(10); /// Delay between connect retries. const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(50); -/// A running `uffs-content-reader` process + its live pipe connections -/// (one per leased drive), held for the whole job's content-reading +/// Connections given to an NVMe/SSD-backed drive's pool. +/// +/// Each connection is a plain, unpipelined request/response round trip +/// over a named pipe, so this is that drive's real read concurrency — +/// see the module doc comment. Reads here are small files (an +/// IPC-round-trip-bound workload, not a bytes/sec-bound one), so a +/// value well above typical disk queue depth is appropriate for a +/// no-seek-penalty medium designed around deep concurrent queues. +/// +/// An HDD (or removable/virtual/unknown — anything +/// [`uffs_mft::platform::DriveType::is_high_performance`] doesn't claim) +/// gets exactly 1 connection instead, chosen by the caller +/// (`vss_job.rs`) — not this constant. Racing multiple concurrent reads +/// against the same spinning disk scatters its head across every +/// in-flight read's location instead of letting it sweep through +/// candidates in the order they were enumerated (which, since +/// candidates come off the MFT roughly in on-disk order, approximates +/// sequential access) — pure seek-time waste for a medium where seeks, +/// not bandwidth, are the bottleneck. +pub(crate) const CONNECTIONS_PER_DRIVE: usize = 8; + +/// A running `uffs-content-reader` process + its live pipe connection +/// pools (one per leased drive, sized by the caller — see +/// `CONNECTIONS_PER_DRIVE`), held for the whole job's content-reading /// phase. pub(crate) struct ContentReader { /// The spawned `uffs-content-reader` child process. Killed on @@ -58,28 +85,60 @@ pub(crate) struct ContentReader { /// direct kill is simplest and correct (mirrors /// [`super::ephemeral_daemon::EphemeralDaemon::shutdown`]). child: Child, - /// One persistent pipe connection per leased drive, keyed by - /// `snapshot_lease_id` — see the module doc comment for why this is - /// per-drive rather than one shared connection. `Mutex`-guarded so - /// `read_at` can take `&self` (the `ContentSource` trait's shape) - /// while still mutating a connection. - connections: HashMap>, + /// One connection pool per leased drive, keyed by + /// `snapshot_lease_id` — see the module doc comment. + connections: HashMap, /// This job's id, echoed into every `ReadRequest`. job_id: [u8; 16], /// Monotonically increasing nonce for request/response correlation. next_nonce: AtomicU64, } +/// A bounded pool of already-open pipe connections for one drive. +/// +/// `checkout`/`checkin` are the two ends of the same bounded +/// [`crossbeam_channel`], pre-filled at construction with as many +/// connections as the caller asked for — see the module doc comment for +/// the checkout/checkin/drop-on-error contract. +struct ConnectionPool { + /// Checked-in (idle) connections, ready to be checked out. + checkout: Receiver, + /// The other end of the same channel — returns a connection after a + /// successful round trip. + checkin: Sender, +} + +impl ConnectionPool { + /// Open `pool_size` fresh connections and fill a new pool with them + /// (clamped to at least 1 — a pool can never be usefully empty). + fn connect(lease_id: u64, pool_size: usize) -> Result { + let clamped_pool_size = pool_size.max(1); + let (checkin, checkout) = crossbeam_channel::bounded(clamped_pool_size); + for _ in 0..clamped_pool_size { + let pipe = connect_with_retry() + .with_context(|| format!("failed to open a connection for lease {lease_id}"))?; + // Never blocks: the channel's capacity is exactly + // clamped_pool_size and we send exactly that many. + checkin.try_send(pipe).map_err(|err| { + anyhow::anyhow!("connection pool for lease {lease_id} overfilled: {err}") + })?; + } + Ok(Self { checkout, checkin }) + } +} + impl ContentReader { /// Spawn `uffs-content-reader --device = - /// ...` for every pair in `devices`, and open one connection per - /// device. + /// ...` for every `(device_path, lease_id, _)` in `devices`, and open + /// a connection pool of the given size for each — see + /// `CONNECTIONS_PER_DRIVE` for how the caller should choose that + /// size per drive. /// /// # Errors /// Returns an error if `devices` is empty, the binary can't be - /// spawned, or any of the `devices.len()` connections never comes up - /// within [`CONNECT_RETRY_BUDGET`]. - pub(crate) fn spawn(job_id: [u8; 16], devices: &[(String, u64)]) -> Result { + /// spawned, or any connection never comes up within + /// [`CONNECT_RETRY_BUDGET`]. + pub(crate) fn spawn(job_id: [u8; 16], devices: &[(String, u64, usize)]) -> Result { anyhow::ensure!( !devices.is_empty(), "at least one device is required to spawn a content reader" @@ -91,7 +150,7 @@ impl ContentReader { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); - for (device_path, lease_id) in devices { + for (device_path, lease_id, _pool_size) in devices { command .arg("--device") .arg(format!("{device_path}={lease_id}")); @@ -107,11 +166,15 @@ impl ContentReader { tracing::info!(pid = child.id(), "content reader: process spawned"); let mut connections = HashMap::with_capacity(devices.len()); - for (_device_path, lease_id) in devices { - let pipe = connect_with_retry() - .with_context(|| format!("failed to open a connection for lease {lease_id}"))?; - tracing::info!(lease_id, "content reader: connection established"); - connections.insert(*lease_id, Mutex::new(pipe)); + for (_device_path, lease_id, pool_size) in devices { + let pool = ConnectionPool::connect(*lease_id, *pool_size) + .with_context(|| format!("failed to build connection pool for lease {lease_id}"))?; + tracing::info!( + lease_id, + connections = pool_size, + "content reader: connection pool established" + ); + connections.insert(*lease_id, pool); } Ok(Self { @@ -184,22 +247,43 @@ impl ContentReader { } /// Send one framed [`ReadRequest`] and read back one framed - /// [`ReadResponse`], over `snapshot_lease_id`'s own connection — - /// never contending with reads for a different drive. + /// [`ReadResponse`], over one of `snapshot_lease_id`'s own pooled + /// connections — never contending with a different drive's pool. + /// Blocks until a connection is available if every connection in + /// this drive's pool is currently checked out. fn round_trip(&self, snapshot_lease_id: u64, request: &ReadRequest) -> Result { - let connection = self.connections.get(&snapshot_lease_id).ok_or_else(|| { + let pool = self.connections.get(&snapshot_lease_id).ok_or_else(|| { anyhow::anyhow!( - "no content reader connection for snapshot_lease_id {snapshot_lease_id}" + "no content reader connection pool for snapshot_lease_id {snapshot_lease_id}" ) })?; - let Ok(mut pipe) = connection.lock() else { - anyhow::bail!("content reader pipe mutex poisoned (lease {snapshot_lease_id})"); - }; - write_framed_message(&mut pipe, &request.encode())?; - let response_bytes = read_framed_message(&mut pipe)?; - let mut wire_reader = WireReader::new(&response_bytes); - ReadResponse::decode(&mut wire_reader, MAX_RESPONSE_PAYLOAD_BYTES) - .map_err(|err| anyhow::anyhow!("malformed Reader response: {err}")) + let mut pipe = pool.checkout.recv().map_err(|err| { + anyhow::anyhow!( + "connection pool for lease {snapshot_lease_id} is exhausted \ + (every connection failed): {err}" + ) + })?; + + let result = (|| -> Result { + write_framed_message(&mut pipe, &request.encode())?; + let response_bytes = read_framed_message(&mut pipe)?; + let mut wire_reader = WireReader::new(&response_bytes); + ReadResponse::decode(&mut wire_reader, MAX_RESPONSE_PAYLOAD_BYTES) + .map_err(|err| anyhow::anyhow!("malformed Reader response: {err}")) + })(); + + if result.is_ok() { + // Still framing-aligned — return it for reuse. Best-effort: + // the pool never holds more than its original connection + // count, so this can't actually overflow; `try_send` is + // just the non-panicking way to express "give it back", and + // a failure here just means one fewer pooled connection. + drop(pool.checkin.try_send(pipe)); + } + // On error, `pipe` is dropped here instead of returned — see + // the module doc comment for why a connection that failed + // mid-round-trip must not be reused. + result } /// Tear down this instance: kill the spawned process. The pipe diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index c46b7c452..547ab1be5 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -17,7 +17,7 @@ use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; use super::content_source::{ContentSource as _, FsContentSource}; use super::intake::JobRequest; use super::manifest_builder::build_manifest; -use super::workflow::run_job; +use super::workflow::{ReadConcurrency, run_job}; #[test] fn dir_walk_candidate_source_enumerates_files_not_directories() { @@ -151,7 +151,7 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { // >1 so this test also exercises the concurrent-read batching // path (`read_candidate_batch`), not just the fully-sequential // (`concurrency == 1`) case. - 4, + &ReadConcurrency::flat(4), |frame| { frames.push(frame); Ok(()) diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index 70fa84ec9..8bc6e7de5 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -22,9 +22,9 @@ use anyhow::{Context as _, Result}; use super::candidate_source::VssCandidateSource; use super::content_source::VssContentSource; use super::intake::JobRequest; -use super::reader_client::ContentReader; +use super::reader_client::{CONNECTIONS_PER_DRIVE, ContentReader}; use super::vss_orchestrator; -use super::workflow::{JobOutcome, run_job}; +use super::workflow::{JobOutcome, ReadConcurrency, run_job}; /// Run `request` end to end against a real VSS snapshot. /// @@ -71,25 +71,38 @@ where let candidate_source = VssCandidateSource::new(&resolved_request, &resources.daemon, drive_to_lease); - let devices_for_reader: Vec<(String, u64)> = resources - .leases - .iter() - .map(|lease| (lease.device_path.clone(), lease.lease_id)) - .collect(); + // Per-drive read concurrency: an HDD gets exactly one connection + // (read its candidates strictly one at a time, in the order they + // were enumerated — approximating sequential disk access instead of + // seek-thrashing between concurrent reads on a spinning disk), an + // NVMe/SSD gets `CONNECTIONS_PER_DRIVE` (no seek penalty to protect, + // and it benefits from many reads in flight). See + // `reader_client::CONNECTIONS_PER_DRIVE` and + // `workflow::ReadConcurrency`'s doc comments for both sides of this. + let mut devices_for_reader: Vec<(String, u64, usize)> = + Vec::with_capacity(resources.leases.len()); + let mut read_concurrency = ReadConcurrency::new(1); + for lease in &resources.leases { + let connections = drive_read_connections(lease.drive_letter); + tracing::info!( + drive = %lease.drive_letter, + connections, + "content read: per-drive connection count" + ); + read_concurrency.set(lease.lease_id, connections); + devices_for_reader.push((lease.device_path.clone(), lease.lease_id, connections)); + } + let content_reader = ContentReader::spawn(job_id, &devices_for_reader) .context("failed to spawn the content reader")?; let content_source = VssContentSource::new(content_reader); - // One concurrent content-read per leased drive — see - // `workflow::run_job`'s "Concurrent reads, sequential emission" doc - // section for why this is safe/correct at any value. - let concurrency = resources.leases.len(); let result = run_job( &resolved_request, &candidate_source, &content_source, run_dir, - concurrency, + &read_concurrency, emit_frame, ) .context("run_job failed"); @@ -110,6 +123,27 @@ where result } +/// How many concurrent content-read connections `drive_letter`'s lease +/// should get: [`CONNECTIONS_PER_DRIVE`] for a high-performance medium +/// (NVMe/SSD — no seek penalty, benefits from many reads in flight), or +/// exactly `1` for anything else (HDD, removable, virtual, or a type +/// that couldn't be determined) — see [`CONNECTIONS_PER_DRIVE`]'s doc +/// comment for why concurrency `1` is the correct choice for a +/// seek-bound medium, not just a conservative fallback. +fn drive_read_connections(drive_letter: char) -> usize { + let Ok(letter) = uffs_mft::platform::DriveLetter::parse(drive_letter) else { + // Unreachable in practice: `drive_letter` came from a lease VSS + // already accepted for this exact letter. Fall back to the safe + // (sequential) choice rather than panicking. + return 1; + }; + if uffs_mft::platform::detect_drive_type(letter).is_high_performance() { + CONNECTIONS_PER_DRIVE + } else { + 1 + } +} + /// Resolve `request.roots`: as given if non-empty, else one root per /// local NTFS drive on this machine — the consumer's "search everything" /// default, matching `uffsd`'s own no-`--drive`-flag fallback diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 19adfd955..09d7283ea 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -11,19 +11,30 @@ //! //! # Concurrent reads, sequential emission //! -//! Candidates are read `concurrency`-at-a-time (see [`run_job`]): each -//! batch's candidates are read on their own `std::thread::scope` thread -//! — concurrently, so reads for candidates on different drives (each -//! routed to its own connection by `reader_client::ContentReader`, see -//! that module's doc comment) actually overlap instead of serializing — -//! but every batch's frames are still *emitted* strictly in original -//! candidate order, on the caller's own thread, exactly matching the -//! fully-sequential emission order this function has always produced. -//! `emit_frame`/`frame_sequence`/`counters`/`failure_log` are therefore -//! still only ever touched from one thread; no synchronization was -//! added to any of them, and downstream consumers of the frame stream -//! (`crate::serve::stream::Grouped`) see exactly the same per-candidate- -//! contiguous ordering as the fully-sequential (`concurrency == 1`) case. +//! Candidates are read in batches, each batch's size chosen per-drive by +//! [`ReadConcurrency`] (see [`run_job`]): each batch's candidates are +//! read on their own `std::thread::scope` thread — concurrently, routed +//! to that drive's own connection pool by `reader_client::ContentReader` +//! (see that module's doc comment) — but every batch's frames are still +//! *emitted* strictly in original candidate order, on the caller's own +//! thread, exactly matching the fully-sequential emission order this +//! function has always produced. `emit_frame`/`frame_sequence`/ +//! `counters`/`failure_log` are therefore still only ever touched from +//! one thread; no synchronization was added to any of them, and +//! downstream consumers of the frame stream (`crate::serve::stream:: +//! Grouped`) see exactly the same per-candidate-contiguous ordering as +//! the fully-sequential (every lease at concurrency `1`) case. +//! +//! A batch never spans two different `snapshot_lease_id`s — candidates +//! are collected one root/drive at a time (see `run_job`'s enumeration +//! loop), so they already arrive grouped contiguously by drive, and +//! [`run_job`]'s batching stops at a lease boundary rather than +//! overshooting into the next drive's own concurrency setting. This is +//! what makes an HDD-backed lease's concurrency-`1` setting actually +//! mean "read every candidate strictly one at a time, in the order they +//! were enumerated" — the same order the MFT (and therefore, roughly, +//! on-disk position) produced them in — rather than being diluted by +//! whatever other drives happen to be in the same job. //! //! # Why `emit_frame` is a callback, not a returned `Vec` //! @@ -66,6 +77,109 @@ use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; /// concern, not something this workflow needs to get "right" yet. pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 64 * 1024; +/// Per-drive (per `snapshot_lease_id`) content-read concurrency. +/// +/// An HDD-backed lease should read its candidates one at a time, in the +/// order [`CandidateSource::enumerate`] returned them: candidates come +/// off the MFT roughly in on-disk order, so reading them strictly in +/// sequence approximates sequential disk access; racing several +/// concurrent reads against the same spinning disk instead scatters its +/// head across every in-flight read's location — pure seek-time waste. +/// An NVMe/SSD-backed lease has no seek penalty to protect and benefits +/// from many candidates in flight at once (see `reader_client`'s +/// `ConnectionPool`, which is what actually backs this concurrency on +/// the wire — this type only controls how many threads +/// [`run_job`] fans a given lease's reads out to). +/// +/// `snapshot_lease_id == 0` (never a real Broker-assigned lease id — see +/// [`CandidateEntry::snapshot_lease_id`]) always falls through to +/// `default`, which is what the cross-platform fake/test harness (no +/// drive-type concept at all) relies on via [`Self::flat`]. +#[derive(Debug, Clone)] +pub struct ReadConcurrency { + /// Concurrency overrides, keyed by `snapshot_lease_id`. + per_lease: std::collections::HashMap, + /// Concurrency for any lease not present in `per_lease`. + default: usize, +} + +impl ReadConcurrency { + /// A single flat concurrency for every lease — for callers with no + /// per-drive concurrency to report (the cross-platform fake/test + /// harness). `1` gives the fully-sequential, deterministic-order + /// behavior some tests rely on. + #[must_use] + pub fn flat(concurrency: usize) -> Self { + Self { + per_lease: std::collections::HashMap::new(), + default: concurrency.max(1), + } + } + + /// Start from `default` (used for any lease [`Self::set`] hasn't + /// overridden) with no per-lease overrides yet. + #[must_use] + pub fn new(default: usize) -> Self { + Self { + per_lease: std::collections::HashMap::new(), + default: default.max(1), + } + } + + /// Override `lease_id`'s own concurrency. + pub fn set(&mut self, lease_id: u64, concurrency: usize) { + self.per_lease.insert(lease_id, concurrency.max(1)); + } + + /// The concurrency to use for a candidate from `lease_id`. + fn for_lease(&self, lease_id: u64) -> usize { + self.per_lease + .get(&lease_id) + .copied() + .unwrap_or(self.default) + } +} + +/// Split `candidates` into batches for [`read_candidate_batch`]: each +/// batch holds only candidates from one `snapshot_lease_id`, sized by +/// that lease's own [`ReadConcurrency::for_lease`] — never more, and +/// never spilling into the next lease's candidates even if this lease's +/// own concurrency would allow a larger batch. See the module doc's +/// "Concurrent reads, sequential emission" section for why staying +/// within one lease per batch matters. +/// +/// Candidates already arrive grouped contiguously by lease (`run_job`'s +/// enumeration loop appends one root/drive at a time), so a single +/// linear scan suffices — no need to look ahead past the current run. +fn batches_by_lease<'entries>( + candidates: &'entries [(&'entries CandidateEntry, u64)], + read_concurrency: &ReadConcurrency, +) -> Vec<&'entries [(&'entries CandidateEntry, u64)]> { + let mut batches = Vec::new(); + let mut start = 0_usize; + while start < candidates.len() { + let Some((first_entry, _)) = candidates.get(start) else { + break; + }; + let lease_id = first_entry.snapshot_lease_id; + let max_batch = read_concurrency.for_lease(lease_id); + let cap = (start + max_batch).min(candidates.len()); + let Some(window) = candidates.get(start..cap) else { + break; + }; + let end = window + .iter() + .position(|(entry, _)| entry.snapshot_lease_id != lease_id) + .map_or(cap, |offset| start + offset); + let Some(batch) = candidates.get(start..end) else { + break; + }; + batches.push(batch); + start = end; + } + batches +} + /// Everything one completed job produced, aside from the frames /// themselves (see the module doc for why those are emitted through a /// callback instead of collected here). @@ -90,13 +204,16 @@ pub struct JobOutcome { /// `JOB_END`) is passed to `emit_frame` in emission order as soon as it /// exists — see the module doc comment. /// -/// `concurrency` is how many candidates are read concurrently per batch -/// (see the module doc's "Concurrent reads, sequential emission" -/// section) — clamped to at least `1`. Pass the number of drives a job -/// actually leased (or `1` for the fully-sequential, deterministic-order -/// behavior tests rely on) — this function has no way to know that -/// itself, since drive leasing happens in the caller -/// (`super::vss_job::run_vss_job`). +/// `read_concurrency` is how many candidates are read concurrently per +/// batch, per drive (see the module doc's "Concurrent reads, sequential +/// emission" section and [`ReadConcurrency`]'s own doc comment for why +/// this varies by drive rather than being one flat number). Pass +/// [`ReadConcurrency::flat`] for the fully-sequential, deterministic- +/// order behavior tests rely on, or a per-lease-tuned one built from +/// each leased drive's actual `uffs_mft::platform::DriveType` (see +/// `super::vss_job::run_vss_job`, the real caller) — this function has +/// no way to know drive types itself, since drive leasing happens in +/// the caller. /// /// # Errors /// Returns an [`io::Error`] for any filesystem failure enumerating @@ -110,13 +227,12 @@ pub fn run_job( candidate_source: &dyn CandidateSource, content_source: &dyn ContentSource, run_dir: &Path, - concurrency: usize, + read_concurrency: &ReadConcurrency, mut emit_frame: F, ) -> io::Result where F: FnMut(Vec) -> io::Result<()>, { - let batch_size = concurrency.max(1_usize); let job_id = *uuid::Uuid::new_v4().as_bytes(); let source_id = source_id_bytes(&request.source_id); // No query filtering is wired up yet (see `JobRequest` docs) — every @@ -126,7 +242,6 @@ where tracing::info!( job_id = %uuid::Uuid::from_bytes(job_id), root_count = request.roots.len(), - concurrency = batch_size, "job: starting candidate enumeration" ); let mut entries = Vec::new(); @@ -174,7 +289,7 @@ where .iter() .zip(built.candidate_ids.iter().copied()) .collect(); - for batch in candidates.chunks(batch_size) { + for batch in batches_by_lease(&candidates, read_concurrency) { let read_results = read_candidate_batch(batch, content_source, DEFAULT_MAX_CHUNK_BYTES); for ((entry, candidate_id), read_result) in batch.iter().copied().zip(read_results) { emit_candidate( diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index 216522c69..e7bfb4931 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -47,6 +47,10 @@ use anyhow as _; #[cfg(test)] use blake3 as _; +// Used by `uffs_content::job::reader_client`'s per-drive connection +// pool, not by this thin entry point directly. +#[cfg(windows)] +use crossbeam_channel as _; // Used by `uffs_content::run` (failure log + summary serialization), not // by this thin entry point directly. use serde as _; diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index ac13bff77..10559d2a4 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -37,6 +37,8 @@ mod support; // per-target rationale (each test binary is its own compilation unit). #[cfg(windows)] use anyhow as _; +#[cfg(windows)] +use crossbeam_channel as _; use serde as _; use serde_json as _; #[cfg(windows)] @@ -64,7 +66,7 @@ mod tests { use uffs_content::job::candidate_source::DirWalkCandidateSource; use uffs_content::job::content_source::FsContentSource; use uffs_content::job::intake::JobRequest; - use uffs_content::job::workflow::{JobOutcome, run_job}; + use uffs_content::job::workflow::{JobOutcome, ReadConcurrency, run_job}; use crate::support; use crate::support::fixture_tree::FixtureFile; @@ -104,7 +106,7 @@ mod tests { // >1, and smaller than the fixture's own file count, so this // parity check also exercises multiple concurrent-read // batches (`read_candidate_batch`), not just one. - 3, + &ReadConcurrency::flat(3), |frame| { frames.push(frame); Ok(()) diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 5a29c99ee..8d7454845 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -43,6 +43,8 @@ #[cfg(windows)] use anyhow as _; use blake3 as _; +#[cfg(windows)] +use crossbeam_channel as _; use serde as _; use serde_json as _; // Used only inside the `#[cfg(windows)] mod windows_tests` below — the From b55ca8d27b5317d39e87b1a4922da2edf8d20979 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:29:53 -0700 Subject: [PATCH 65/98] perf(content): sliding-window pipeline replaces fixed-batch content reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware benchmarking on a corpus mixing 14 multi-gigabyte files into tens of thousands of tiny ones (C:'s *.txt content) showed a stark throughput gap versus a size-uniform corpus (30.6 vs 112.0 MiB/s on the same NVMe drive, same 8-connection pool) that the earlier per-drive connection-pool fix couldn't explain. Root cause: run_job's batching grouped candidates into fixed-size batches (capped at that lease's concurrency) and used std::thread::scope to spawn-then-join a whole batch before starting the next one. If a large straggler landed in a batch, every other connection in that batch finished in milliseconds and then sat idle — the next batch (and every batch after it) couldn't even start until the straggler's thread joined. Replaced batches_by_lease/read_candidate_batch with a genuine sliding window (read_lease_run_pipelined, in the new job/workflow/pipeline.rs submodule): a feeder thread dispatches candidate indices through a bounded input channel (capacity = that lease's concurrency), N worker threads each claim-read-push in a loop, and a coordinator drains completions through a bounded output channel into a small reorder map, calling on_ready in strict candidate order as each becomes available. A worker that finishes early immediately claims the next candidate instead of waiting for batch-mates, so the other connections keep streaming subsequent files for the entire time one straggler is still in flight. The input/output channel bounds keep this self-throttling to a small constant multiple of concurrency regardless of run length, so memory stays bounded even when a giant file sits far behind the current emission cursor. lease_runs (renamed from batches_by_lease) no longer caps a run's size to the lease's concurrency — a run is now every contiguous same-snapshot_lease_id slice, with concurrency only bounding worker count, not how many candidates one run may contain. Moved this machinery into job/workflow/pipeline.rs (mirroring the reader.rs/reader/ split already used elsewhere in this codebase) to keep workflow.rs under the workspace's 800-line file-size policy after the addition. crossbeam-channel moved from uffs-content's windows-only dependency group to unconditional, since the pipeline is exercised by the cross-platform fake-reader test harness too, not just the real VSS path. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/Cargo.toml | 14 +- crates/uffs-content/src/job/tests.rs | 6 +- crates/uffs-content/src/job/workflow.rs | 331 +++++++----------- .../uffs-content/src/job/workflow/pipeline.rs | 271 ++++++++++++++ crates/uffs-content/src/main.rs | 6 +- .../tests/e2e_dir_walk_parity_fake_reader.rs | 8 +- .../tests/e2e_real_vss_content_reader.rs | 4 +- 7 files changed, 421 insertions(+), 219 deletions(-) create mode 100644 crates/uffs-content/src/job/workflow/pipeline.rs diff --git a/crates/uffs-content/Cargo.toml b/crates/uffs-content/Cargo.toml index 0b4af3e02..0d2051bf6 100644 --- a/crates/uffs-content/Cargo.toml +++ b/crates/uffs-content/Cargo.toml @@ -73,6 +73,15 @@ uuid.workspace = true # only installing a subscriber to consume them (`tracing-subscriber`, # Windows-only below) is Windows-specific. tracing.workspace = true +# `workflow::read_lease_run_pipelined`'s bounded sliding-window content +# reader: a per-lease-run pool of worker threads pulling candidate +# indices off a bounded input channel and returning results through a +# bounded output channel, so it stays unconditional the same way +# `workflow.rs` itself does (exercised by both the cross-platform fake +# harness and the real Windows VSS pipeline). Already a workspace +# dependency (`uffs-mft`/this crate's own `reader_client.rs` use it the +# same way). +crossbeam-channel.workspace = true # Windows-only deps: the real Snapshot Manager pipe client # (`src/job/snapshot_client.rs`) and the real VSS+MFT-query @@ -129,11 +138,6 @@ tokio = { workspace = true, features = ["net"] } # transport server (`src/serve/pipe_io.rs`) — the same primitives # `uffs-daemon`'s and `uffs-content-reader`'s named-pipe servers use. uffs-security.workspace = true -# Per-drive content-reader connection pool (`src/job/reader_client.rs`): -# a bounded channel of open pipe connections doubles as a checkout/ -# check-in pool with free blocking-wait-for-availability semantics. -# Already a workspace dependency (`uffs-mft` uses it the same way). -crossbeam-channel.workspace = true [build-dependencies] uffs-version = { workspace = true, features = ["build"] } diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 547ab1be5..7621b0ff5 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -148,9 +148,9 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { &DirWalkCandidateSource, &FsContentSource, run_dir.path(), - // >1 so this test also exercises the concurrent-read batching - // path (`read_candidate_batch`), not just the fully-sequential - // (`concurrency == 1`) case. + // >1 so this test also exercises the sliding-window concurrent- + // read path (`read_lease_run_pipelined`), not just the fully- + // sequential (`concurrency == 1`) case. &ReadConcurrency::flat(4), |frame| { frames.push(frame); diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 09d7283ea..049aaaa68 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -11,30 +11,57 @@ //! //! # Concurrent reads, sequential emission //! -//! Candidates are read in batches, each batch's size chosen per-drive by -//! [`ReadConcurrency`] (see [`run_job`]): each batch's candidates are -//! read on their own `std::thread::scope` thread — concurrently, routed -//! to that drive's own connection pool by `reader_client::ContentReader` -//! (see that module's doc comment) — but every batch's frames are still -//! *emitted* strictly in original candidate order, on the caller's own -//! thread, exactly matching the fully-sequential emission order this -//! function has always produced. `emit_frame`/`frame_sequence`/ -//! `counters`/`failure_log` are therefore still only ever touched from -//! one thread; no synchronization was added to any of them, and -//! downstream consumers of the frame stream (`crate::serve::stream:: -//! Grouped`) see exactly the same per-candidate-contiguous ordering as -//! the fully-sequential (every lease at concurrency `1`) case. +//! Candidates are read through a bounded pipeline, one per contiguous +//! `snapshot_lease_id` run (see `lease_runs`), sized by that lease's +//! own [`ReadConcurrency`] — routed to that drive's own connection pool +//! by `reader_client::ContentReader` (see that module's doc comment). +//! `read_lease_run_pipelined` is a genuine sliding window, not a +//! fixed-size batch that waits for its slowest member before starting +//! the next one: as soon as any of the `concurrency` worker threads +//! finishes a candidate, it immediately claims the next not-yet-started +//! one from the same run, regardless of whether earlier candidates are +//! still in flight. This matters in practice — a real-hardware run +//! mixing a handful of multi-gigabyte files into tens of thousands of +//! tiny ones showed the earlier fixed-batch design stalling *every* +//! connection in a batch for as long as its one large straggler took, +//! since the next batch could never start until the current one's +//! `std::thread::scope` join completed. The sliding window instead keeps +//! the other `concurrency - 1` connections working through subsequent +//! candidates for the whole time the straggler is still streaming. //! -//! A batch never spans two different `snapshot_lease_id`s — candidates +//! Despite the out-of-order reads, frames are still *emitted* strictly +//! in original candidate order, on the caller's own thread, exactly +//! matching the fully-sequential emission order this function has always +//! produced: `read_lease_run_pipelined`'s coordinator loop buffers an +//! out-of-order completion in a small reorder map and only calls the +//! caller's `on_ready` once every earlier candidate in the run has +//! already been handed back. `emit_frame`/`frame_sequence`/`counters`/ +//! `failure_log` are therefore still only ever touched from one thread; +//! no synchronization was added to any of them, and downstream consumers +//! of the frame stream (`crate::serve::stream::Grouped`) see exactly the +//! same per-candidate-contiguous ordering as the fully-sequential (every +//! lease at concurrency `1`) case. +//! +//! The reorder map's size — and therefore how far the sliding window can +//! run ahead of a straggler — is self-bounding to a small constant +//! multiple of `concurrency`, never the run's total length: candidates +//! are dispatched to workers through an input channel bounded to +//! `concurrency` slots, and completed results flow back through an +//! output channel of the same bound, so a worker that finishes early +//! blocks on its next claim (or its result send) once the pipeline is +//! full, rather than racing arbitrarily far ahead and buffering the rest +//! of the job's content in memory behind one slow file. +//! +//! A run never spans two different `snapshot_lease_id`s — candidates //! are collected one root/drive at a time (see `run_job`'s enumeration //! loop), so they already arrive grouped contiguously by drive, and -//! [`run_job`]'s batching stops at a lease boundary rather than -//! overshooting into the next drive's own concurrency setting. This is -//! what makes an HDD-backed lease's concurrency-`1` setting actually -//! mean "read every candidate strictly one at a time, in the order they -//! were enumerated" — the same order the MFT (and therefore, roughly, -//! on-disk position) produced them in — rather than being diluted by -//! whatever other drives happen to be in the same job. +//! `lease_runs` splits at each lease boundary rather than letting one +//! drive's run absorb another's candidates under the wrong concurrency +//! setting. This is what makes an HDD-backed lease's concurrency-`1` +//! setting actually mean "read every candidate strictly one at a time, +//! in the order they were enumerated" — the same order the MFT (and +//! therefore, roughly, on-disk position) produced them in — rather than +//! being diluted by whatever other drives happen to be in the same job. //! //! # Why `emit_frame` is a callback, not a returned `Vec` //! @@ -51,23 +78,26 @@ //! send-window size rather than the job size — see that module's own doc //! comment for the consumer side of this. +use std::collections::HashMap; use std::io; use std::path::Path; -use uffs_content_protocol::codec::{Digest, IncrementalDigest, digest}; +use uffs_content_protocol::codec::{Digest, digest}; use uffs_content_protocol::error::ErrorCode; use uffs_content_protocol::frame::{ - ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, - FileEnd, FileFailed, FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, - ReadMode, RetryClass, + ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, FileEnd, FileFailed, + FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, ReadMode, RetryClass, }; use uffs_content_protocol::manifest::AuthorizationMode; use uffs_content_protocol::path_encoding::WindowsPath; +use self::pipeline::{CandidateContent, lease_runs, read_lease_run_pipelined}; use super::candidate_source::{CandidateEntry, CandidateSource}; use super::content_source::ContentSource; use super::intake::JobRequest; use super::manifest_builder::build_manifest; + +mod pipeline; use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; /// One `CONTENT_CHUNK`'s maximum payload size for a job run. @@ -98,7 +128,7 @@ pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 64 * 1024; #[derive(Debug, Clone)] pub struct ReadConcurrency { /// Concurrency overrides, keyed by `snapshot_lease_id`. - per_lease: std::collections::HashMap, + per_lease: HashMap, /// Concurrency for any lease not present in `per_lease`. default: usize, } @@ -111,7 +141,7 @@ impl ReadConcurrency { #[must_use] pub fn flat(concurrency: usize) -> Self { Self { - per_lease: std::collections::HashMap::new(), + per_lease: HashMap::new(), default: concurrency.max(1), } } @@ -121,7 +151,7 @@ impl ReadConcurrency { #[must_use] pub fn new(default: usize) -> Self { Self { - per_lease: std::collections::HashMap::new(), + per_lease: HashMap::new(), default: default.max(1), } } @@ -140,46 +170,6 @@ impl ReadConcurrency { } } -/// Split `candidates` into batches for [`read_candidate_batch`]: each -/// batch holds only candidates from one `snapshot_lease_id`, sized by -/// that lease's own [`ReadConcurrency::for_lease`] — never more, and -/// never spilling into the next lease's candidates even if this lease's -/// own concurrency would allow a larger batch. See the module doc's -/// "Concurrent reads, sequential emission" section for why staying -/// within one lease per batch matters. -/// -/// Candidates already arrive grouped contiguously by lease (`run_job`'s -/// enumeration loop appends one root/drive at a time), so a single -/// linear scan suffices — no need to look ahead past the current run. -fn batches_by_lease<'entries>( - candidates: &'entries [(&'entries CandidateEntry, u64)], - read_concurrency: &ReadConcurrency, -) -> Vec<&'entries [(&'entries CandidateEntry, u64)]> { - let mut batches = Vec::new(); - let mut start = 0_usize; - while start < candidates.len() { - let Some((first_entry, _)) = candidates.get(start) else { - break; - }; - let lease_id = first_entry.snapshot_lease_id; - let max_batch = read_concurrency.for_lease(lease_id); - let cap = (start + max_batch).min(candidates.len()); - let Some(window) = candidates.get(start..cap) else { - break; - }; - let end = window - .iter() - .position(|(entry, _)| entry.snapshot_lease_id != lease_id) - .map_or(cap, |offset| start + offset); - let Some(batch) = candidates.get(start..end) else { - break; - }; - batches.push(batch); - start = end; - } - batches -} - /// Everything one completed job produced, aside from the frames /// themselves (see the module doc for why those are emitted through a /// callback instead of collected here). @@ -289,21 +279,16 @@ where .iter() .zip(built.candidate_ids.iter().copied()) .collect(); - for batch in batches_by_lease(&candidates, read_concurrency) { - let read_results = read_candidate_batch(batch, content_source, DEFAULT_MAX_CHUNK_BYTES); - for ((entry, candidate_id), read_result) in batch.iter().copied().zip(read_results) { - emit_candidate( - entry, - candidate_id, - read_result, - &mut counters, - &mut failure_log, - job_id, - &mut frame_sequence, - &mut emit_frame, - )?; - } - } + read_and_emit_all_candidates( + &candidates, + read_concurrency, + content_source, + job_id, + &mut counters, + &mut failure_log, + &mut frame_sequence, + &mut emit_frame, + )?; drop(failure_log); tracing::info!( @@ -337,6 +322,65 @@ where }) } +/// Read and emit every candidate's content, one [`read_lease_run_pipelined`] +/// call per contiguous [`lease_runs`] group. Extracted from [`run_job`] +/// itself so that function stays under the workspace's `too_many_lines` +/// budget — every parameter here is `run_job`'s own local state, threaded +/// through unchanged. +/// +/// # Errors +/// Propagates the first error from enumerating a lease run's content or +/// from `emit_frame` itself, exactly as `run_job`'s own doc comment +/// describes. +#[expect( + clippy::too_many_arguments, + reason = "the alternative is a bespoke context struct bundling counters/failure_log/ \ + frame_sequence/emit_frame purely to satisfy this lint, for a private helper \ + extracted from run_job with exactly one call site; not worth the indirection" +)] +fn read_and_emit_all_candidates( + candidates: &[(&CandidateEntry, u64)], + read_concurrency: &ReadConcurrency, + content_source: &dyn ContentSource, + job_id: [u8; 16], + counters: &mut RunCounters, + failure_log: &mut FailureLogWriter, + frame_sequence: &mut u64, + emit_frame: &mut F, +) -> io::Result<()> +where + F: FnMut(Vec) -> io::Result<()>, +{ + for run in lease_runs(candidates) { + let Some(&(first_entry, _)) = run.first() else { + continue; + }; + let concurrency = read_concurrency.for_lease(first_entry.snapshot_lease_id); + read_lease_run_pipelined( + run, + concurrency, + content_source, + DEFAULT_MAX_CHUNK_BYTES, + |index, read_result| { + let Some(&(entry, candidate_id)) = run.get(index) else { + return Ok(()); + }; + emit_candidate( + entry, + candidate_id, + read_result, + counters, + failure_log, + job_id, + frame_sequence, + emit_frame, + ) + }, + )?; + } + Ok(()) +} + /// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and /// advancing the next `frame_sequence`. fn encode_frame( @@ -409,127 +453,6 @@ fn emit_job_end( )) } -/// One candidate's content, fully read into memory by -/// [`read_candidate_batch`]/[`read_one_candidate`] and consumed by -/// [`emit_candidate`]. Bounded to exactly one candidate's content per -/// instance — never the whole batch or job — since the batch itself -/// bounds how many of these exist in memory at once (see the module -/// doc's "Concurrent reads, sequential emission" section). -struct CandidateContent { - /// Every `CONTENT_CHUNK` this candidate's content produced, in order. - chunks: Vec, - /// Sum of every chunk's payload length. - total_read: u64, - /// BLAKE3 digest over every chunk's payload, in order. - digest: Digest, - /// Set only if a read failed partway through; `None` means every - /// byte up to `entry.logical_size` was read successfully. - read_error: Option, -} - -/// Read every candidate in `batch` concurrently — one -/// [`std::thread::scope`] thread each — returning each candidate's -/// [`CandidateContent`] in the same order as `batch` itself. Bounds how -/// far ahead of frame emission reading can get to `batch.len()` -/// candidates' content, never the whole job's. -fn read_candidate_batch( - batch: &[(&CandidateEntry, u64)], - content_source: &dyn ContentSource, - max_chunk_bytes: u32, -) -> Vec { - std::thread::scope(|scope| { - // The intermediate `Vec` is semantically required, not needless: - // it forces every thread to be spawned before any is joined. - // Fusing this into one `.map(spawn).map(join)` chain would join - // each thread immediately after spawning it, one at a time — - // exactly the sequential behavior this function exists to avoid. - #[expect( - clippy::needless_collect, - reason = "see the comment above — collecting here is what makes every spawn \ - happen before any join, not an accident" - )] - let handles: Vec<_> = batch - .iter() - .map(|&(entry, candidate_id)| { - scope.spawn(move || { - read_one_candidate(entry, candidate_id, content_source, max_chunk_bytes) - }) - }) - .collect(); - handles - .into_iter() - .map(|handle| { - handle - .join() - .unwrap_or_else(|panic_payload| CandidateContent { - chunks: Vec::new(), - total_read: 0, - digest: IncrementalDigest::new().finalize(), - read_error: Some(io::Error::other(format!( - "content-read thread panicked: {panic_payload:?}" - ))), - }) - }) - .collect() - }) -} - -/// Read one candidate's content into memory, up to `entry.logical_size` -/// or the first read error. Never touches `emit_frame`/`frame_sequence`/ -/// `counters`/`failure_log` — those stay single-threaded, touched only -/// by [`emit_candidate`] afterward. -fn read_one_candidate( - entry: &CandidateEntry, - candidate_id: u64, - content_source: &dyn ContentSource, - max_chunk_bytes: u32, -) -> CandidateContent { - let mut hasher = IncrementalDigest::new(); - let mut offset = 0_u64; - let mut chunk_sequence = 0_u64; - let mut total_read = 0_u64; - let mut chunks = Vec::new(); - let mut read_error = None; - - while offset < entry.logical_size { - match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { - Ok(bytes) if bytes.is_empty() => break, - Ok(bytes) => { - let read_len = len_as_u64(bytes.len()); - hasher.update(&bytes); - total_read += read_len; - chunks.push(ContentChunk { - candidate_id, - chunk_sequence, - logical_offset: offset, - logical_length: read_len, - payload: bytes, - }); - offset += read_len; - chunk_sequence += 1; - } - Err(err) => { - tracing::warn!( - candidate_id, - path = %entry.relative_path.display(), - offset, - error = %err, - "content read failed" - ); - read_error = Some(err); - break; - } - } - } - - CandidateContent { - chunks, - total_read, - digest: hasher.finalize(), - read_error, - } -} - /// Emit one already-read candidate's `FILE_BEGIN`, its `CONTENT_CHUNK`s, /// and its terminal frame (`FILE_END`/`FILE_FAILED`), in that order, on /// the caller's own thread — see the module doc's "Concurrent reads, diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs new file mode 100644 index 000000000..18d81e04d --- /dev/null +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Bounded sliding-window content-read pipeline — the concurrent-read +//! machinery behind [`super::run_job`], split into its own file so +//! `workflow.rs` itself stays under the workspace's file-size policy. +//! See that module's doc comment ("Concurrent reads, sequential +//! emission") for the full design rationale; everything here is +//! mechanism, not policy. + +use std::collections::HashMap; +use std::io; + +use crossbeam_channel::{Receiver, Sender}; +use uffs_content_protocol::codec::{Digest, IncrementalDigest}; +use uffs_content_protocol::frame::ContentChunk; + +use crate::job::candidate_source::CandidateEntry; +use crate::job::content_source::ContentSource; + +/// Split `candidates` into contiguous same-`snapshot_lease_id` runs for +/// [`read_lease_run_pipelined`] — unlike a fixed batch size, a run is +/// never capped: its own concurrency (looked up once, from its first +/// candidate) instead bounds how many of its worker threads run at +/// once, not how many candidates it may contain. See the parent +/// module's "Concurrent reads, sequential emission" doc section for why +/// staying within one lease per run matters. +/// +/// Candidates already arrive grouped contiguously by lease (`run_job`'s +/// enumeration loop appends one root/drive at a time), so a single +/// linear scan suffices — no need to look ahead past the current run. +pub(super) fn lease_runs<'entries>( + candidates: &'entries [(&'entries CandidateEntry, u64)], +) -> Vec<&'entries [(&'entries CandidateEntry, u64)]> { + let mut runs = Vec::new(); + let mut start = 0_usize; + while start < candidates.len() { + let Some((first_entry, _)) = candidates.get(start) else { + break; + }; + let lease_id = first_entry.snapshot_lease_id; + let Some(tail) = candidates.get(start..) else { + break; + }; + let end = tail + .iter() + .position(|(entry, _)| entry.snapshot_lease_id != lease_id) + .map_or(candidates.len(), |offset| start + offset); + let Some(run) = candidates.get(start..end) else { + break; + }; + runs.push(run); + start = end; + } + runs +} + +/// One candidate's content, fully read into memory by +/// [`read_lease_run_pipelined`]/[`read_one_candidate`] and consumed by +/// the parent module's `emit_candidate`. Bounded to exactly one +/// candidate's content per instance — never a whole run or job — since +/// the pipeline's own bounded channels cap how many of these exist in +/// memory at once (see the parent module doc's "Concurrent reads, +/// sequential emission" section). +pub(super) struct CandidateContent { + /// Every `CONTENT_CHUNK` this candidate's content produced, in order. + pub(super) chunks: Vec, + /// Sum of every chunk's payload length. + pub(super) total_read: u64, + /// BLAKE3 digest over every chunk's payload, in order. + pub(super) digest: Digest, + /// Set only if a read failed partway through; `None` means every + /// byte up to `entry.logical_size` was read successfully. + pub(super) read_error: Option, +} + +/// Read every candidate in `run` through a bounded sliding-window +/// pipeline of `concurrency` worker threads, invoking `on_ready(index, +/// content)` — `index` into `run` — strictly in order as each +/// candidate's turn comes up, regardless of the order its read actually +/// completed in. See the parent module doc's "Concurrent reads, +/// sequential emission" section for the full rationale and the +/// memory-boundedness argument; in short: this is a genuine sliding +/// window (a worker immediately claims the next unclaimed candidate the +/// moment it finishes its current one), not a fixed-size batch that +/// waits for its slowest member before admitting more work. +/// +/// Three threads-of-control cooperate, all joined via `std::thread::scope` +/// before this function returns: +/// - one **feeder** thread sends candidate indices `0..run.len()`, in order, +/// into a bounded input channel (capacity `concurrency`) — its `send` blocks +/// once that many are unclaimed, which is what keeps a fast run of tiny +/// candidates from letting workers race arbitrarily far ahead of a slow one; +/// - `concurrency` **worker** threads each loop: claim the next index from the +/// input channel, read that candidate, then push `(index, content)` to a +/// bounded output channel (same capacity) — blocking there, not just on the +/// next input claim, if results are piling up faster than they can be +/// consumed; +/// - this function's own body (the **coordinator**, running on the caller's +/// thread inside the `scope` — not a spawned thread) drains the output +/// channel into a small reorder map and calls `on_ready` for `0, 1, 2, ...` +/// in turn as each becomes available. +/// +/// If `on_ready` itself returns an error (e.g. a downstream transport +/// failure), the coordinator stops calling it but keeps draining the +/// output channel to completion anyway — never stopping early and +/// leaving a worker or the feeder blocked on a channel nobody is +/// servicing anymore — and returns that first error once every +/// candidate has actually been read (wasting the now-moot remaining +/// reads in that rare case, in exchange for a pipeline that can never +/// deadlock on early termination). +/// +/// # Errors +/// Returns the first error `on_ready` produced, if any. +pub(super) fn read_lease_run_pipelined( + run: &[(&CandidateEntry, u64)], + concurrency: usize, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, + mut on_ready: impl FnMut(usize, CandidateContent) -> io::Result<()>, +) -> io::Result<()> { + if run.is_empty() { + return Ok(()); + } + let worker_count = concurrency.max(1).min(run.len()); + + let (input_tx, input_rx): (Sender, Receiver) = + crossbeam_channel::bounded(worker_count); + let (output_tx, output_rx): (Sender, Receiver) = + crossbeam_channel::bounded(worker_count); + + std::thread::scope(|scope| { + scope.spawn(move || { + for index in 0..run.len() { + if input_tx.send(index).is_err() { + break; + } + } + // Dropping input_tx here (end of scope) closes the channel + // once every index has been sent, so workers' `recv` loops + // end cleanly instead of blocking forever. + }); + + for _ in 0..worker_count { + let worker_input_rx = input_rx.clone(); + let worker_output_tx = output_tx.clone(); + scope.spawn(move || { + while let Ok(index) = worker_input_rx.recv() { + let Some(&(entry, candidate_id)) = run.get(index) else { + continue; + }; + let content = + read_one_candidate(entry, candidate_id, content_source, max_chunk_bytes); + if worker_output_tx.send((index, content)).is_err() { + break; + } + } + }); + } + // This scope's own sender handle; every worker holds its own + // clone, so the channel only truly closes once all of them + // finish. + drop(output_tx); + + drain_pipelined_output(run.len(), &output_rx, &mut on_ready) + }) +} + +/// One worker's completed read, tagged with its index into the +/// enclosing [`read_lease_run_pipelined`] call's `run` slice. +type IndexedContent = (usize, CandidateContent); + +/// The coordinator half of [`read_lease_run_pipelined`]: drain +/// `output_rx` into a reorder map and call `on_ready` for +/// `0..total_candidates` in turn as each becomes available. Extracted +/// so `read_lease_run_pipelined` itself stays under the workspace's +/// `too_many_lines` budget. +/// +/// # Errors +/// Returns the first error `on_ready` produced, after draining every +/// remaining result (see [`read_lease_run_pipelined`]'s doc comment for +/// why finishing the drain, rather than stopping early, is what keeps +/// this deadlock-free). +fn drain_pipelined_output( + total_candidates: usize, + output_rx: &Receiver, + on_ready: &mut dyn FnMut(usize, CandidateContent) -> io::Result<()>, +) -> io::Result<()> { + let mut next_expected = 0_usize; + let mut pending: HashMap = HashMap::new(); + let mut first_error: Option = None; + while next_expected < total_candidates { + if let Some(content) = pending.remove(&next_expected) { + if first_error.is_none() + && let Err(err) = on_ready(next_expected, content) + { + first_error = Some(err); + } + next_expected += 1; + continue; + } + match output_rx.recv() { + Ok((index, content)) => { + pending.insert(index, content); + } + // Every worker finished without ever producing + // `next_expected` — unreachable in practice (the feeder + // sends every index in `0..run.len()` and every worker + // processes whatever it claims), but fail safe rather than + // spin. + Err(_) => break, + } + } + first_error.map_or(Ok(()), Err) +} + +/// Read one candidate's content into memory, up to `entry.logical_size` +/// or the first read error. Never touches `emit_frame`/`frame_sequence`/ +/// `counters`/`failure_log` — those stay single-threaded, touched only +/// by the parent module's `emit_candidate` afterward. +fn read_one_candidate( + entry: &CandidateEntry, + candidate_id: u64, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, +) -> CandidateContent { + let mut hasher = IncrementalDigest::new(); + let mut offset = 0_u64; + let mut chunk_sequence = 0_u64; + let mut total_read = 0_u64; + let mut chunks = Vec::new(); + let mut read_error = None; + + while offset < entry.logical_size { + match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { + Ok(bytes) if bytes.is_empty() => break, + Ok(bytes) => { + let read_len = super::len_as_u64(bytes.len()); + hasher.update(&bytes); + total_read += read_len; + chunks.push(ContentChunk { + candidate_id, + chunk_sequence, + logical_offset: offset, + logical_length: read_len, + payload: bytes, + }); + offset += read_len; + chunk_sequence += 1; + } + Err(err) => { + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + offset, + error = %err, + "content read failed" + ); + read_error = Some(err); + break; + } + } + } + + CandidateContent { + chunks, + total_read, + digest: hasher.finalize(), + read_error, + } +} diff --git a/crates/uffs-content/src/main.rs b/crates/uffs-content/src/main.rs index e7bfb4931..b5a39296a 100644 --- a/crates/uffs-content/src/main.rs +++ b/crates/uffs-content/src/main.rs @@ -47,9 +47,9 @@ use anyhow as _; #[cfg(test)] use blake3 as _; -// Used by `uffs_content::job::reader_client`'s per-drive connection -// pool, not by this thin entry point directly. -#[cfg(windows)] +// Used by `uffs_content::job::workflow`'s pipelined content reader and +// `uffs_content::job::reader_client`'s per-drive connection pool, not +// by this thin entry point directly. use crossbeam_channel as _; // Used by `uffs_content::run` (failure log + summary serialization), not // by this thin entry point directly. diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index 10559d2a4..a760c6622 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -37,7 +37,8 @@ mod support; // per-target rationale (each test binary is its own compilation unit). #[cfg(windows)] use anyhow as _; -#[cfg(windows)] +// Used by `uffs_content::job::workflow`'s pipelined content reader, +// exercised by this cross-platform test via `run_job` itself. use crossbeam_channel as _; use serde as _; use serde_json as _; @@ -104,8 +105,9 @@ mod tests { &FsContentSource, run_dir.path(), // >1, and smaller than the fixture's own file count, so this - // parity check also exercises multiple concurrent-read - // batches (`read_candidate_batch`), not just one. + // parity check also exercises the sliding-window concurrent- + // read path (`read_lease_run_pipelined`) with more candidates + // than the window is wide, not just a single pass. &ReadConcurrency::flat(3), |frame| { frames.push(frame); diff --git a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs index 8d7454845..02de4ec92 100644 --- a/crates/uffs-content/tests/e2e_real_vss_content_reader.rs +++ b/crates/uffs-content/tests/e2e_real_vss_content_reader.rs @@ -43,7 +43,9 @@ #[cfg(windows)] use anyhow as _; use blake3 as _; -#[cfg(windows)] +// Used by `uffs_content::job::workflow`'s pipelined content reader, not +// by this thin test directly (its own real test body is windows-only, +// see below — but the dependency itself is unconditional). use crossbeam_channel as _; use serde as _; use serde_json as _; From be6f23b121a81ae543f0f7ea1508e60b89763121 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:49:39 -0700 Subject: [PATCH 66/98] feat(content): enforce consumer-requestable content-delivery ceiling Docenta's blocker #2: max_content_delivery_bytes was fully defined on the wire (JobBegin field, ReadMode::MetadataOnly, FileEnd.content_digest semantics) but never enforced anywhere in uffs-content, and there was no JobRequest field for a consumer to actually set it. Add JobRequest.max_content_delivery_bytes, enforce it in read_one_candidate (skip the read, report MetadataOnly with no digest for an over-ceiling candidate), and thread it through JobBegin/FileEnd. While threading this through the sliding-window pipeline, fix a panic-safety regression from the earlier batch-to-pipeline rewrite: worker threads are now fire-and-forget scope.spawn calls with no retained JoinHandle, so a panic would propagate and tear down an entire lease run instead of just failing one candidate. Wrap the real read in catch_unwind so a panic degrades to a single failed candidate again. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/intake.rs | 16 +++ crates/uffs-content/src/job/tests.rs | 62 +++++++++++- crates/uffs-content/src/job/workflow.rs | 22 ++++- .../uffs-content/src/job/workflow/pipeline.rs | 97 +++++++++++++++++-- 4 files changed, 183 insertions(+), 14 deletions(-) diff --git a/crates/uffs-content/src/job/intake.rs b/crates/uffs-content/src/job/intake.rs index ac106f135..76c0b3a69 100644 --- a/crates/uffs-content/src/job/intake.rs +++ b/crates/uffs-content/src/job/intake.rs @@ -71,4 +71,20 @@ pub struct JobRequest { /// Mirrors `SearchParams::attr`. #[serde(default)] pub attr: Option, + /// Content-delivery ceiling: a candidate whose `logical_size` exceeds + /// this is still enumerated in the manifest (so reap/tombstone + /// completeness holds — see + /// [`uffs_content_protocol::frame::ReadMode::MetadataOnly`]'s own doc + /// comment) but its body is not streamed. `None` means no ceiling — + /// every matched candidate's content is delivered regardless of size. + /// + /// Independent of `query`/`ext`/`min_size`/etc.: those decide which + /// files become candidates at all; this decides which already- + /// matched candidates are worth paying to stream, e.g. so a consumer + /// doesn't wait on a 100 GB file it has no intention of extracting + /// text from. Forwarded verbatim into + /// `JOB_BEGIN.max_content_delivery_bytes` + /// (see [`super::workflow::run_job`]). + #[serde(default)] + pub max_content_delivery_bytes: Option, } diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 7621b0ff5..f7f3a45a0 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -10,7 +10,7 @@ use std::fs; use uffs_content_protocol::codec::Reader; -use uffs_content_protocol::frame::{FrameEnvelope, FrameType}; +use uffs_content_protocol::frame::{FileEnd, FrameEnvelope, FrameType, ReadMode}; use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader, ManifestTrailer}; use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; @@ -186,3 +186,63 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { .count(); assert_eq!(file_end_count, 2, "both candidates must reach FILE_END"); } + +#[test] +fn candidates_over_the_delivery_ceiling_are_reported_metadata_only() { + let source_dir = tempfile::tempdir().expect("create source temp dir"); + fs::write(source_dir.path().join("small.txt"), b"tiny").expect("write small.txt"); + fs::write(source_dir.path().join("big.bin"), vec![0_u8; 64]).expect("write big.bin"); + + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let request = JobRequest { + source_id: "ceiling-source".to_owned(), + roots: vec![source_dir.path().to_path_buf()], + query: "*".to_owned(), + max_content_delivery_bytes: Some(10), + ..Default::default() + }; + + let mut frames = Vec::new(); + let outcome = run_job( + &request, + &DirWalkCandidateSource, + &FsContentSource, + run_dir.path(), + &ReadConcurrency::flat(2), + |frame| { + frames.push(frame); + Ok(()) + }, + ) + .expect("run_job must succeed"); + + assert_eq!(outcome.run_summary.candidate_count, 2); + assert_eq!(outcome.run_summary.succeeded_count, 2); + + let mut file_ends = Vec::new(); + for frame_bytes in &frames { + let mut reader = Reader::new(frame_bytes); + let (envelope, payload) = + FrameEnvelope::decode(&mut reader, u64::MAX).expect("decode frame envelope"); + if envelope.frame_type == FrameType::FileEnd { + let mut payload_reader = Reader::new(&payload); + file_ends.push(FileEnd::decode(&mut payload_reader).expect("decode file end")); + } + } + assert_eq!(file_ends.len(), 2); + + let big_end = file_ends + .iter() + .find(|end| end.total_logical_bytes == 0) + .expect("big file's FILE_END must report zero delivered bytes"); + assert_eq!(big_end.read_mode, ReadMode::MetadataOnly); + assert!(big_end.content_digest.is_none()); + assert_eq!(big_end.chunk_count, 0); + + let small_end = file_ends + .iter() + .find(|end| end.total_logical_bytes == 4) + .expect("small file's FILE_END must report its actual byte count"); + assert_eq!(small_end.read_mode, ReadMode::LogicalSnapshot); + assert!(small_end.content_digest.is_some()); +} diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 049aaaa68..0332c145c 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -261,7 +261,7 @@ where content_semantics: ContentSemantics::UnnamedLogicalStream, digest_algorithm: DigestAlgorithm::Blake3, max_chunk_bytes: DEFAULT_MAX_CHUNK_BYTES, - max_content_delivery_bytes: None, + max_content_delivery_bytes: request.max_content_delivery_bytes, }; emit_frame(encode_frame( job_id, @@ -283,6 +283,7 @@ where &candidates, read_concurrency, content_source, + request.max_content_delivery_bytes, job_id, &mut counters, &mut failure_log, @@ -342,6 +343,7 @@ fn read_and_emit_all_candidates( candidates: &[(&CandidateEntry, u64)], read_concurrency: &ReadConcurrency, content_source: &dyn ContentSource, + max_content_delivery_bytes: Option, job_id: [u8; 16], counters: &mut RunCounters, failure_log: &mut FailureLogWriter, @@ -361,6 +363,7 @@ where concurrency, content_source, DEFAULT_MAX_CHUNK_BYTES, + max_content_delivery_bytes, |index, read_result| { let Some(&(entry, candidate_id)) = run.get(index) else { return Ok(()); @@ -483,7 +486,7 @@ fn emit_candidate( path, logical_size: entry.logical_size, mtime: entry.mtime_unix_ms, - read_mode: ReadMode::LogicalSnapshot, + read_mode: content.read_mode, attempt_number: 1, content_object_id: None, }; @@ -504,13 +507,24 @@ fn emit_candidate( ))?; } + let read_mode = content.read_mode; match content.read_error { None => { + // A metadata-only candidate never had real bytes read (see + // `pipeline::read_one_candidate`'s doc comment), so its + // digest is meaningless — report `None`, matching the wire + // contract `ReadMode::MetadataOnly`'s own doc comment + // documents (design-doc's two-tier delivery-ceiling model). + let content_digest = if read_mode == ReadMode::MetadataOnly { + None + } else { + Some(content.digest) + }; let file_end = FileEnd { candidate_id, total_logical_bytes: content.total_read, - content_digest: Some(content.digest), - read_mode: ReadMode::LogicalSnapshot, + content_digest, + read_mode, chunk_count, elapsed_ms: 0, warning_flags: 0, diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs index 18d81e04d..8c1c3e1ef 100644 --- a/crates/uffs-content/src/job/workflow/pipeline.rs +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -13,7 +13,7 @@ use std::io; use crossbeam_channel::{Receiver, Sender}; use uffs_content_protocol::codec::{Digest, IncrementalDigest}; -use uffs_content_protocol::frame::ContentChunk; +use uffs_content_protocol::frame::{ContentChunk, ReadMode}; use crate::job::candidate_source::CandidateEntry; use crate::job::content_source::ContentSource; @@ -64,14 +64,25 @@ pub(super) fn lease_runs<'entries>( /// sequential emission" section). pub(super) struct CandidateContent { /// Every `CONTENT_CHUNK` this candidate's content produced, in order. + /// Empty when `read_mode == MetadataOnly`. pub(super) chunks: Vec, - /// Sum of every chunk's payload length. + /// Sum of every chunk's payload length. `0` when `read_mode == + /// MetadataOnly`. pub(super) total_read: u64, - /// BLAKE3 digest over every chunk's payload, in order. + /// BLAKE3 digest over every chunk's payload, in order. Only + /// meaningful when `read_mode != MetadataOnly` — the parent module's + /// `emit_candidate` reports `content_digest: None` on `FILE_END` for + /// a metadata-only candidate regardless of this value. pub(super) digest: Digest, /// Set only if a read failed partway through; `None` means every - /// byte up to `entry.logical_size` was read successfully. + /// byte up to `entry.logical_size` was read successfully (or the + /// read was skipped entirely because `read_mode == MetadataOnly`). pub(super) read_error: Option, + /// Whether this candidate's body was actually streamed + /// (`LogicalSnapshot`) or skipped because `entry.logical_size` + /// exceeded the job's `max_content_delivery_bytes` ceiling + /// (`MetadataOnly`) — see [`read_one_candidate`]'s doc comment. + pub(super) read_mode: ReadMode, } /// Read every candidate in `run` through a bounded sliding-window @@ -117,6 +128,7 @@ pub(super) fn read_lease_run_pipelined( concurrency: usize, content_source: &dyn ContentSource, max_chunk_bytes: u32, + max_content_delivery_bytes: Option, mut on_ready: impl FnMut(usize, CandidateContent) -> io::Result<()>, ) -> io::Result<()> { if run.is_empty() { @@ -149,8 +161,13 @@ pub(super) fn read_lease_run_pipelined( let Some(&(entry, candidate_id)) = run.get(index) else { continue; }; - let content = - read_one_candidate(entry, candidate_id, content_source, max_chunk_bytes); + let content = read_one_candidate_catch_panic( + entry, + candidate_id, + content_source, + max_chunk_bytes, + max_content_delivery_bytes, + ); if worker_output_tx.send((index, content)).is_err() { break; } @@ -214,16 +231,77 @@ fn drain_pipelined_output( first_error.map_or(Ok(()), Err) } +/// [`read_one_candidate`], guarded against a panic partway through: +/// `content_source` is a `&dyn ContentSource` trait object this crate +/// doesn't control every implementation of (see that trait's own doc +/// comment), so a third-party impl panicking must not take down this +/// candidate's entire worker thread — and, transitively, every other +/// candidate's read still in flight on this same `thread::scope` (a +/// spawned thread's panic propagates when the scope joins it). A caught +/// panic is reported the same way an `io::Error` from a normal read +/// failure is: as a retryable `FILE_FAILED`, via `read_error`. +/// +/// Mirrors the panic-to-`read_error` conversion the earlier fixed-batch +/// design got for free from `JoinHandle::join()` returning `Err` on a +/// panicked thread — this pipeline's workers are fire-and-forget +/// (`scope.spawn` without a retained handle), so that safety net has to +/// be reintroduced explicitly here instead. +fn read_one_candidate_catch_panic( + entry: &CandidateEntry, + candidate_id: u64, + content_source: &dyn ContentSource, + max_chunk_bytes: u32, + max_content_delivery_bytes: Option, +) -> CandidateContent { + let outcome = std::panic::catch_unwind(core::panic::AssertUnwindSafe(|| { + read_one_candidate( + entry, + candidate_id, + content_source, + max_chunk_bytes, + max_content_delivery_bytes, + ) + })); + outcome.unwrap_or_else(|panic_payload| CandidateContent { + chunks: Vec::new(), + total_read: 0, + digest: IncrementalDigest::new().finalize(), + read_error: Some(io::Error::other(format!( + "content-read thread panicked: {panic_payload:?}" + ))), + read_mode: ReadMode::LogicalSnapshot, + }) +} + /// Read one candidate's content into memory, up to `entry.logical_size` -/// or the first read error. Never touches `emit_frame`/`frame_sequence`/ -/// `counters`/`failure_log` — those stay single-threaded, touched only -/// by the parent module's `emit_candidate` afterward. +/// or the first read error — or, if `entry.logical_size` exceeds +/// `max_content_delivery_bytes`, skip the read entirely and report +/// `read_mode: MetadataOnly` with no bytes read at all. This is the only +/// place the delivery ceiling is enforced; the query filters +/// (`ext`/`min_size`/etc.) that decide which files become candidates at +/// all are evaluated earlier, by the daemon — this is a second, +/// independent gate on an already-matched candidate's body. +/// +/// Never touches `emit_frame`/`frame_sequence`/`counters`/`failure_log` +/// — those stay single-threaded, touched only by the parent module's +/// `emit_candidate` afterward. fn read_one_candidate( entry: &CandidateEntry, candidate_id: u64, content_source: &dyn ContentSource, max_chunk_bytes: u32, + max_content_delivery_bytes: Option, ) -> CandidateContent { + if max_content_delivery_bytes.is_some_and(|ceiling| entry.logical_size > ceiling) { + return CandidateContent { + chunks: Vec::new(), + total_read: 0, + digest: IncrementalDigest::new().finalize(), + read_error: None, + read_mode: ReadMode::MetadataOnly, + }; + } + let mut hasher = IncrementalDigest::new(); let mut offset = 0_u64; let mut chunk_sequence = 0_u64; @@ -267,5 +345,6 @@ fn read_one_candidate( total_read, digest: hasher.finalize(), read_error, + read_mode: ReadMode::LogicalSnapshot, } } From 2d97a60301848b46e5851b0eefb05cd5122437c5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:54:43 -0700 Subject: [PATCH 67/98] feat(content): populate JOB_BEGIN snapshot provenance from the real VSS lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docenta's blocker #3: JOB_BEGIN.snapshot_id/snapshot_created_at were always emitted empty/zero even though the Broker's lease response already carries both. LeasedDrive discarded them; now it keeps them, and run_vss_job forwards the first leased drive's values into run_job. The wire protocol has only one job-level snapshot_id/snapshot_created_at pair, so a multi-drive job's provenance is necessarily representative rather than per-drive — documented at the call site. run_job itself gains two new parameters (snapshot_id, snapshot_created_at); the fake/ test callers pass empty/zero, unchanged from today's behavior. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/tests.rs | 4 ++++ crates/uffs-content/src/job/vss_job.rs | 17 +++++++++++++++++ crates/uffs-content/src/job/vss_orchestrator.rs | 11 +++++++++++ crates/uffs-content/src/job/workflow.rs | 13 +++++++++++-- .../tests/e2e_dir_walk_parity_fake_reader.rs | 2 ++ 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index f7f3a45a0..bdbfdebe3 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -152,6 +152,8 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { // read path (`read_lease_run_pipelined`), not just the fully- // sequential (`concurrency == 1`) case. &ReadConcurrency::flat(4), + &[], + 0, |frame| { frames.push(frame); Ok(()) @@ -209,6 +211,8 @@ fn candidates_over_the_delivery_ceiling_are_reported_metadata_only() { &FsContentSource, run_dir.path(), &ReadConcurrency::flat(2), + &[], + 0, |frame| { frames.push(frame); Ok(()) diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index 8bc6e7de5..832740925 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -97,12 +97,29 @@ where .context("failed to spawn the content reader")?; let content_source = VssContentSource::new(content_reader); + // JOB_BEGIN carries only one job-level snapshot_id/snapshot_created_at + // pair (see `JobBegin`'s own doc comment), so a multi-drive job's + // provenance is necessarily a representative one, not one per drive. + // The first leased drive is as good a choice as any: every lease for + // a job is taken back-to-back at job start (see + // `vss_orchestrator::prepare_ephemeral_daemon_for_roots`), so their + // snapshot_created_at values differ by at most the lease loop's own + // wall-clock time, not something a consumer's temporal-memory use case + // would notice. + let (snapshot_id, snapshot_created_at) = resources + .leases + .first() + .map(|lease| (lease.snapshot_id.clone(), lease.snapshot_created_at_unix_ms)) + .unwrap_or_default(); + let result = run_job( &resolved_request, &candidate_source, &content_source, run_dir, &read_concurrency, + &snapshot_id, + snapshot_created_at, emit_frame, ) .context("run_job failed"); diff --git a/crates/uffs-content/src/job/vss_orchestrator.rs b/crates/uffs-content/src/job/vss_orchestrator.rs index 2dab2b7dd..ca8e70587 100644 --- a/crates/uffs-content/src/job/vss_orchestrator.rs +++ b/crates/uffs-content/src/job/vss_orchestrator.rs @@ -50,6 +50,15 @@ pub(crate) struct LeasedDrive { pub(crate) drive_letter: char, /// This drive's lease id. pub(crate) lease_id: u64, + /// Opaque VSS snapshot identifier, as reported by the Broker at + /// lease time. Carried through to `JOB_BEGIN.snapshot_id` (see + /// `super::vss_job::run_vss_job`) — one drive's worth of real + /// snapshot provenance, since the wire protocol has only one + /// job-level `snapshot_id`/`snapshot_created_at` pair even though a + /// job may lease several drives. + pub(crate) snapshot_id: Vec, + /// This drive's snapshot creation time, Unix milliseconds. + pub(crate) snapshot_created_at_unix_ms: i64, } /// Every live resource this orchestration step produced. @@ -206,6 +215,8 @@ fn lease_one_drive(job_id: [u8; 16], letter: char) -> Result device_path: lease.snapshot_device_identity, drive_letter: letter, lease_id: lease.snapshot_lease_id, + snapshot_id: lease.snapshot_id, + snapshot_created_at_unix_ms: lease.snapshot_created_at_unix_ms, })) } diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 0332c145c..3d12c1fee 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -212,12 +212,21 @@ pub struct JobOutcome { /// failure). A per-candidate content-read failure is *not* an error /// return — it's recorded as a `FAILED_RETRYABLE` outcome for that /// candidate instead (a [`FileFailed`] frame plus a [`FailureRecord`]). +#[expect( + clippy::too_many_arguments, + reason = "snapshot_id/snapshot_created_at are real VSS provenance the caller (run_vss_job) \ + already holds from its lease response; the fake/test callers pass empty/zero. \ + Bundling them into a struct purely to satisfy this lint would add indirection \ + for two fields that always travel together and change meaning together." +)] pub fn run_job( request: &JobRequest, candidate_source: &dyn CandidateSource, content_source: &dyn ContentSource, run_dir: &Path, read_concurrency: &ReadConcurrency, + snapshot_id: &[u8], + snapshot_created_at: i64, mut emit_frame: F, ) -> io::Result where @@ -252,8 +261,8 @@ where let job_begin = JobBegin { job_id, source_id, - snapshot_id: Vec::new(), - snapshot_created_at: 0, + snapshot_id: snapshot_id.to_vec(), + snapshot_created_at, manifest_digest: built.manifest_digest, candidate_count, authorization_mode: AuthorizationMode::AdminExport, diff --git a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs index a760c6622..86d7e7549 100644 --- a/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs +++ b/crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs @@ -109,6 +109,8 @@ mod tests { // read path (`read_lease_run_pipelined`) with more candidates // than the window is wide, not just a single pass. &ReadConcurrency::flat(3), + &[], + 0, |frame| { frames.push(frame); Ok(()) From 1b479e7aabf87a23a938db96aeb0016ad4b909b3 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:00:53 -0700 Subject: [PATCH 68/98] feat(content-protocol): reject a mismatched protocol_version explicitly Docenta's blocker #4: any wire-format break should fail loud, not get silently misparsed under a header shape it was never validated against. Add PROTOCOL_VERSION (currently 2, matching FRAME_MAGIC's "UFS2") and FrameError::ProtocolVersionMismatch. FrameEnvelope::decode now checks protocol_version immediately after reading it, before parsing any version-shape-dependent field, and every FrameEnvelope this crate constructs uses the named constant instead of a bare literal. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-protocol/src/frame/mod.rs | 27 ++++++++++++++++++ .../uffs-content-protocol/src/frame/tests.rs | 28 +++++++++++++++++-- crates/uffs-content/src/job/workflow.rs | 5 ++-- crates/uffs-content/src/serve/pipe_io.rs | 4 +-- crates/uffs-content/src/serve/stream.rs | 6 ++-- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/uffs-content-protocol/src/frame/mod.rs b/crates/uffs-content-protocol/src/frame/mod.rs index ba1697b2f..e01c03315 100644 --- a/crates/uffs-content-protocol/src/frame/mod.rs +++ b/crates/uffs-content-protocol/src/frame/mod.rs @@ -41,6 +41,16 @@ pub use job_end::JobEnd; /// Frame envelope magic (design-doc §12.1). pub const FRAME_MAGIC: [u8; 4] = *b"UFS2"; +/// Wire format version this build produces and requires on decode. +/// +/// Every [`FrameEnvelope`] encoded by this crate sets `protocol_version` +/// to this value, and [`FrameEnvelope::decode`] rejects any other value +/// explicitly (see [`FrameError::ProtocolVersionMismatch`]) rather than +/// attempting to parse a header shape it was never validated against — +/// a future wire-breaking change should bump this constant, not +/// silently reinterpret old bytes under a new layout. +pub const PROTOCOL_VERSION: u16 = 2; + /// Bytes of the fixed envelope header preceding `header_checksum`: /// magic(4) + `protocol_version`(2) + `frame_type`(2) + flags(4) + /// `header_length`(4) + `payload_length`(8) + `job_id`(16) + @@ -60,6 +70,17 @@ pub enum FrameError { /// Envelope magic did not match [`FRAME_MAGIC`]. #[error("bad frame magic: {0:?}")] BadMagic([u8; 4]), + /// `protocol_version` did not match [`PROTOCOL_VERSION`] — a peer + /// speaking a different wire format, not a corrupt frame. Rejected + /// before any version-shape-dependent field is parsed, so a future + /// breaking wire change fails loud instead of misparsing. + #[error("protocol_version mismatch: expected {expected}, got {actual}")] + ProtocolVersionMismatch { + /// This build's [`PROTOCOL_VERSION`]. + expected: u16, + /// The version the peer actually sent. + actual: u16, + }, /// The declared `header_length` did not match bytes actually consumed. #[error("header_length mismatch: declared {declared}, actual {actual}")] HeaderLengthMismatch { @@ -258,6 +279,12 @@ impl FrameEnvelope { return Err(FrameError::BadMagic(magic)); } let protocol_version = reader.read_u16_le()?; + if protocol_version != PROTOCOL_VERSION { + return Err(FrameError::ProtocolVersionMismatch { + expected: PROTOCOL_VERSION, + actual: protocol_version, + }); + } let frame_type_raw = reader.read_u16_le()?; let frame_type = FrameType::decode(frame_type_raw).map_err(FrameError::UnknownFrameType)?; let flags = reader.read_u32_le()?; diff --git a/crates/uffs-content-protocol/src/frame/tests.rs b/crates/uffs-content-protocol/src/frame/tests.rs index 8e3cf022b..d5ce0b2e5 100644 --- a/crates/uffs-content-protocol/src/frame/tests.rs +++ b/crates/uffs-content-protocol/src/frame/tests.rs @@ -7,7 +7,7 @@ use super::{ ConsumerAckStatus, ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileAck, FileBegin, FileDeferred, FileEnd, FileFailed, FrameEnvelope, FrameError, FrameOrdering, FrameType, Heartbeat, JobBegin, JobCancel, JobEnd, JobResume, JobStatus, - JobSubmit, Progress, ReadMode, RetryClass, WindowUpdate, + JobSubmit, PROTOCOL_VERSION, Progress, ReadMode, RetryClass, WindowUpdate, }; use crate::codec::Reader; use crate::error::ErrorCode; @@ -16,7 +16,7 @@ use crate::path_encoding::WindowsPath; fn sample_envelope(frame_type: FrameType, frame_sequence: u64) -> FrameEnvelope { FrameEnvelope { - protocol_version: 2, + protocol_version: PROTOCOL_VERSION, frame_type, flags: 0, job_id: [3_u8; 16], @@ -133,6 +133,30 @@ fn envelope_rejects_unknown_frame_type() { assert!(matches!(err, FrameError::UnknownFrameType(999))); } +#[test] +#[expect( + clippy::indexing_slicing, + reason = "test mutation of a known, already-validated buffer range; \ + clippy::get_unwrap is also denied, so a scoped exception on \ + direct indexing is the established pattern for this \ + conflict (see crates/uffs-daemon/tests/ipc_integration.rs)" +)] +fn envelope_rejects_mismatched_protocol_version() { + // Patch the protocol_version field bytes directly (offset 4-5: + // magic(4), before frame_type at offset 6-7) rather than constructing + // an envelope with the "wrong" version, since `FrameEnvelope` only + // has one field for it and this crate defines what "right" means. + let envelope = sample_envelope(FrameType::Heartbeat, 1); + let mut bytes = envelope.encode(&[]); + bytes[4..6].copy_from_slice(&99_u16.to_le_bytes()); + let mut reader = Reader::new(&bytes); + let err = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap_err(); + assert!(matches!(err, FrameError::ProtocolVersionMismatch { + expected: PROTOCOL_VERSION, + actual: 99 + })); +} + #[test] fn frame_type_round_trips_all_variants() { for value in 1_u16..=14 { diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 3d12c1fee..5bd3025c5 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -86,7 +86,8 @@ use uffs_content_protocol::codec::{Digest, digest}; use uffs_content_protocol::error::ErrorCode; use uffs_content_protocol::frame::{ ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, FileEnd, FileFailed, - FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, ReadMode, RetryClass, + FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, PROTOCOL_VERSION, + ReadMode, RetryClass, }; use uffs_content_protocol::manifest::AuthorizationMode; use uffs_content_protocol::path_encoding::WindowsPath; @@ -402,7 +403,7 @@ fn encode_frame( payload: &[u8], ) -> Vec { let envelope = FrameEnvelope { - protocol_version: 2, + protocol_version: PROTOCOL_VERSION, frame_type, flags: 0, job_id, diff --git a/crates/uffs-content/src/serve/pipe_io.rs b/crates/uffs-content/src/serve/pipe_io.rs index 291cba970..5a7f1e072 100644 --- a/crates/uffs-content/src/serve/pipe_io.rs +++ b/crates/uffs-content/src/serve/pipe_io.rs @@ -183,7 +183,7 @@ pub(super) fn hex_job_id(job_id: [u8; 16]) -> String { #[cfg(test)] mod tests { use uffs_content_protocol::frame::{ - ContentChunk, FileBegin, FrameEnvelope, FrameType, ReadMode, + ContentChunk, FileBegin, FrameEnvelope, FrameType, PROTOCOL_VERSION, ReadMode, }; use uffs_content_protocol::manifest::MAX_PATH_BYTES; use uffs_content_protocol::path_encoding::WindowsPath; @@ -193,7 +193,7 @@ mod tests { fn encoded_len(frame_type: FrameType, payload: &[u8]) -> u32 { let bytes = FrameEnvelope { - protocol_version: 2, + protocol_version: PROTOCOL_VERSION, frame_type, flags: 0, job_id: [0; 16], diff --git a/crates/uffs-content/src/serve/stream.rs b/crates/uffs-content/src/serve/stream.rs index 733cb2b16..dd70a2f34 100644 --- a/crates/uffs-content/src/serve/stream.rs +++ b/crates/uffs-content/src/serve/stream.rs @@ -623,7 +623,9 @@ fn set_active(state: &Arc, active: Option) { #[cfg(test)] mod tests { - use uffs_content_protocol::frame::{FileBegin, FrameEnvelope, FrameType, JobBegin, ReadMode}; + use uffs_content_protocol::frame::{ + FileBegin, FrameEnvelope, FrameType, JobBegin, PROTOCOL_VERSION, ReadMode, + }; use uffs_content_protocol::manifest::AuthorizationMode; use uffs_content_protocol::path_encoding::WindowsPath; @@ -633,7 +635,7 @@ mod tests { fn encode(frame_sequence: u64, frame_type: FrameType, payload: &[u8]) -> Vec { FrameEnvelope { - protocol_version: 2, + protocol_version: PROTOCOL_VERSION, frame_type, flags: 0, job_id: JOB_ID, From 6700e662de5d16605c4353fcac1dc2c3e352930f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:06:57 -0700 Subject: [PATCH 69/98] test(content-protocol): add a full job-stream replay fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docenta's blocker #5: a recorded frame stream to validate their decoder against real producer bytes without a Windows/VSS host, since they develop cross-platform. The existing golden-fixture corpus only froze individual frame types in isolation. Add tests/fixtures/job_stream_two_candidates.bin: one representative job's full ordered byte stream (JOB_BEGIN, candidate 1's FILE_BEGIN/two CONTENT_CHUNKs/FILE_END, candidate 2's FILE_BEGIN/FILE_END, JOB_END), decodable with only uffs-content-protocol — no uffs-content or VSS involved. Candidate 2 exceeds the job's delivery ceiling and is reported ReadMode::MetadataOnly, so the fixture also exercises that wire shape in full sequence context, not just standalone. Generated via the crate's existing UFFS_REGENERATE_FIXTURES=1 discipline, same as every other fixture in this corpus. Co-Authored-By: Claude Sonnet 5 --- .../fixtures/job_stream_two_candidates.bin | Bin 0 -> 1111 bytes .../tests/golden_fixtures.rs | 289 +++++++++++++++++- 2 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 crates/uffs-content-protocol/tests/fixtures/job_stream_two_candidates.bin diff --git a/crates/uffs-content-protocol/tests/fixtures/job_stream_two_candidates.bin b/crates/uffs-content-protocol/tests/fixtures/job_stream_two_candidates.bin new file mode 100644 index 0000000000000000000000000000000000000000..825951ac8a3c74fba65ad6bfbc144bf441283d0b GIT binary patch literal 1111 zcmah|IY2}>?}kC!B(*mEd=KuBl00+VE^nKf8Lv!-AM0fdkJX3#K32nzX9YFk#5+! z&=NQP?1>W2T_{oy^PZ=BNpsGda#H%V6VttvYnijUVHj;NJoK@Pp!p(FLa#+4`gn|y4aqE(EOqODX^OTypY{=1DoVOszA!yy z*|yW5BDBg36+v(Scse|0sM5&~(eTqf@`z2La^ga8o3XD##=0Fdi}%eG8S{MJlR zVH%9~(4Tof^jBpoH#h3m?$eKVpG)WaV|&%Fx$l|P;6al{Lm;XQNZs~u0d%50c6NQU z-n?^6wWJO}3tkl4?g0}wXX18akbp^u<7wl5!`x0XfE9jCeyKj*i8=;WkBK^Gn&Y+c zmJihhn~8|X#`Ax7e@;=|&uup3Z5dIkS4e+Y@3>kxI$x&hq-D6EqGi;A@>}Y0GhwG) N%PaC*=AViP`~q literal 0 HcmV?d00001 diff --git a/crates/uffs-content-protocol/tests/golden_fixtures.rs b/crates/uffs-content-protocol/tests/golden_fixtures.rs index e3590578c..9ee9811cf 100644 --- a/crates/uffs-content-protocol/tests/golden_fixtures.rs +++ b/crates/uffs-content-protocol/tests/golden_fixtures.rs @@ -42,9 +42,9 @@ mod tests { use uffs_content_protocol::codec::Reader; use uffs_content_protocol::error::ErrorCode; use uffs_content_protocol::frame::{ - ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileEnd, FileFailed, - FrameEnvelope, FrameError, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, ReadMode, - RetryClass, + ContentChunk, ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, + FileEnd, FileFailed, FrameEnvelope, FrameError, FrameOrdering, FrameType, JobBegin, JobEnd, + JobStatus, ReadMode, RetryClass, }; use uffs_content_protocol::manifest::{ AuthorizationMode, CandidateFlags, CandidateRecord, ManifestHeader, ManifestTrailer, @@ -398,6 +398,289 @@ mod tests { ); } + // ───────────────────────── job stream (full sequence) + // ───────────────────────── + // + // A recorded frame stream for one representative job, end to end: + // JOB_BEGIN, then FILE_BEGIN/[CONTENT_CHUNK]*/FILE_END per candidate, + // then JOB_END — no framing beyond each frame's own envelope, exactly + // as a consumer sees it over the wire. This is the "replay fixture" + // a consumer without a Windows/VSS host (e.g. Docenta) can decode + // against to validate their own decoder end to end, not just against + // one frame type in isolation. Candidate 1 is an ordinary two-chunk + // success; candidate 2 exceeds `JOB_BEGIN.max_content_delivery_bytes` + // and is reported `ReadMode::MetadataOnly`, so this stream also + // covers that still-recent wire shape in full sequence context. + + const JOB_STREAM_JOB_ID: [u8; 16] = [0xAA_u8; 16]; + + const fn job_stream_content() -> &'static [u8] { + b"hello world" + } + + fn sample_job_stream_job_begin() -> JobBegin { + JobBegin { + job_id: JOB_STREAM_JOB_ID, + source_id: [0xBB_u8; 16], + snapshot_id: b"vss-snapshot-job-stream-0001".to_vec(), + snapshot_created_at: 1_752_100_000_000, + manifest_digest: [0xCC_u8; 32], + candidate_count: 2, + authorization_mode: AuthorizationMode::AdminExport, + ordering: FrameOrdering::None, + content_semantics: ContentSemantics::UnnamedLogicalStream, + digest_algorithm: DigestAlgorithm::Blake3, + max_chunk_bytes: 1_048_576, + max_content_delivery_bytes: Some(1024), + } + } + + fn sample_job_stream_file_begin_1() -> FileBegin { + FileBegin { + candidate_id: 1, + file_reference: 0x1000_0000_0000_0001, + path: WindowsPath::from_str_lossless(r"C:\Users\robert\Documents\report.docx"), + logical_size: 11, + mtime: 1_752_100_000_000, + read_mode: ReadMode::LogicalSnapshot, + attempt_number: 1, + content_object_id: None, + } + } + + fn sample_job_stream_chunk_1a() -> ContentChunk { + ContentChunk { + candidate_id: 1, + chunk_sequence: 0, + logical_offset: 0, + logical_length: 6, + payload: b"hello ".to_vec(), + } + } + + fn sample_job_stream_chunk_1b() -> ContentChunk { + ContentChunk { + candidate_id: 1, + chunk_sequence: 1, + logical_offset: 6, + logical_length: 5, + payload: b"world".to_vec(), + } + } + + fn sample_job_stream_file_end_1() -> FileEnd { + FileEnd { + candidate_id: 1, + total_logical_bytes: 11, + content_digest: Some(*blake3::hash(job_stream_content()).as_bytes()), + read_mode: ReadMode::LogicalSnapshot, + chunk_count: 2, + elapsed_ms: 3, + warning_flags: 0, + } + } + + fn sample_job_stream_file_begin_2() -> FileBegin { + FileBegin { + candidate_id: 2, + file_reference: 0x1000_0000_0000_0002, + path: WindowsPath::from_str_lossless(r"C:\Data\bigfile.bin"), + logical_size: 10_737_418_240, + mtime: 1_752_100_000_000, + read_mode: ReadMode::MetadataOnly, + attempt_number: 1, + content_object_id: None, + } + } + + const fn sample_job_stream_file_end_2() -> FileEnd { + FileEnd { + candidate_id: 2, + total_logical_bytes: 0, + content_digest: None, + read_mode: ReadMode::MetadataOnly, + chunk_count: 0, + elapsed_ms: 0, + warning_flags: 0, + } + } + + fn sample_job_stream_job_end() -> JobEnd { + JobEnd { + candidate_count: 2, + succeeded_count: 2, + failed_retryable_count: 0, + failed_terminal_count: 0, + deferred_manual_count: 0, + acknowledged_success_count: 0, + logical_bytes_succeeded: 11, + failure_bucket_id: b"job-stream-0001-failures".to_vec(), + manifest_digest: [0xCC_u8; 32], + outcome_ledger_digest: [0xDD_u8; 32], + job_status: JobStatus::Completed, + } + } + + /// Wraps `payload` in a `FrameEnvelope` for the job-stream fixture, + /// assigning `frame_sequence` in emission order. + fn job_stream_envelope(frame_type: FrameType, frame_sequence: u64, payload: &[u8]) -> Vec { + FrameEnvelope { + protocol_version: 2, + frame_type, + flags: 0, + job_id: JOB_STREAM_JOB_ID, + frame_sequence, + } + .encode(payload) + } + + /// Builds the full, ordered byte stream: `JOB_BEGIN`, candidate 1's + /// `FILE_BEGIN`/two `CONTENT_CHUNK`s/`FILE_END`, candidate 2's + /// `FILE_BEGIN`/`FILE_END` (metadata-only), then `JOB_END`. + fn build_job_stream_bytes() -> Vec { + let mut out = Vec::new(); + out.extend(job_stream_envelope( + FrameType::JobBegin, + 0, + &sample_job_stream_job_begin().encode(), + )); + out.extend(job_stream_envelope( + FrameType::FileBegin, + 1, + &sample_job_stream_file_begin_1().encode(), + )); + out.extend(job_stream_envelope( + FrameType::ContentChunk, + 2, + &sample_job_stream_chunk_1a().encode(), + )); + out.extend(job_stream_envelope( + FrameType::ContentChunk, + 3, + &sample_job_stream_chunk_1b().encode(), + )); + out.extend(job_stream_envelope( + FrameType::FileEnd, + 4, + &sample_job_stream_file_end_1().encode(), + )); + out.extend(job_stream_envelope( + FrameType::FileBegin, + 5, + &sample_job_stream_file_begin_2().encode(), + )); + out.extend(job_stream_envelope( + FrameType::FileEnd, + 6, + &sample_job_stream_file_end_2().encode(), + )); + out.extend(job_stream_envelope( + FrameType::JobEnd, + 7, + &sample_job_stream_job_end() + .encode() + .unwrap_or_else(|err| panic!("sample_job_stream_job_end must encode: {err}")), + )); + out + } + + #[test] + #[ignore = "writes a golden fixture; run with UFFS_REGENERATE_FIXTURES=1 --ignored"] + fn regenerate_job_stream_fixture() { + regenerate_fixture("job_stream_two_candidates.bin", &build_job_stream_bytes()); + } + + #[test] + fn job_stream_fixture_decodes_to_expected_full_sequence() { + let bytes = load_fixture("job_stream_two_candidates.bin"); + let mut reader = Reader::new(&bytes); + + let mut decoded_types = Vec::new(); + let mut total_chunk_bytes = Vec::new(); + let mut file_ends = Vec::new(); + while reader.remaining() > 0 { + let (envelope, payload) = FrameEnvelope::decode(&mut reader, 1_000_000).unwrap(); + assert_eq!(envelope.job_id, JOB_STREAM_JOB_ID); + decoded_types.push(envelope.frame_type); + + let mut payload_reader = Reader::new(&payload); + match envelope.frame_type { + FrameType::JobBegin => { + assert_eq!( + JobBegin::decode(&mut payload_reader).unwrap(), + sample_job_stream_job_begin() + ); + } + FrameType::ContentChunk => { + let chunk = ContentChunk::decode(&mut payload_reader, 1_000_000).unwrap(); + if chunk.candidate_id == 1 { + total_chunk_bytes.extend(chunk.payload); + } + } + FrameType::FileEnd => { + file_ends.push(FileEnd::decode(&mut payload_reader).unwrap()); + } + FrameType::JobEnd => { + assert_eq!( + JobEnd::decode(&mut payload_reader).unwrap(), + sample_job_stream_job_end() + ); + } + FrameType::FileBegin => { + // Decoded via the per-candidate assertions below. + } + other @ (FrameType::FileFailed + | FrameType::FileDeferred + | FrameType::FileAck + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobCancel + | FrameType::WindowUpdate + | FrameType::JobResume + | FrameType::JobSubmit) => { + panic!("unexpected frame type in job stream: {other:?}") + } + } + } + + assert_eq!(decoded_types, vec![ + FrameType::JobBegin, + FrameType::FileBegin, + FrameType::ContentChunk, + FrameType::ContentChunk, + FrameType::FileEnd, + FrameType::FileBegin, + FrameType::FileEnd, + FrameType::JobEnd, + ]); + + // Candidate 1: chunks reassemble to the exact original content, + // and the digest FILE_END reports matches an independent BLAKE3 + // recomputation over those reassembled bytes. + assert_eq!(total_chunk_bytes, job_stream_content()); + assert_eq!(file_ends.len(), 2); + let candidate_1_end = file_ends + .iter() + .find(|end| end.candidate_id == 1) + .expect("candidate 1's FILE_END must be present"); + assert_eq!(candidate_1_end, &sample_job_stream_file_end_1()); + assert_eq!( + candidate_1_end.content_digest, + Some(*blake3::hash(&total_chunk_bytes).as_bytes()) + ); + + // Candidate 2: over the delivery ceiling, so metadata-only — + // matches ReadMode::MetadataOnly's own doc comment (design-doc's + // two-tier delivery-ceiling model). + let candidate_2_end = file_ends + .iter() + .find(|end| end.candidate_id == 2) + .expect("candidate 2's FILE_END must be present"); + assert_eq!(candidate_2_end, &sample_job_stream_file_end_2()); + assert_eq!(candidate_2_end.content_digest, None); + assert_eq!(candidate_2_end.chunk_count, 0); + } + // ───────────────────────── invalid fixtures (must be rejected) // ───────────────────────── From 067bb62fee9e85dce5e83bb0dff7e5b2b05d899b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:42:01 -0700 Subject: [PATCH 70/98] fix(mft): re-open the correct device path in the write-protect fallback open_unbuffered_handle (the FILE_FLAG_NO_BUFFERING fallback used when a write-protected volume's IOCP read fails) always reconstructed "\\.\{volume}:" from the drive letter, even for a VolumeHandle opened against a VSS snapshot device via open_device_path. Since VolumeHandle never stored the path it was actually opened with, the fallback would silently switch a snapshot read to the live volume instead, defeating point-in-time consistency. DuplicateHandle can't substitute for a real re-open here either, since it preserves the original handle's flags rather than adding FILE_FLAG_NO_BUFFERING. Add VolumeHandle.opened_path: Option>, populated with the real CreateFileW path in open_raw_path (covers both open() and open_device_path()) and left None for broker-adopted/duplicated handles (always the live volume, so re-deriving "\\.\{volume}:" there is still correct). open_unbuffered_handle now re-opens opened_path when present. Not reachable from uffs-content's VSS reads today (they succeed via the primary IOCP path), so this was flagged and deliberately deferred rather than rushed; fixing now on request rather than leaving it as a known gap. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-mft/src/platform/volume.rs | 97 ++++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 7 deletions(-) diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index bf77e3afc..c2ff2d2c3 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -453,6 +453,22 @@ pub struct VolumeHandle { /// handle it would silently read the *live* `$MFT`'s layout instead /// of the snapshot's, corrupting every offset computed from it. is_live_letter: bool, + /// The exact NUL-terminated UTF-16 path this handle was opened + /// against via `CreateFileW` — `Some` for [`Self::open`]'s own + /// `CreateFileW` fallback and [`Self::open_device_path`], `None` for + /// a broker-adopted or duplicated handle (which never called + /// `CreateFileW` itself here). + /// + /// [`Self::open_unbuffered_handle`] needs this: re-opening a snapshot + /// device handle's write-protect fallback by reconstructing + /// `"\\.\{volume}:"` from the drive letter would silently switch to + /// the *live* volume instead of the snapshot, and `DuplicateHandle` + /// can't substitute for a real re-open here since it preserves the + /// original handle's flags rather than adding + /// `FILE_FLAG_NO_BUFFERING`. A `None` case is always safe to + /// re-derive as `"\\.\{volume}:"`, since a broker-adopted handle only + /// ever points at the live volume (see [`Self::is_live_letter`]). + opened_path: Option>, } #[expect( @@ -658,6 +674,7 @@ impl VolumeHandle { volume_data, broker_backed: false, is_live_letter, + opened_path: Some(path.to_vec()), }) } @@ -752,6 +769,7 @@ impl VolumeHandle { volume_data, broker_backed: true, is_live_letter, + opened_path: None, }) } @@ -1014,6 +1032,14 @@ impl VolumeHandle { /// sector-aligned buffers and offsets (already guaranteed by /// [`AlignedBuffer`]). /// + /// Re-opens [`Self::opened_path`] when this handle was opened against + /// a real path (live volume or VSS snapshot device) — critically, + /// *not* re-derived as `"\\.\{volume}:"`, which would silently switch + /// a snapshot-device handle to the live volume instead (see + /// [`Self::opened_path`]'s own doc comment). Falls back to + /// `"\\.\{volume}:"` only when there is no stored path — i.e. a + /// broker-adopted/duplicated handle, which is always the live volume. + /// /// The caller is responsible for closing the returned handle. /// /// # Errors @@ -1022,17 +1048,14 @@ impl VolumeHandle { #[expect(unsafe_code, reason = "FFI: windows API (CreateFileW)")] pub(crate) fn open_unbuffered_handle(&self) -> Result { let volume = self.volume; - let volume_path: Vec = format!("\\\\.\\{volume}:") - .encode_utf16() - .chain(core::iter::once(0)) - .collect(); + let path = Self::unbuffered_reopen_path(self.opened_path.as_deref(), volume); - // SAFETY: `volume_path` is UTF-16 and NUL-terminated for the duration - // of the call, optional pointers are passed as `None`, and the + // SAFETY: `path` is UTF-16 and NUL-terminated for the duration of + // the call, optional pointers are passed as `None`, and the // returned handle is transferred to the caller. let handle = unsafe { CreateFileW( - PCWSTR::from_raw(volume_path.as_ptr()), + PCWSTR::from_raw(path.as_ptr()), FILE_READ_DATA | FILE_READ_ATTRIBUTES.0 | SYNCHRONIZE.0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, None, @@ -1048,6 +1071,25 @@ impl VolumeHandle { }) } + /// The exact NUL-terminated UTF-16 path [`Self::open_unbuffered_handle`] + /// re-opens: `opened_path` when present, else `"\\.\{volume}:"` + /// re-derived from the drive letter (only correct when there is no + /// stored path — i.e. a broker-adopted/duplicated handle, which is + /// always the live volume). Extracted from + /// [`Self::open_unbuffered_handle`] so this path-selection decision is + /// unit-testable without touching the filesystem. + fn unbuffered_reopen_path(opened_path: Option<&[u16]>, volume: super::DriveLetter) -> Vec { + opened_path.map_or_else( + || { + format!("\\\\.\\{volume}:") + .encode_utf16() + .chain(core::iter::once(0)) + .collect() + }, + <[u16]>::to_vec, + ) + } + /// Returns the byte offset of the MFT on the volume. #[must_use] pub fn mft_byte_offset(&self) -> u64 { @@ -1822,4 +1864,45 @@ mod tests { "non-WIN32 HRESULT must be forwarded verbatim", ); } + + // ── unbuffered_reopen_path regression tests ────────────────────────── + // + // Pins the write-protect-fallback fix: re-opening a snapshot-device + // handle for FILE_FLAG_NO_BUFFERING must re-open the *same* device + // path, never silently fall back to the live volume's "\\.\{letter}:" + // — that would defeat point-in-time consistency exactly like the bug + // `Self::from_duplicated_handle`'s own doc comment describes for the + // async re-open path. + + fn wide_nul_terminated(text: &str) -> Vec { + text.encode_utf16().chain(core::iter::once(0)).collect() + } + + #[test] + fn unbuffered_reopen_path_uses_the_stored_snapshot_device_path() { + let snapshot_path = wide_nul_terminated(r"\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy7"); + let volume = super::super::DriveLetter::parse('C').expect("valid drive letter"); + + let reopen_path = VolumeHandle::unbuffered_reopen_path(Some(&snapshot_path), volume); + + assert_eq!( + reopen_path, snapshot_path, + "a handle opened against a real path (live or snapshot) must re-open that exact \ + path, not re-derive the live-volume path from the drive letter" + ); + } + + #[test] + fn unbuffered_reopen_path_falls_back_to_the_live_volume_when_no_path_is_stored() { + let volume = super::super::DriveLetter::parse('D').expect("valid drive letter"); + + let reopen_path = VolumeHandle::unbuffered_reopen_path(None, volume); + + assert_eq!( + reopen_path, + wide_nul_terminated(r"\\.\D:"), + "a broker-adopted/duplicated handle has no stored path, but is always the live \ + volume, so re-deriving the live-volume path is correct here" + ); + } } From 0975005b30e06d4febb19c47570dfbc2212a0494 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:59:09 -0700 Subject: [PATCH 71/98] feat(content-protocol): add FrameStreamReader + explicit wire-layout docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docenta's slice-D nice-to-have: a streaming/incremental frame reader (or at least a precisely documented header layout) so they can frame bytes off the named pipe themselves. Add FrameStreamReader: feed it bytes as they arrive off any stream (a named pipe, a socket) in whatever chunks your I/O layer produces, and pull out complete, checksum-validated frames as they become available. Rejects an oversized payload_length from the 24-byte length prefix alone, before buffering the payload a corrupt/hostile length would otherwise make it accumulate. Keeps this crate's "no I/O" boundary (see its Cargo.toml) — it's a pure byte-assembler; the caller still owns the actual read() calls. Also expand frame/mod.rs's module doc with an explicit byte-offset wire- layout table (the crate already had this scattered across ENVELOPE_HEADER_LEN's comment and encode()/decode()'s bodies; now it's one place), and update the published Docenta wire-reference artifact to match: the delivery- ceiling, snapshot-provenance, and protocol-version callouts were stale against this session's earlier fixes, and it now documents FrameStreamReader plus the job-stream replay fixture. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-protocol/src/frame/mod.rs | 35 +++ .../src/frame/stream_reader.rs | 227 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 crates/uffs-content-protocol/src/frame/stream_reader.rs diff --git a/crates/uffs-content-protocol/src/frame/mod.rs b/crates/uffs-content-protocol/src/frame/mod.rs index e01c03315..1fc5470c1 100644 --- a/crates/uffs-content-protocol/src/frame/mod.rs +++ b/crates/uffs-content-protocol/src/frame/mod.rs @@ -11,6 +11,39 @@ //! on [`FrameType`]. This mirrors [`crate::manifest`]'s //! header/record split and keeps the bounds-checking chokepoint in one //! place regardless of which of the 12 frame types is inside. +//! +//! # Wire layout +//! +//! Every frame is this exact byte sequence, all integers little-endian. +//! There is no separate outer length prefix — `header_length` and +//! `payload_length` below are it — so a consumer reading frames directly +//! off a stream (a named pipe, a socket) reads this sequence in order: +//! +//! | Bytes | Field | Notes | +//! |---|---|---| +//! | 4 | `magic` | [`FRAME_MAGIC`] (`b"UFS2"`) | +//! | 2 | `protocol_version` | must equal [`PROTOCOL_VERSION`] | +//! | 2 | `frame_type` | [`FrameType`] discriminant | +//! | 4 | `flags` | reserved, `0` in v2 | +//! | 4 | `header_length` | bytes from `magic` through `frame_sequence`, inclusive (always `48` in v2) | +//! | 8 | `payload_length` | byte length of `payload` below | +//! | 16 | `job_id` | | +//! | 8 | `frame_sequence` | | +//! | 4 | `header_checksum` | [`crate::codec::checksum32`] over the 48 bytes above | +//! | 4 | `payload_checksum` | `checksum32` over `payload` | +//! | `payload_length` | `payload` | opaque bytes; decode per `frame_type` (e.g. [`JobBegin::decode`]) | +//! +//! So: read 24 bytes to learn `payload_length`, read 56 bytes total +//! (`header_length` + both checksums) before you can validate anything, +//! then read exactly `payload_length` more bytes for the payload — 56 + +//! `payload_length` bytes per frame, back to back, no gaps. Validate +//! `header_checksum` against bytes `0..48` and `payload_checksum` +//! against the payload before trusting either; [`FrameEnvelope::decode`] +//! already does all of this for an in-memory buffer holding a whole +//! frame. For assembling frames out of arbitrary read-sized chunks off a +//! live stream, use [`FrameStreamReader`] instead of reimplementing this +//! table — it performs exactly the above and needs no more wiring than a +//! `feed()` call per read plus a `try_next()` loop. use crate::codec::{ Reader, checksum32, write_bytes_u16_prefixed, write_i64_le, write_u16_le, write_u32_le, @@ -27,6 +60,7 @@ mod file_end; mod file_failed; mod job_begin; mod job_end; +mod stream_reader; pub use content_chunk::ContentChunk; pub use control::{Heartbeat, JobCancel, JobResume, JobSubmit, Progress, WindowUpdate}; @@ -37,6 +71,7 @@ pub use file_end::FileEnd; pub use file_failed::{FailedOutcome, FileFailed}; pub use job_begin::JobBegin; pub use job_end::JobEnd; +pub use stream_reader::FrameStreamReader; /// Frame envelope magic (design-doc §12.1). pub const FRAME_MAGIC: [u8; 4] = *b"UFS2"; diff --git a/crates/uffs-content-protocol/src/frame/stream_reader.rs b/crates/uffs-content-protocol/src/frame/stream_reader.rs new file mode 100644 index 000000000..87219de72 --- /dev/null +++ b/crates/uffs-content-protocol/src/frame/stream_reader.rs @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Incremental, transport-agnostic frame assembly. +//! +//! [`FrameEnvelope::decode`] needs a whole frame's bytes (header, both +//! checksums, and the full payload) already in one contiguous buffer — +//! exactly what a consumer reading off a named pipe or socket does *not* +//! have up front: reads return whatever bytes happen to be available, +//! which may split a frame across two reads or bundle several frames +//! into one. [`FrameStreamReader`] closes that gap: feed it bytes as +//! they arrive, in whatever chunks your I/O layer produces, and pull out +//! complete decoded frames as they become available. +//! +//! This crate does no I/O itself (see this crate's `Cargo.toml` header +//! comment) — [`FrameStreamReader`] doesn't change that. The caller +//! still owns the actual `read()` calls (blocking, async, anything); +//! this only assembles the bytes those reads produce into frames. + +use super::{ENVELOPE_HEADER_LEN, FrameEnvelope, FrameError}; +use crate::codec::Reader; + +/// Bytes needed before `payload_length` can even be read: `magic`(4) + +/// `protocol_version`(2) + `frame_type`(2) + `flags`(4) + +/// `header_length`(4) + `payload_length`(8) = 24 — the same leading +/// field order [`FrameEnvelope::decode`] itself reads. +const PAYLOAD_LENGTH_PREFIX_LEN: usize = 24; + +/// Bytes needed before a full frame can be decoded: the fixed header +/// ([`ENVELOPE_HEADER_LEN`]) plus `header_checksum`(4) plus +/// `payload_checksum`(4) — everything preceding the payload itself. +const FIXED_PREFIX_LEN: usize = ENVELOPE_HEADER_LEN + 8; + +/// Incrementally assembles [`FrameEnvelope`]s from bytes fed in as they +/// arrive off a stream. +/// +/// # Example +/// +/// ``` +/// use uffs_content_protocol::frame::FrameStreamReader; +/// +/// let mut assembler = FrameStreamReader::new(1_000_000); +/// // however your I/O layer hands you bytes: +/// // assembler.feed(&bytes_just_read); +/// while let Some((_envelope, _payload)) = assembler.try_next().unwrap() { +/// // handle one fully-decoded frame +/// } +/// // `Ok(None)` means: not enough bytes yet, read more and feed again. +/// ``` +#[derive(Debug)] +pub struct FrameStreamReader { + /// Bytes fed so far that have not yet formed a complete frame. + buffer: Vec, + /// Forwarded to [`FrameEnvelope::decode`] for every frame, and + /// checked against a peeked `payload_length` before buffering that + /// many bytes — so a corrupt or hostile length claim is rejected + /// immediately rather than after accumulating unbounded payload + /// bytes waiting for the rest of a frame that will never decode. + max_payload_bytes: u64, +} + +impl FrameStreamReader { + /// Creates an empty assembler. `max_payload_bytes` bounds any single + /// frame's payload — see [`FrameEnvelope::decode`]'s own parameter of + /// the same name. + #[must_use] + pub const fn new(max_payload_bytes: u64) -> Self { + Self { + buffer: Vec::new(), + max_payload_bytes, + } + } + + /// Appends newly-received bytes (e.g. the result of one `read()` + /// call) to the internal buffer. + pub fn feed(&mut self, bytes: &[u8]) { + self.buffer.extend_from_slice(bytes); + } + + /// Attempts to decode and consume the next complete frame from the + /// buffered bytes. + /// + /// Returns `Ok(None)` when there aren't enough buffered bytes yet + /// for a complete frame — call [`Self::feed`] with more bytes and + /// try again. Returns `Ok(Some(..))` once a full frame decoded + /// successfully; its bytes are removed from the internal buffer, so + /// calling this again immediately may return a second already-fully- + /// buffered frame without an intervening `feed`. + /// + /// # Errors + /// Returns [`FrameError`] if the buffered bytes form a malformed + /// frame (bad magic, a checksum mismatch, an unknown discriminant, + /// ...) or a `payload_length` exceeding `max_payload_bytes`. Either + /// way the underlying stream is desynchronized — there is no + /// well-defined next frame boundary to resume from, so treat this as + /// fatal for the connection (matching `uffs-content`'s own + /// command-pipe dispatcher: log and close, don't retry `try_next`). + pub fn try_next(&mut self) -> Result)>, FrameError> { + let Some(payload_length) = peek_payload_length(&self.buffer) else { + return Ok(None); + }; + if payload_length > self.max_payload_bytes { + return Err(FrameError::PayloadTooLarge { + declared: payload_length, + max: self.max_payload_bytes, + }); + } + let payload_len_usize = usize::try_from(payload_length).unwrap_or(usize::MAX); + let Some(total_len) = FIXED_PREFIX_LEN.checked_add(payload_len_usize) else { + return Err(FrameError::PayloadTooLarge { + declared: payload_length, + max: self.max_payload_bytes, + }); + }; + if self.buffer.len() < total_len { + return Ok(None); + } + + let frame_bytes: Vec = self.buffer.drain(0..total_len).collect(); + let mut reader = Reader::new(&frame_bytes); + let (envelope, payload) = FrameEnvelope::decode(&mut reader, self.max_payload_bytes)?; + Ok(Some((envelope, payload))) + } +} + +/// Peeks `payload_length` out of `buffer` without consuming anything, +/// reading the exact same leading field sequence +/// [`FrameEnvelope::decode`] does. Returns `None` if `buffer` doesn't +/// yet hold [`PAYLOAD_LENGTH_PREFIX_LEN`] bytes. +fn peek_payload_length(buffer: &[u8]) -> Option { + if buffer.len() < PAYLOAD_LENGTH_PREFIX_LEN { + return None; + } + let mut reader = Reader::new(buffer); + let _magic: [u8; 4] = reader.read_array().ok()?; + let _protocol_version = reader.read_u16_le().ok()?; + let _frame_type_raw = reader.read_u16_le().ok()?; + let _flags = reader.read_u32_le().ok()?; + let _header_length = reader.read_u32_le().ok()?; + reader.read_u64_le().ok() +} + +#[cfg(test)] +mod tests { + use super::FrameStreamReader; + use crate::frame::{FrameEnvelope, FrameType, PROTOCOL_VERSION}; + + fn sample_frame_bytes(frame_sequence: u64, payload: &[u8]) -> Vec { + FrameEnvelope { + protocol_version: PROTOCOL_VERSION, + frame_type: FrameType::Heartbeat, + flags: 0, + job_id: [9_u8; 16], + frame_sequence, + } + .encode(payload) + } + + #[test] + fn returns_none_until_enough_bytes_are_fed() { + let full = sample_frame_bytes(1, b"hello"); + let mut assembler = FrameStreamReader::new(1_000_000); + + // Feed one byte at a time; only the very last byte should + // complete the frame. + for (index, byte) in full.iter().enumerate() { + assembler.feed(core::slice::from_ref(byte)); + let result = assembler.try_next().expect("no decode error expected"); + if index + 1 < full.len() { + assert!(result.is_none(), "must not decode before all bytes arrive"); + } else { + let (envelope, payload) = result.expect("frame must be ready on the last byte"); + assert_eq!(envelope.frame_sequence, 1); + assert_eq!(payload, b"hello"); + } + } + } + + #[test] + fn assembles_two_frames_delivered_in_one_chunk() { + let mut all_bytes = sample_frame_bytes(1, b"first"); + all_bytes.extend(sample_frame_bytes(2, b"second")); + + let mut assembler = FrameStreamReader::new(1_000_000); + assembler.feed(&all_bytes); + + let (first_envelope, first_payload) = assembler + .try_next() + .expect("no decode error expected") + .expect("first frame must be ready"); + assert_eq!(first_envelope.frame_sequence, 1); + assert_eq!(first_payload, b"first"); + + let (second_envelope, second_payload) = assembler + .try_next() + .expect("no decode error expected") + .expect("second frame must be ready without an extra feed"); + assert_eq!(second_envelope.frame_sequence, 2); + assert_eq!(second_payload, b"second"); + + assert!( + assembler + .try_next() + .expect("no decode error expected") + .is_none(), + "buffer must be empty after both frames are consumed" + ); + } + + #[test] + fn rejects_a_payload_length_over_the_ceiling_without_buffering_it() { + let big_frame = sample_frame_bytes(1, &[0_u8; 64]); + // A ceiling smaller than the real payload — the assembler must + // reject this from the length prefix alone, without needing the + // full (oversized) payload to ever be fed. + let mut assembler = FrameStreamReader::new(10); + assembler.feed(&big_frame); + + let err = assembler + .try_next() + .expect_err("payload_length exceeds max_payload_bytes"); + assert!(matches!(err, crate::frame::FrameError::PayloadTooLarge { + declared: 64, + max: 10 + })); + } +} From cd1364a04ba55b85799a03b60a1824b6dcde0251 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:38:19 -0700 Subject: [PATCH 72/98] fix(content): stop a silent single-candidate hang, add real progress logging Live-debugged with the user: uffs-content --self-test-reader-benchmark "*.txt" --drive C ran for 79+ minutes with zero output after "enumeration complete". Process inspection (Get-Counter, thread state) showed uffs-content.exe pinned near 100% CPU on one Running thread with 3.4 GB resident, while uffs-content-reader.exe was fully idle -- consistent with one candidate's read loop (read_one_candidate) trusting a corrupted/ implausible logical_size with no independent bound, no timeout, and no visibility into what was happening. Add two things read_one_candidate was missing entirely: - an upfront warning when a candidate's declared logical_size is implausibly large (>1 TiB), the likely signature of stale/corrupted MFT metadata (e.g. a reused FRS), not a genuinely huge file; - a stall warning every ~30s while a single candidate's read is still in progress, so a stuck read is visible within seconds, not after an hour of silence. Also add periodic "job: content read progress" heartbeats (every ~10s or every 1000 candidates, whichever first) to read_and_emit_all_candidates, so any long-running job -- not just a corrupted-metadata case -- reports live progress instead of going silent between the enumeration-complete log line and the final summary. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/workflow.rs | 60 +++++++++++++++- .../uffs-content/src/job/workflow/pipeline.rs | 69 +++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 5bd3025c5..a9329e317 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -363,6 +363,10 @@ fn read_and_emit_all_candidates( where F: FnMut(Vec) -> io::Result<()>, { + let total_candidates = candidates.len(); + let mut emitted_count = 0_usize; + let mut last_progress_log_at = std::time::Instant::now(); + for run in lease_runs(candidates) { let Some(&(first_entry, _)) = run.first() else { continue; @@ -378,7 +382,7 @@ where let Some(&(entry, candidate_id)) = run.get(index) else { return Ok(()); }; - emit_candidate( + let result = emit_candidate( entry, candidate_id, read_result, @@ -387,13 +391,65 @@ where job_id, frame_sequence, emit_frame, - ) + ); + emitted_count += 1; + log_progress_if_due( + emitted_count, + total_candidates, + counters, + &mut last_progress_log_at, + ); + result }, )?; } Ok(()) } +/// How often [`read_and_emit_all_candidates`] logs a progress heartbeat, +/// at minimum — never less often than this many wall-clock seconds +/// apart, regardless of candidate count or throughput. Chosen so a job +/// that's silently grinding for a long time (whether genuinely slow or +/// stuck on one candidate — see `pipeline::read_one_candidate`'s own +/// per-candidate stall warning) is never silent for more than about this +/// long between updates. +const PROGRESS_LOG_MIN_INTERVAL: core::time::Duration = core::time::Duration::from_secs(10); + +/// Also log a heartbeat every this many candidates, even if +/// [`PROGRESS_LOG_MIN_INTERVAL`] hasn't elapsed — keeps a very fast run +/// (thousands of tiny files) from having its own progress signal +/// throttled down to nothing. +const PROGRESS_LOG_CANDIDATE_STRIDE: usize = 1000; + +/// Log an `INFO`-level progress line if either [`PROGRESS_LOG_MIN_INTERVAL`] +/// has elapsed since the last one or `emitted_count` just crossed a +/// [`PROGRESS_LOG_CANDIDATE_STRIDE`] boundary — see this module's +/// "Concurrent reads, sequential emission" doc section for why total +/// silence during content reading was a real problem this closes. +fn log_progress_if_due( + emitted_count: usize, + total_candidates: usize, + counters: &RunCounters, + last_progress_log_at: &mut std::time::Instant, +) { + let due_by_time = last_progress_log_at.elapsed() >= PROGRESS_LOG_MIN_INTERVAL; + let due_by_count = emitted_count.is_multiple_of(PROGRESS_LOG_CANDIDATE_STRIDE) + || emitted_count == total_candidates; + if !due_by_time && !due_by_count { + return; + } + *last_progress_log_at = std::time::Instant::now(); + tracing::info!( + emitted_count, + total_candidates, + succeeded = counters.succeeded_count, + failed_retryable = counters.failed_retryable_count, + failed_terminal = counters.failed_terminal_count, + logical_bytes_succeeded = counters.logical_bytes_succeeded, + "job: content read progress" + ); +} + /// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and /// advancing the next `frame_sequence`. fn encode_frame( diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs index 8c1c3e1ef..6000b464f 100644 --- a/crates/uffs-content/src/job/workflow/pipeline.rs +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -18,6 +18,21 @@ use uffs_content_protocol::frame::{ContentChunk, ReadMode}; use crate::job::candidate_source::CandidateEntry; use crate::job::content_source::ContentSource; +/// A declared `logical_size` above this is almost certainly corrupted +/// MFT metadata, not a genuine file — used only to log a warning early +/// (see [`read_one_candidate`]), never to reject or cap the read itself, +/// since a real use case (e.g. a VM image or disk image export) can +/// legitimately exceed this. +const IMPLAUSIBLE_LOGICAL_SIZE_BYTES: u64 = 1024 * 1024 * 1024 * 1024; // 1 TiB + +/// How often [`read_one_candidate`] re-warns about a single candidate +/// still being read, once it's been in progress this long. Chosen to be +/// well above normal per-file read latency (even a large legitimate +/// file should clear this comfortably) but short enough that a genuinely +/// stuck candidate is visible in the log within one interval, not after +/// an hour of silence. +const STALL_WARNING_INTERVAL: core::time::Duration = core::time::Duration::from_secs(30); + /// Split `candidates` into contiguous same-`snapshot_lease_id` runs for /// [`read_lease_run_pipelined`] — unlike a fixed batch size, a run is /// never capped: its own concurrency (looked up once, from its first @@ -301,6 +316,18 @@ fn read_one_candidate( read_mode: ReadMode::MetadataOnly, }; } + if entry.logical_size > IMPLAUSIBLE_LOGICAL_SIZE_BYTES { + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + declared_logical_size = entry.logical_size, + "content read: candidate's declared logical_size is implausibly large -- this \ + usually means corrupted/stale MFT metadata for this file (e.g. a reused FRS or a \ + race with the file being resized around snapshot time), not a genuinely huge file; \ + the read below is bounded by this declared size regardless, so a corrupted value \ + here can make one candidate consume a very long time and a lot of memory" + ); + } let mut hasher = IncrementalDigest::new(); let mut offset = 0_u64; @@ -308,8 +335,17 @@ fn read_one_candidate( let mut total_read = 0_u64; let mut chunks = Vec::new(); let mut read_error = None; + let read_started_at = std::time::Instant::now(); + let mut last_stall_warning_at = read_started_at; while offset < entry.logical_size { + warn_if_candidate_read_is_stalling( + entry, + candidate_id, + total_read, + read_started_at, + &mut last_stall_warning_at, + ); match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { Ok(bytes) if bytes.is_empty() => break, Ok(bytes) => { @@ -348,3 +384,36 @@ fn read_one_candidate( read_mode: ReadMode::LogicalSnapshot, } } + +/// Logs a warning if this candidate's read has been running for at +/// least [`STALL_WARNING_INTERVAL`] and hasn't already warned within the +/// last [`STALL_WARNING_INTERVAL`] — extracted from +/// [`read_one_candidate`]'s read loop purely to keep that function's +/// cognitive complexity down; see its own doc comment for why this +/// exists (a corrupted `logical_size` or a stuck reader-side round trip +/// must be visible in the log, not silent for an hour). +fn warn_if_candidate_read_is_stalling( + entry: &CandidateEntry, + candidate_id: u64, + total_read: u64, + read_started_at: std::time::Instant, + last_stall_warning_at: &mut std::time::Instant, +) { + if read_started_at.elapsed() < STALL_WARNING_INTERVAL + || last_stall_warning_at.elapsed() < STALL_WARNING_INTERVAL + { + return; + } + *last_stall_warning_at = std::time::Instant::now(); + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + declared_logical_size = entry.logical_size, + total_read, + elapsed_secs = read_started_at.elapsed().as_secs(), + "content read: this candidate is taking unusually long -- still in progress, not \ + necessarily hung, but if this repeats every ~30s indefinitely for the same \ + candidate_id, suspect corrupted logical_size (see the warning above, if any) or a \ + stuck reader-side round trip" + ); +} From 45f4d0bae4b8955527ea27f781120ca757d189b9 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:40:31 -0700 Subject: [PATCH 73/98] fix(content): shorten stall threshold, add upfront notably-large-file logging Live-debugged with the user: a run hit another ~1.1 GB file that slowed throughput to ~3.4 MB/s (down from ~120+ MB/s on small files) but never individually crossed the 30s stall-warning threshold, so the run went through it with no per-file signal at all -- only inferable after the fact from progress-heartbeat arithmetic. Shorten STALL_WARNING_INTERVAL from 30s to 10s, and add a new upfront INFO log the moment a candidate's declared logical_size crosses 50 MiB, before any bytes are read -- so a throughput dip during a run can always be traced to a named candidate_id/path immediately, not reconstructed later from byte/time deltas across heartbeat lines. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/workflow/pipeline.rs | 70 ++++++++++++++----- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs index 6000b464f..f9acd004f 100644 --- a/crates/uffs-content/src/job/workflow/pipeline.rs +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -25,13 +25,23 @@ use crate::job::content_source::ContentSource; /// legitimately exceed this. const IMPLAUSIBLE_LOGICAL_SIZE_BYTES: u64 = 1024 * 1024 * 1024 * 1024; // 1 TiB +/// A declared `logical_size` at or above this is worth announcing +/// *before* reading it (see [`read_one_candidate`]) — real hardware has +/// shown large/fragmented files reading dramatically slower toward +/// their end than a small-file baseline would predict, without any +/// single candidate ever crossing [`STALL_WARNING_INTERVAL`] on its own. +/// Logging every such candidate up front means a run's throughput dip +/// can always be attributed to a named file, not just inferred after +/// the fact from progress-heartbeat arithmetic. +const NOTABLY_LARGE_LOGICAL_SIZE_BYTES: u64 = 50 * 1024 * 1024; // 50 MiB + /// How often [`read_one_candidate`] re-warns about a single candidate -/// still being read, once it's been in progress this long. Chosen to be -/// well above normal per-file read latency (even a large legitimate -/// file should clear this comfortably) but short enough that a genuinely -/// stuck candidate is visible in the log within one interval, not after -/// an hour of silence. -const STALL_WARNING_INTERVAL: core::time::Duration = core::time::Duration::from_secs(30); +/// still being read, once it's been in progress this long. Shorter than +/// might seem necessary on purpose: real hardware has shown a single +/// large/fragmented file's read throughput can collapse well before 30s +/// of elapsed time on that one candidate, so a shorter interval catches +/// a slow candidate sooner without waiting for it to look fully stuck. +const STALL_WARNING_INTERVAL: core::time::Duration = core::time::Duration::from_secs(10); /// Split `candidates` into contiguous same-`snapshot_lease_id` runs for /// [`read_lease_run_pipelined`] — unlike a fixed batch size, a run is @@ -316,18 +326,7 @@ fn read_one_candidate( read_mode: ReadMode::MetadataOnly, }; } - if entry.logical_size > IMPLAUSIBLE_LOGICAL_SIZE_BYTES { - tracing::warn!( - candidate_id, - path = %entry.relative_path.display(), - declared_logical_size = entry.logical_size, - "content read: candidate's declared logical_size is implausibly large -- this \ - usually means corrupted/stale MFT metadata for this file (e.g. a reused FRS or a \ - race with the file being resized around snapshot time), not a genuinely huge file; \ - the read below is bounded by this declared size regardless, so a corrupted value \ - here can make one candidate consume a very long time and a lot of memory" - ); - } + log_candidate_size_if_notable(entry, candidate_id); let mut hasher = IncrementalDigest::new(); let mut offset = 0_u64; @@ -385,6 +384,41 @@ fn read_one_candidate( } } +/// Logs a warning if `entry.logical_size` is implausibly large (likely +/// corrupted MFT metadata), or an informational note if it's merely +/// notably large (a real, sizeable file worth naming up front) — +/// extracted from [`read_one_candidate`] purely to keep that function's +/// cognitive complexity down; see [`IMPLAUSIBLE_LOGICAL_SIZE_BYTES`] and +/// [`NOTABLY_LARGE_LOGICAL_SIZE_BYTES`]'s own doc comments for why both +/// exist. +fn log_candidate_size_if_notable(entry: &CandidateEntry, candidate_id: u64) { + if entry.logical_size > IMPLAUSIBLE_LOGICAL_SIZE_BYTES { + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + declared_logical_size = entry.logical_size, + "content read: candidate's declared logical_size is implausibly large -- this \ + usually means corrupted/stale MFT metadata for this file (e.g. a reused FRS or a \ + race with the file being resized around snapshot time), not a genuinely huge file; \ + the read below is bounded by this declared size regardless, so a corrupted value \ + here can make one candidate consume a very long time and a lot of memory" + ); + } else if entry.logical_size >= NOTABLY_LARGE_LOGICAL_SIZE_BYTES { + // Announced up front, before any bytes are read, so a later + // throughput dip can always be traced back to a named candidate + // instead of only inferred from progress-heartbeat arithmetic + // after the fact -- real hardware has shown a large/fragmented + // file's read slow down well before it individually crosses + // STALL_WARNING_INTERVAL. + tracing::info!( + candidate_id, + path = %entry.relative_path.display(), + declared_logical_size = entry.logical_size, + "content read: about to read a notably large candidate" + ); + } +} + /// Logs a warning if this candidate's read has been running for at /// least [`STALL_WARNING_INTERVAL`] and hasn't already warned within the /// last [`STALL_WARNING_INTERVAL`] — extracted from From a7bc3aaf6fed99fef81d3b1b6378ad097b129fd2 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:47:27 -0700 Subject: [PATCH 74/98] fix(content): bound the sliding-window pipeline's reorder-buffer memory Live-debugged with the user: a run against C:\*.txt (44065 candidates) hit uffs-content.exe using 16 GB of resident memory. Root cause: the coordinator (drain_pipelined_output) drained every worker's completed read into an unbounded reorder HashMap regardless of how far ahead of the emission cursor it was. One slow candidate holding up next_expected did not stop the other workers from reading every remaining candidate in the run to completion and fully buffering all of it in memory -- the module's own doc comment already claimed memory stayed "a small constant multiple of concurrency, regardless of run length", but the code never actually enforced that. Add a bounded credit channel: the feeder must claim one credit before sending each index, and the coordinator returns exactly one credit every time an index resolves (whether on_ready was actually called for it or not, so the error-handling drain path can't strand a credit and deadlock the feeder). credit_window is worker_count * 4 -- generous enough that workers rarely feel it on an ordinary run, but always finite. This makes the pipeline's memory bound match what its own documentation already claimed. New test (a_run_larger_than_the_credit_window_still_completes_correctly) exercises a run well past this window with concurrency=2, confirming correctness (strict candidate_id emission order, full success) when the feeder is forced to actually block on credits, not just the never-full-window happy path every other test here exercises. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/tests.rs | 69 +++++++++++++++++++ .../uffs-content/src/job/workflow/pipeline.rs | 48 ++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index bdbfdebe3..045317e21 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -189,6 +189,75 @@ fn run_job_produces_a_well_formed_frame_sequence_with_no_failures() { assert_eq!(file_end_count, 2, "both candidates must reach FILE_END"); } +#[test] +fn a_run_larger_than_the_credit_window_still_completes_correctly() { + let source_dir = tempfile::tempdir().expect("create source temp dir"); + // concurrency 2 -> credit_window = 2 * 4 = 8 (see pipeline:: + // read_lease_run_pipelined), so a run of 40 files forces the feeder + // to actually exhaust and wait on credits several times over, not + // just exercise the never-full-window happy path every other test + // in this file takes. + let file_count: u64 = 40; + for index in 0..file_count { + fs::write( + source_dir.path().join(format!("file_{index:03}.txt")), + format!("content for file {index}").into_bytes(), + ) + .expect("write fixture file"); + } + + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let request = JobRequest { + source_id: "credit-window-source".to_owned(), + roots: vec![source_dir.path().to_path_buf()], + query: "*".to_owned(), + ..Default::default() + }; + + let mut frames = Vec::new(); + let outcome = run_job( + &request, + &DirWalkCandidateSource, + &FsContentSource, + run_dir.path(), + &ReadConcurrency::flat(2), + &[], + 0, + |frame| { + frames.push(frame); + Ok(()) + }, + ) + .expect("run_job must succeed"); + + assert_eq!(outcome.run_summary.candidate_count, file_count); + assert_eq!(outcome.run_summary.succeeded_count, file_count); + assert_eq!(outcome.run_summary.failed_retryable_count, 0); + assert_eq!(outcome.run_summary.failed_terminal_count, 0); + + // FILE_END frames must appear in strict, gapless candidate_id order + // -- confirms the credit-window backpressure never disturbs the + // pipeline's strict-order emission guarantee, even when the feeder + // is repeatedly forced to block waiting for a credit. + let mut file_end_candidate_ids = Vec::new(); + for frame_bytes in &frames { + let mut reader = Reader::new(frame_bytes); + let (envelope, payload) = + FrameEnvelope::decode(&mut reader, u64::MAX).expect("decode frame envelope"); + if envelope.frame_type == FrameType::FileEnd { + let mut payload_reader = Reader::new(&payload); + let file_end = FileEnd::decode(&mut payload_reader).expect("decode file end"); + file_end_candidate_ids.push(file_end.candidate_id); + } + } + let expected_ids: Vec = (1..=file_count).collect(); + assert_eq!( + file_end_candidate_ids, expected_ids, + "FILE_END frames must appear in strict candidate_id order even when the run exceeds \ + the credit window" + ); +} + #[test] fn candidates_over_the_delivery_ceiling_are_reported_metadata_only() { let source_dir = tempfile::tempdir().expect("create source temp dir"); diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs index f9acd004f..7ab7e6a19 100644 --- a/crates/uffs-content/src/job/workflow/pipeline.rs +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -137,6 +137,21 @@ pub(super) struct CandidateContent { /// channel into a small reorder map and calls `on_ready` for `0, 1, 2, ...` /// in turn as each becomes available. /// +/// A fourth, bounded **credit** channel keeps the whole pipeline's memory +/// bounded regardless of run length: the feeder must claim one credit +/// before sending each index, and the coordinator returns exactly one +/// credit every time an index resolves (whether `on_ready` was actually +/// called for it or not — see below). Without this, a single slow +/// candidate holding up `next_expected` would not stop the *other* +/// workers from reading every remaining candidate in the run to +/// completion and piling the results into the reorder map unbounded — +/// real hardware has shown this: one multi-GB candidate stalling +/// emission while workers kept finishing (and fully buffering) tens of +/// thousands of others behind it, well past what "a small constant +/// multiple of concurrency" should ever allow. The credit window is +/// generous relative to `concurrency` (workers should rarely feel it on +/// an ordinary run) but always finite. +/// /// If `on_ready` itself returns an error (e.g. a downstream transport /// failure), the coordinator stops calling it but keeps draining the /// output channel to completion anyway — never stopping early and @@ -160,15 +175,32 @@ pub(super) fn read_lease_run_pipelined( return Ok(()); } let worker_count = concurrency.max(1).min(run.len()); + // How many candidates may be claimed-but-not-yet-emitted at once — + // see this function's own doc comment on the credit channel. A + // small multiple of worker_count gives workers slack to keep moving + // even while a handful of candidates ahead of the emission cursor + // are still being read, without letting the whole remainder of a + // huge run pile into memory behind one straggler. + let credit_window = worker_count.saturating_mul(4); let (input_tx, input_rx): (Sender, Receiver) = crossbeam_channel::bounded(worker_count); let (output_tx, output_rx): (Sender, Receiver) = crossbeam_channel::bounded(worker_count); + let (credit_tx, credit_rx): (Sender<()>, Receiver<()>) = + crossbeam_channel::bounded(credit_window); + for _ in 0..credit_window { + // Never blocks: capacity is exactly credit_window and this sends + // exactly that many, once, before any thread below starts. + let _prefilled = credit_tx.try_send(()).ok(); + } std::thread::scope(|scope| { scope.spawn(move || { for index in 0..run.len() { + if credit_rx.recv().is_err() { + break; + } if input_tx.send(index).is_err() { break; } @@ -204,7 +236,7 @@ pub(super) fn read_lease_run_pipelined( // finish. drop(output_tx); - drain_pipelined_output(run.len(), &output_rx, &mut on_ready) + drain_pipelined_output(run.len(), &output_rx, &credit_tx, &mut on_ready) }) } @@ -218,6 +250,14 @@ type IndexedContent = (usize, CandidateContent); /// so `read_lease_run_pipelined` itself stays under the workspace's /// `too_many_lines` budget. /// +/// Returns one credit to `credit_tx` every time an index resolves — +/// whether `on_ready` was actually called for it or not (see this +/// function's own error-handling branch below) — so the feeder in +/// [`read_lease_run_pipelined`] never blocks waiting for a credit that a +/// resolved-but-unemitted index should have released. This is the other +/// half of that function's credit-window backpressure; see its doc +/// comment for why the window exists at all. +/// /// # Errors /// Returns the first error `on_ready` produced, after draining every /// remaining result (see [`read_lease_run_pipelined`]'s doc comment for @@ -226,6 +266,7 @@ type IndexedContent = (usize, CandidateContent); fn drain_pipelined_output( total_candidates: usize, output_rx: &Receiver, + credit_tx: &Sender<()>, on_ready: &mut dyn FnMut(usize, CandidateContent) -> io::Result<()>, ) -> io::Result<()> { let mut next_expected = 0_usize; @@ -239,6 +280,11 @@ fn drain_pipelined_output( first_error = Some(err); } next_expected += 1; + // Best-effort: a disconnected credit channel just means the + // feeder already exited (e.g. it hit a send error on + // input_tx and gave up), not something this coordinator + // needs to react to. + let _credit_returned = credit_tx.send(()).ok(); continue; } match output_rx.recv() { From 7edfec7088eff89a88ad108808f93f009fa1bba0 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:13:49 -0700 Subject: [PATCH 75/98] feat(content): log consumer-facing pipe throughput in the progress heartbeat Directly answers "what should the downstream consumer expect for pipe throughput on a system like ours": logical_bytes_succeeded only advances as candidates are actually emitted (handed to emit_frame -- the real wire write to the consumer in --serve mode), so it's already the right signal for consumer-facing throughput. It just wasn't expressed as a rate. Add mib_per_sec_since_last_heartbeat (bytes emitted since the previous heartbeat, divided by the elapsed time since then) and mib_per_sec_since_job_start (cumulative average) to the existing "job: content read progress" log line. Scanning mib_per_sec_since_last_heartbeat across a run's log now gives real min/max figures with no hand computation from timestamp deltas; the last line's mib_per_sec_since_job_start is the run's overall average. This is a direct measurement of what actually crosses the wire to a downstream consumer -- not an internal per-connection or per-drive read rate, and not affected by however many of the drive's connections are interleaved internally to produce it. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/workflow.rs | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index a9329e317..1d5c569a8 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -365,7 +365,9 @@ where { let total_candidates = candidates.len(); let mut emitted_count = 0_usize; - let mut last_progress_log_at = std::time::Instant::now(); + let run_started_at = std::time::Instant::now(); + let mut last_progress_log_at = run_started_at; + let mut last_progress_log_bytes = 0_u64; for run in lease_runs(candidates) { let Some(&(first_entry, _)) = run.first() else { @@ -397,7 +399,9 @@ where emitted_count, total_candidates, counters, + run_started_at, &mut last_progress_log_at, + &mut last_progress_log_bytes, ); result }, @@ -426,11 +430,33 @@ const PROGRESS_LOG_CANDIDATE_STRIDE: usize = 1000; /// [`PROGRESS_LOG_CANDIDATE_STRIDE`] boundary — see this module's /// "Concurrent reads, sequential emission" doc section for why total /// silence during content reading was a real problem this closes. +/// +/// Also reports `mib_per_sec_since_last_heartbeat` and +/// `mib_per_sec_since_job_start`: `logical_bytes_succeeded` only +/// advances as candidates are actually *emitted* — i.e. bytes handed to +/// `emit_frame`, the real wire write in `--serve` mode — so both figures +/// are a direct measurement of consumer-facing pipe throughput, not an +/// internal per-connection or per-drive read rate. Scan +/// `mib_per_sec_since_last_heartbeat` across a run's log for min/max; +/// the last line's `mib_per_sec_since_job_start` is the run's overall +/// average. +#[expect( + clippy::cast_precision_loss, + reason = "diagnostic-only throughput figures for a log line, not computed against further \ + — same posture as uffs-content's own benchmark report (self_test.rs)" +)] +#[expect( + clippy::float_arithmetic, + reason = "diagnostic-only throughput ratios for a log line, matching self_test.rs's \ + existing benchmark-report precedent" +)] fn log_progress_if_due( emitted_count: usize, total_candidates: usize, counters: &RunCounters, + run_started_at: std::time::Instant, last_progress_log_at: &mut std::time::Instant, + last_progress_log_bytes: &mut u64, ) { let due_by_time = last_progress_log_at.elapsed() >= PROGRESS_LOG_MIN_INTERVAL; let due_by_count = emitted_count.is_multiple_of(PROGRESS_LOG_CANDIDATE_STRIDE) @@ -438,7 +464,24 @@ fn log_progress_if_due( if !due_by_time && !due_by_count { return; } + let interval_secs = last_progress_log_at.elapsed().as_secs_f64(); + let interval_bytes = counters + .logical_bytes_succeeded + .saturating_sub(*last_progress_log_bytes); + let mib_per_sec_since_last_heartbeat = if interval_secs > 0.0_f64 { + (interval_bytes as f64 / (1_024.0_f64 * 1_024.0_f64)) / interval_secs + } else { + 0.0_f64 + }; + let overall_secs = run_started_at.elapsed().as_secs_f64(); + let mib_per_sec_since_job_start = if overall_secs > 0.0_f64 { + (counters.logical_bytes_succeeded as f64 / (1_024.0_f64 * 1_024.0_f64)) / overall_secs + } else { + 0.0_f64 + }; + *last_progress_log_at = std::time::Instant::now(); + *last_progress_log_bytes = counters.logical_bytes_succeeded; tracing::info!( emitted_count, total_candidates, @@ -446,6 +489,8 @@ fn log_progress_if_due( failed_retryable = counters.failed_retryable_count, failed_terminal = counters.failed_terminal_count, logical_bytes_succeeded = counters.logical_bytes_succeeded, + mib_per_sec_since_last_heartbeat, + mib_per_sec_since_job_start, "job: content read progress" ); } From bb8a5bdb0019bcc99bd7afbbc6de972beee52596 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:08:20 -0700 Subject: [PATCH 76/98] perf(content): raise DEFAULT_MAX_CHUNK_BYTES 64KB -> 1MiB Quick win #1 from the real-hardware perf investigation: uffs-content-reader's read_logical (crates/uffs-content-reader/src/reader/logical.rs) does a full CreateFileW+OpenFileById+GetFileSizeEx+ReadFile+close-both-handles cycle on every single ReadRequest, with zero handle caching across a file's sequential reads. At 64KB chunks, a 2.76 GB file needed roughly 42,000 of those full open/close cycles; per-OpenFileById cost against a VSS snapshot device varied wildly file to file in a way that tracked with open/close overhead far better than with file size or fragmentation (a 582MB file was measured slower than a 2.76GB one). 1 MiB cuts that count 16x with no protocol risk: it stays far under uffs-content-reader-protocol::MAX_RESPONSE_PAYLOAD_BYTES (64 MiB), and serve::pipe_io::MAX_MESSAGE_BYTES is derived from this constant so it grows with it automatically. Also updated the published Docenta wire-reference artifact, which quoted the old 65536 default. This doesn't fix the underlying per-chunk open/close cost, only reduces how often it's paid -- caching the open handle across a candidate's whole read is the deeper fix, tracked as a follow-up. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/workflow.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 1d5c569a8..984ed7483 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -103,10 +103,25 @@ use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; /// One `CONTENT_CHUNK`'s maximum payload size for a job run. /// -/// Deliberately small so even modest fixture files exercise multiple -/// chunks — production tuning of this value is a UFI.2 scheduler -/// concern, not something this workflow needs to get "right" yet. -pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 64 * 1024; +/// Real-hardware benchmarking found `uffs-content-reader`'s +/// `read_logical` (`crates/uffs-content-reader/src/reader/logical.rs`) +/// does a full `CreateFileW`+`OpenFileById`+`GetFileSizeEx`+`ReadFile`+ +/// close-both-handles cycle on *every single* `ReadRequest` — i.e. once +/// per chunk, with no handle caching across a file's sequential reads. +/// At the previous `64 * 1024` default, a 2.76 GB file needed roughly +/// 42,000 of those full open/close cycles; per-`OpenFileById` cost +/// against a VSS snapshot device varied wildly file to file in a way +/// that didn't correlate with file size, which is exactly what you'd +/// expect from open/close overhead rather than genuine streaming +/// throughput. `1 MiB` cuts that count 16x with no protocol risk — it +/// stays far under `uffs_content_reader_protocol::MAX_RESPONSE_PAYLOAD_BYTES` +/// (64 MiB), and `serve::pipe_io::MAX_MESSAGE_BYTES` is derived from +/// this constant, so it grows with it automatically. +/// +/// This does not fix the underlying per-chunk open/close cost, only +/// reduces how often it's paid; caching the open handle across a +/// candidate's whole read (a bigger, separate change) is the deeper fix. +pub const DEFAULT_MAX_CHUNK_BYTES: u32 = 1024 * 1024; /// Per-drive (per `snapshot_lease_id`) content-read concurrency. /// From b35e11c5d287ed07de457e7db952bf286da50e4e Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 15:39:02 -0700 Subject: [PATCH 77/98] fix(content): pin connection + cache file handle per candidate read Real-hardware benchmarking on a 44k-file/9GB corpus showed large-file read speed varying wildly (3.8-80 MB/s) in a way that tracked open/close overhead far better than file size or disk throughput. Root cause: the Reader did a fresh CreateFileW + OpenFileById + GetFileSizeEx cycle on every single chunk, with no handle reuse across a file's sequential reads. Redesign ContentSource from a stateless per-chunk read_at into a session-based model (begin_read + ReadSession). The client now pins one connection per candidate's whole read instead of checking one out fresh per chunk (reader_client.rs), and the server caches its open NTFS file handle per connection across consecutive same-file requests (uffs-content-reader/src/reader/logical.rs's ReadHandleCache), threaded through the pipe server's spawn_blocking boundary. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-reader/src/reader.rs | 55 ++-- .../uffs-content-reader/src/reader/logical.rs | 110 +++++++- .../src/reader/pipe_server.rs | 54 ++-- crates/uffs-content/src/job/content_source.rs | 107 +++++--- crates/uffs-content/src/job/reader_client.rs | 234 ++++++++++++------ crates/uffs-content/src/job/tests.rs | 16 +- .../uffs-content/src/job/workflow/pipeline.rs | 23 +- 7 files changed, 434 insertions(+), 165 deletions(-) diff --git a/crates/uffs-content-reader/src/reader.rs b/crates/uffs-content-reader/src/reader.rs index a4d0171df..4316b4f21 100644 --- a/crates/uffs-content-reader/src/reader.rs +++ b/crates/uffs-content-reader/src/reader.rs @@ -44,7 +44,12 @@ use std::collections::HashMap; #[cfg(windows)] use uffs_content_reader_protocol::{ReadRequest, ReadResponse, ReaderErrorCode}; -/// Dispatch one decoded `ReadRequest` into a `ReadResponse`. +/// Dispatch one decoded `ReadRequest` into a `ReadResponse`, threading +/// this connection's [`logical::ReadHandleCache`] through the call — +/// see that type's own doc comment for why. Always returns a fresh +/// cache: `Some` (the possibly-reused-or-reopened handle) on success, +/// `ReadHandleCache::empty()` on any failure, since a failed read leaves +/// the cached handle's state unknown. /// /// Every failure mode (unknown lease, open failure, read failure, /// invalid VDL/EOF metadata) is caught here and turned into a typed @@ -52,15 +57,22 @@ use uffs_content_reader_protocol::{ReadRequest, ReadResponse, ReaderErrorCode}; /// `Err` up to its caller, since a malformed *single* request must not /// tear down the whole connection. #[cfg(windows)] -fn dispatch_request(request: &ReadRequest, devices: &HashMap) -> ReadResponse { +fn dispatch_request( + request: &ReadRequest, + devices: &HashMap, + cache: logical::ReadHandleCache, +) -> (ReadResponse, logical::ReadHandleCache) { let Some(device_path) = devices.get(&request.snapshot_lease_id) else { - return ReadResponse::Error { - code: ReaderErrorCode::LeaseInvalid, - message: format!( - "snapshot_lease_id {} is not one of this process's --device leases", - request.snapshot_lease_id - ), - }; + return ( + ReadResponse::Error { + code: ReaderErrorCode::LeaseInvalid, + message: format!( + "snapshot_lease_id {} is not one of this process's --device leases", + request.snapshot_lease_id + ), + }, + logical::ReadHandleCache::empty(), + ); }; match logical::read_logical( @@ -68,16 +80,23 @@ fn dispatch_request(request: &ReadRequest, devices: &HashMap) -> Re request.full_file_reference, request.logical_offset, request.maximum_logical_length, + cache, ) { - Ok((payload, actual_mode)) => ReadResponse::Bytes { - logical_offset: request.logical_offset, - actual_mode, - payload, - }, - Err(err) => ReadResponse::Error { - code: ReaderErrorCode::ReadIoTransient, - message: format!("{err:#}"), - }, + Ok((payload, actual_mode, updated_cache)) => ( + ReadResponse::Bytes { + logical_offset: request.logical_offset, + actual_mode, + payload, + }, + updated_cache, + ), + Err(err) => ( + ReadResponse::Error { + code: ReaderErrorCode::ReadIoTransient, + message: format!("{err:#}"), + }, + logical::ReadHandleCache::empty(), + ), } } diff --git a/crates/uffs-content-reader/src/reader/logical.rs b/crates/uffs-content-reader/src/reader/logical.rs index 2ecd6e98c..b8db88df4 100644 --- a/crates/uffs-content-reader/src/reader/logical.rs +++ b/crates/uffs-content-reader/src/reader/logical.rs @@ -11,6 +11,24 @@ //! ([`super::read_plan::read_plan`]), and reads real bytes via //! `ReadFile` at the requested offset. //! +//! # Handle caching across a connection's consecutive requests +//! +//! `open_file_by_id` is not cheap: `OpenFileById` has to resolve/validate +//! the target FRS against the MFT, and against a VSS snapshot's +//! copy-on-write device that cost varies a lot request to request. +//! Real-hardware benchmarking found this dominating read time on large +//! files far more than actual disk throughput did — a 2.76 GB file at +//! the old 64 KiB chunk size needed roughly 42,000 full open/close +//! cycles, one per chunk. [`ReadHandleCache`] lets [`read_logical`] reuse +//! the same open file handle across consecutive requests for the same +//! `full_file_reference`, opening fresh only when the target file +//! actually changes. The caller (`pipe_server`) owns one cache per +//! connection and threads it through every request on that connection — +//! this only pays off because the Coordinator now pins one connection +//! per candidate's whole sequential read (see +//! `uffs-content::job::reader_client`'s module doc) rather than +//! round-robining a chunk at a time across the pool. +//! //! # v1 simplifications (documented, not silent) //! //! - **VDL is treated as equal to EOF.** Getting the true NTFS valid data @@ -68,6 +86,51 @@ impl Drop for OwnedHandle { } } +#[expect( + unsafe_code, + reason = "windows file handles are thread-safe kernel objects, not thread-affine" +)] +// SAFETY: `OwnedHandle` owns a Windows `HANDLE` to a kernel-managed file +// object with no thread affinity and no unsynchronized interior +// mutability of its own — moving ownership to another thread (as +// `ReadHandleCache` does across a `spawn_blocking` boundary in +// `pipe_server`) does not invalidate any aliasing assumptions. Handle +// cleanup remains centralized in `Drop`, above. +unsafe impl Send for OwnedHandle {} + +/// A previously-opened file handle, cached across a connection's +/// consecutive requests — see this module's doc comment for why. +/// `pub(crate)` (rather than living entirely inside this module) because +/// `pipe_server` owns one instance per connection and threads it through +/// every request on that connection, across a `spawn_blocking` boundary. +#[derive(Default)] +pub(crate) struct ReadHandleCache(Option); + +impl ReadHandleCache { + /// A fresh cache holding nothing — one per new connection. + pub(crate) const fn empty() -> Self { + Self(None) + } +} + +/// One cached open file handle plus the file reference and `EOF` it was +/// opened/resolved for — [`read_logical`] reuses it only when a new +/// request's `full_file_reference` matches exactly. +struct CachedFileHandle { + /// The file reference this handle was opened against. + full_file_reference: u64, + /// The open handle itself. + handle: OwnedHandle, + /// `EOF` as resolved when this handle was opened. Deliberately not + /// re-queried on every cached-hit read: the file is being read + /// against a frozen VSS snapshot, not the live volume, so its size + /// cannot legitimately change for the life of that snapshot — a + /// changed `EOF` on a subsequent query would indicate something has + /// gone wrong (a corrupted snapshot, a bug), not a real update to + /// react to. + eof: u64, +} + /// Encode `text` as a NUL-terminated UTF-16 buffer for `PCWSTR` FFI calls. fn to_wide_null(text: &str) -> Vec { std::ffi::OsStr::new(text) @@ -187,26 +250,45 @@ fn read_exact(handle: &OwnedHandle, buf: &mut [u8]) -> anyhow::Result<()> { Ok(()) } -/// Perform one logical read: open by file reference, re-resolve `EOF`, -/// apply the VDL/EOF rule, and read the resulting real-byte range -/// (zero-extending per the plan). +/// Perform one logical read: reuse `cache`'s open handle if it's already +/// open against `full_file_reference` (see this module's doc comment), +/// else open fresh by file reference and re-resolve `EOF`; apply the +/// VDL/EOF rule, and read the resulting real-byte range (zero-extending +/// per the plan). +/// +/// Returns the (possibly newly-opened) handle back as an updated +/// [`ReadHandleCache`] for the caller to reuse on its next call — on +/// success this always holds `Some`, even when nothing changed, so the +/// caller never has to special-case "cache unchanged" against "cache +/// now empty". /// /// # Errors /// Returns an error if the device/file can't be opened, the read fails, /// or the resolved metadata is invalid (`vdl > eof` — never possible /// here since VDL is derived from EOF, but `read_plan`'s contract keeps -/// that check in one place regardless of caller). +/// that check in one place regardless of caller). The returned error +/// carries no cache — the caller must treat the connection's cache as +/// empty afterward, matching how a round-trip failure already discards +/// the connection itself one level up. pub(crate) fn read_logical( device_path: &str, full_file_reference: u64, logical_offset: u64, maximum_logical_length: u32, -) -> anyhow::Result<(Vec, ActualReadMode)> { - let volume_hint = open_volume_hint(device_path)?; - let file_handle = open_file_by_id(&volume_hint, full_file_reference)?; - drop(volume_hint); - - let eof = file_size(&file_handle)?; + cache: ReadHandleCache, +) -> anyhow::Result<(Vec, ActualReadMode, ReadHandleCache)> { + let (file_handle, eof) = match cache.0 { + Some(cached) if cached.full_file_reference == full_file_reference => { + (cached.handle, cached.eof) + } + _ => { + let volume_hint = open_volume_hint(device_path)?; + let file_handle = open_file_by_id(&volume_hint, full_file_reference)?; + drop(volume_hint); + let eof = file_size(&file_handle)?; + (file_handle, eof) + } + }; let vdl = eof; // v1 simplification — see module doc. let plan = read_plan(vdl, eof, logical_offset, maximum_logical_length) @@ -221,5 +303,11 @@ pub(crate) fn read_logical( } payload.resize(payload.len() + plan.zero_bytes as usize, 0); - Ok((payload, ActualReadMode::Logical)) + let updated_cache = ReadHandleCache(Some(CachedFileHandle { + full_file_reference, + handle: file_handle, + eof, + })); + + Ok((payload, ActualReadMode::Logical, updated_cache)) } diff --git a/crates/uffs-content-reader/src/reader/pipe_server.rs b/crates/uffs-content-reader/src/reader/pipe_server.rs index ddaf48607..79759ac63 100644 --- a/crates/uffs-content-reader/src/reader/pipe_server.rs +++ b/crates/uffs-content-reader/src/reader/pipe_server.rs @@ -90,12 +90,21 @@ async fn serve(devices: &HashMap) -> anyhow::Result<()> { /// [`read_one_request`]). Every request's blocking disk I/O runs via /// `spawn_blocking`, so a slow read on one connection never blocks any /// other connection. +/// +/// Owns this connection's [`super::logical::ReadHandleCache`] for the +/// connection's whole lifetime, starting empty and threading the +/// updated cache back out of every `dispatch_request_blocking` call — +/// see that type's own doc comment for why a per-connection cache is +/// safe and effective here (the Coordinator pins one connection per +/// candidate's whole sequential read). async fn serve_requests(server: &mut NamedPipeServer, devices: &Arc>) { + let mut cache = super::logical::ReadHandleCache::empty(); loop { match read_one_request(server).await { Ok(Some(request)) => { - if !respond_to_one_request(server, request, devices).await { - return; + match respond_to_one_request(server, request, devices, cache).await { + Some(updated_cache) => cache = updated_cache, + None => return, } } Ok(None) => { @@ -110,36 +119,47 @@ async fn serve_requests(server: &mut NamedPipeServer, devices: &Arc>, -) -> bool { - let response = dispatch_request_blocking(request, Arc::clone(devices)).await; + cache: super::logical::ReadHandleCache, +) -> Option { + let (response, updated_cache) = + dispatch_request_blocking(request, Arc::clone(devices), cache).await; if let Err(err) = write_one_response(server, &response).await { tracing::warn!(error = %err, "failed to write response; closing connection"); - return false; + return None; } - true + Some(updated_cache) } /// Run [`super::dispatch_request`]'s blocking disk I/O on tokio's /// blocking thread pool, turning a panic there into a -/// [`ReaderErrorCode::InternalError`] response rather than propagating -/// it (a single request's panic must not tear down the whole -/// connection, matching `dispatch_request`'s own never-panics contract). +/// [`ReaderErrorCode::InternalError`] response (with an emptied cache) +/// rather than propagating it (a single request's panic must not tear +/// down the whole connection, matching `dispatch_request`'s own +/// never-panics contract). async fn dispatch_request_blocking( request: ReadRequest, devices: Arc>, -) -> ReadResponse { - tokio::task::spawn_blocking(move || super::dispatch_request(&request, &devices)) + cache: super::logical::ReadHandleCache, +) -> (ReadResponse, super::logical::ReadHandleCache) { + tokio::task::spawn_blocking(move || super::dispatch_request(&request, &devices, cache)) .await - .unwrap_or_else(|join_err| ReadResponse::Error { - code: ReaderErrorCode::InternalError, - message: format!("read task panicked: {join_err}"), + .unwrap_or_else(|join_err| { + ( + ReadResponse::Error { + code: ReaderErrorCode::InternalError, + message: format!("read task panicked: {join_err}"), + }, + super::logical::ReadHandleCache::empty(), + ) }) } diff --git a/crates/uffs-content/src/job/content_source.rs b/crates/uffs-content/src/job/content_source.rs index 848e680f1..7374c46d8 100644 --- a/crates/uffs-content/src/job/content_source.rs +++ b/crates/uffs-content/src/job/content_source.rs @@ -10,9 +10,10 @@ use super::candidate_source::CandidateEntry; /// Reads a bounded range of a candidate's logical content. /// -/// The production implementation (not yet built — UFI.2) is -/// `uffs-content`'s IPC client to `uffs-content-reader`, which resolves -/// and reads against a VSS snapshot device, never the live volume. +/// The production implementation is `uffs-content`'s IPC client to +/// `uffs-content-reader` (`VssContentSource`, Windows-only — not in +/// scope on this platform's rustdoc build), which resolves and reads +/// against a VSS snapshot device, never the live volume. /// [`FsContentSource`] is a real, correct, but unprivileged stand-in: it /// reads the live file directly with `std::fs`. See /// [`super::candidate_source::CandidateSource`] for why that's the right @@ -22,31 +23,58 @@ use super::candidate_source::CandidateEntry; /// concurrently (one `std::thread::scope` thread each, sharing one /// `&dyn ContentSource` — see that module's "Concurrent reads, /// sequential emission" doc section), so any implementation must -/// tolerate concurrent `read_at` calls from different threads. +/// tolerate concurrent `begin_read` calls from different threads. The +/// [`ReadSession`] a single `begin_read` call produces is used from +/// exactly one thread for its whole lifetime, so it carries no such +/// bound itself. pub trait ContentSource: Sync { - /// Read up to `max_len` bytes starting at `offset` from `candidate`. + /// Begin a session for reading one candidate's *entire* content, + /// pinning whatever connection/handle state that read needs for the + /// session's whole lifetime rather than re-establishing it per + /// chunk. + /// + /// This exists because `VssContentSource`'s production + /// counterpart, `uffs-content-reader`, caches its open NTFS file + /// handle per connection across consecutive requests for the same + /// file (real-hardware benchmarking found the alternative — a fresh + /// `OpenFileById` on every chunk — dominates read time on large + /// files far more than actual disk throughput does). That cache + /// only helps if a candidate's chunks all land on the *same* + /// connection, which is exactly what pinning one session to one + /// connection for the read's whole duration guarantees. /// /// `candidate_id` is the same id `manifest_builder::build_manifest` /// assigned this candidate (the caller already has it — see /// `workflow::run_job`'s `entries.iter().zip(&built.candidate_ids)`) - /// — the production implementation needs it to correlate this read - /// against the finalized manifest over the Reader's wire protocol; - /// [`FsContentSource`] ignores it entirely. + /// — the production implementation needs it to correlate every read + /// in the session against the finalized manifest over the Reader's + /// wire protocol; [`FsContentSource`] ignores it entirely. + /// + /// # Errors + /// Propagates the underlying [`io::Error`] from whatever + /// establishing a session requires (e.g. opening the file, or + /// checking out a pooled connection). + fn begin_read( + &self, + candidate: &CandidateEntry, + candidate_id: u64, + ) -> io::Result>; +} + +/// One candidate's whole sequential read session, opened by +/// [`ContentSource::begin_read`]. Ends (releases whatever connection/ +/// handle it pinned) when dropped. +pub trait ReadSession { + /// Read up to `max_len` bytes starting at `offset`, continuing this + /// session. /// /// Returns fewer than `max_len` bytes only at EOF (matching a normal /// [`std::io::Read::read`] short-read contract at end of file); an /// empty result means `offset` was at or past EOF. /// /// # Errors - /// Propagates the underlying [`io::Error`] from opening/seeking/ - /// reading the file. - fn read_at( - &self, - candidate: &CandidateEntry, - candidate_id: u64, - offset: u64, - max_len: u32, - ) -> io::Result>; + /// Propagates the underlying [`io::Error`] from seeking/reading. + fn read_at(&mut self, offset: u64, max_len: u32) -> io::Result>; } /// Reads content directly from the live filesystem. @@ -54,22 +82,37 @@ pub trait ContentSource: Sync { pub struct FsContentSource; impl ContentSource for FsContentSource { - fn read_at( + fn begin_read( &self, candidate: &CandidateEntry, _candidate_id: u64, - offset: u64, - max_len: u32, - ) -> io::Result> { - let mut file = File::open(&candidate.absolute_path)?; - file.seek(SeekFrom::Start(offset))?; + ) -> io::Result> { + let file = File::open(&candidate.absolute_path)?; + Ok(Box::new(FsReadSession { file })) + } +} + +/// [`FsContentSource`]'s session: just the one already-open file, kept +/// open for the candidate's whole read instead of reopened per chunk — +/// mirrors `VssContentSource`'s real optimization even though a local +/// `std::fs::File` open is cheap enough that it wouldn't matter much on +/// its own; keeping the two implementations' shapes symmetric is the +/// point. +struct FsReadSession { + /// The candidate's already-open file handle. + file: File, +} + +impl ReadSession for FsReadSession { + fn read_at(&mut self, offset: u64, max_len: u32) -> io::Result> { + self.file.seek(SeekFrom::Start(offset))?; let capacity = usize::try_from(max_len).unwrap_or(usize::MAX); let mut buffer = vec![0_u8; capacity]; let mut total_read = 0_usize; while total_read < buffer.len() { let remaining = buffer.get_mut(total_read..).unwrap_or(&mut []); - let read = file.read(remaining)?; + let read = self.file.read(remaining)?; if read == 0 { break; } @@ -114,21 +157,19 @@ impl VssContentSource { #[cfg(windows)] impl ContentSource for VssContentSource { - fn read_at( + fn begin_read( &self, candidate: &CandidateEntry, candidate_id: u64, - offset: u64, - max_len: u32, - ) -> io::Result> { - self.reader - .read_at( + ) -> io::Result> { + let session = self + .reader + .begin_read( candidate.snapshot_lease_id, candidate_id, candidate.file_reference, - offset, - max_len, ) - .map_err(|err| io::Error::other(err.to_string())) + .map_err(|err| io::Error::other(err.to_string()))?; + Ok(Box::new(session)) } } diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index 45dddda28..23b2aeb20 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -19,9 +19,17 @@ //! on each other's pool regardless of size. //! //! Each pool is a bounded [`crossbeam_channel`] of already-open -//! connections: checking one out is a blocking `recv` (waits for a -//! connection to free up rather than erroring), and a connection that -//! survives its round trip unscathed is sent back for reuse. A +//! connections. A candidate's whole sequential read pins exactly one +//! connection for its entire duration — see [`ContentReader::begin_read`]/ +//! [`ReaderSession`] — rather than checking one out fresh per chunk: +//! real-hardware benchmarking found `uffs-content-reader` caches its +//! open NTFS file handle per connection across consecutive requests for +//! the same file (see that crate's `reader/logical.rs`), so consecutive +//! chunks of one candidate landing on *different* connections (which a +//! per-chunk checkout would do, round-robin) would defeat that cache +//! entirely. Checking out a connection is a blocking `recv` (waits for +//! one to free up rather than erroring); a connection that survives its +//! session unscathed is returned for reuse when the session drops. A //! connection that errors mid-round-trip (frame desync, pipe reset) is //! deliberately *not* returned — better to shrink that drive's pool by //! one than serve subsequent reads over a connection in an unknown @@ -32,6 +40,7 @@ //! (`[u32 LE length][payload]`) exactly — see that module's doc comment //! for the rationale. +use alloc::sync::Arc; use core::sync::atomic::{AtomicU64, Ordering}; use core::time::Duration; use std::collections::HashMap; @@ -47,6 +56,8 @@ use uffs_content_reader_protocol::{ StreamKind, VolumeIdentity, }; +use super::content_source::ReadSession; + /// How long to retry connecting to the freshly spawned Reader's pipe /// while it finishes binding it. const CONNECT_RETRY_BUDGET: Duration = Duration::from_secs(10); @@ -91,7 +102,9 @@ pub(crate) struct ContentReader { /// This job's id, echoed into every `ReadRequest`. job_id: [u8; 16], /// Monotonically increasing nonce for request/response correlation. - next_nonce: AtomicU64, + /// Shared (not per-session) via `Arc` so every session drawn from + /// every drive's pool still produces globally unique nonces. + next_nonce: Arc, } /// A bounded pool of already-open pipe connections for one drive. @@ -181,29 +194,121 @@ impl ContentReader { child, connections, job_id, - next_nonce: AtomicU64::new(1), + next_nonce: Arc::new(AtomicU64::new(1)), }) } - /// Read up to `maximum_logical_length` bytes at `logical_offset` - /// from the file identified by `full_file_reference`, scoped to - /// `snapshot_lease_id`. + /// Begin a session for reading one candidate's entire content, + /// checking out one of `snapshot_lease_id`'s pooled connections and + /// pinning it for the session's whole lifetime — see the module doc + /// comment for why pinning (rather than checking a connection out + /// fresh per chunk) matters. Blocks until a connection is available + /// if every connection in this drive's pool is currently checked + /// out. /// /// # Errors - /// Returns an error if the round trip fails or the Reader reports - /// failure. - pub(crate) fn read_at( + /// Returns an error if `snapshot_lease_id` has no pool, or every + /// connection in that pool has already failed and been dropped. + pub(crate) fn begin_read( &self, snapshot_lease_id: u64, candidate_id: u64, full_file_reference: u64, + ) -> Result { + let pool = self.connections.get(&snapshot_lease_id).ok_or_else(|| { + anyhow::anyhow!( + "no content reader connection pool for snapshot_lease_id {snapshot_lease_id}" + ) + })?; + let pipe = pool.checkout.recv().map_err(|err| { + anyhow::anyhow!( + "connection pool for lease {snapshot_lease_id} is exhausted \ + (every connection failed): {err}" + ) + })?; + Ok(ReaderSession { + pipe: Some(pipe), + checkin: pool.checkin.clone(), + job_id: self.job_id, + snapshot_lease_id, + candidate_id, + full_file_reference, + next_nonce: Arc::clone(&self.next_nonce), + }) + } + + /// Tear down this instance: kill the spawned process. The pipe + /// connection is closed when `self` drops. + /// + /// # Errors + /// Returns an error if the process couldn't be killed. + pub(crate) fn shutdown(mut self) -> Result<()> { + self.child + .kill() + .context("failed to kill content reader process")?; + drop(self.child.wait()); + Ok(()) + } +} + +impl Drop for ContentReader { + /// Best-effort safety net: if [`Self::shutdown`] was never called + /// explicitly, don't leak the child process. + fn drop(&mut self) { + drop(self.child.kill()); + } +} + +/// One candidate's whole sequential read session: a pinned pooled +/// connection plus the fields every one of that candidate's requests +/// shares — see [`ContentReader::begin_read`]'s doc comment and the +/// module doc comment for why pinning one connection for the whole +/// session (rather than checking one out fresh per chunk) matters. +/// +/// Checks the connection back in on drop if it's still framing-aligned +/// (`pipe` is `Some`); a connection that failed mid-round-trip is left +/// as `None` and simply not returned, shrinking that drive's pool by +/// one — same contract [`ContentReader`]'s old per-call `round_trip` +/// used to implement. +pub(crate) struct ReaderSession { + /// The pinned connection, or `None` once a round trip on it has + /// failed (see the struct doc comment). + pipe: Option, + /// Returns `pipe` to its pool's checkout queue on drop. + checkin: Sender, + /// This job's id, echoed into every `ReadRequest`. + job_id: [u8; 16], + /// This session's drive, echoed into every `ReadRequest`. + snapshot_lease_id: u64, + /// This session's candidate, echoed into every `ReadRequest`. + candidate_id: u64, + /// This session's file, echoed into every `ReadRequest`. + full_file_reference: u64, + /// Shared with every other live session (see + /// [`ContentReader::next_nonce`]'s own doc comment). + next_nonce: Arc, +} + +impl ReaderSession { + /// Send one framed [`ReadRequest`] for this session's candidate/file + /// and read back one framed [`ReadResponse`], over this session's + /// pinned connection. + fn round_trip( + &mut self, logical_offset: u64, maximum_logical_length: u32, - ) -> Result> { + ) -> Result { + let Some(pipe) = self.pipe.as_mut() else { + anyhow::bail!( + "read session for candidate {} already lost its connection to an earlier \ + round-trip failure", + self.candidate_id + ); + }; let request = ReadRequest { job_id: self.job_id, - snapshot_lease_id, - candidate_id, + snapshot_lease_id: self.snapshot_lease_id, + candidate_id: self.candidate_id, // Presently inert on the Reader side — v1's `OpenFileById` // locates the file by `full_file_reference` alone, no // volume cross-check. See @@ -212,7 +317,7 @@ impl ContentReader { volume_serial: 0, volume_guid: Vec::new(), }, - full_file_reference, + full_file_reference: self.full_file_reference, stream_kind: StreamKind::UnnamedData, logical_offset, maximum_logical_length, @@ -220,91 +325,66 @@ impl ContentReader { request_nonce: self.next_nonce.fetch_add(1, Ordering::Relaxed), }; - match self.round_trip(snapshot_lease_id, &request) { + let result = (|| -> Result { + write_framed_message(pipe, &request.encode())?; + let response_bytes = read_framed_message(pipe)?; + let mut wire_reader = WireReader::new(&response_bytes); + ReadResponse::decode(&mut wire_reader, MAX_RESPONSE_PAYLOAD_BYTES) + .map_err(|err| anyhow::anyhow!("malformed Reader response: {err}")) + })(); + if result.is_err() { + // Framing state is now unknown — see the struct doc comment + // for why this session's connection must not be reused + // again, by this or any future chunk. + self.pipe = None; + } + result + } +} + +impl ReadSession for ReaderSession { + fn read_at(&mut self, offset: u64, max_len: u32) -> std::io::Result> { + let snapshot_lease_id = self.snapshot_lease_id; + let candidate_id = self.candidate_id; + match self.round_trip(offset, max_len) { Ok(ReadResponse::Bytes { payload, .. }) => Ok(payload), Ok(ReadResponse::Error { code, message }) => { tracing::warn!( snapshot_lease_id, candidate_id, - logical_offset, + offset, ?code, message = %message, "content reader: read rejected" ); - anyhow::bail!("Reader rejected read: {code:?}: {message}") + Err(std::io::Error::other(format!( + "Reader rejected read: {code:?}: {message}" + ))) } Err(err) => { tracing::warn!( snapshot_lease_id, candidate_id, - logical_offset, + offset, error = %err, "content reader: round trip failed" ); - Err(err) + Err(std::io::Error::other(err.to_string())) } } } - - /// Send one framed [`ReadRequest`] and read back one framed - /// [`ReadResponse`], over one of `snapshot_lease_id`'s own pooled - /// connections — never contending with a different drive's pool. - /// Blocks until a connection is available if every connection in - /// this drive's pool is currently checked out. - fn round_trip(&self, snapshot_lease_id: u64, request: &ReadRequest) -> Result { - let pool = self.connections.get(&snapshot_lease_id).ok_or_else(|| { - anyhow::anyhow!( - "no content reader connection pool for snapshot_lease_id {snapshot_lease_id}" - ) - })?; - let mut pipe = pool.checkout.recv().map_err(|err| { - anyhow::anyhow!( - "connection pool for lease {snapshot_lease_id} is exhausted \ - (every connection failed): {err}" - ) - })?; - - let result = (|| -> Result { - write_framed_message(&mut pipe, &request.encode())?; - let response_bytes = read_framed_message(&mut pipe)?; - let mut wire_reader = WireReader::new(&response_bytes); - ReadResponse::decode(&mut wire_reader, MAX_RESPONSE_PAYLOAD_BYTES) - .map_err(|err| anyhow::anyhow!("malformed Reader response: {err}")) - })(); - - if result.is_ok() { - // Still framing-aligned — return it for reuse. Best-effort: - // the pool never holds more than its original connection - // count, so this can't actually overflow; `try_send` is - // just the non-panicking way to express "give it back", and - // a failure here just means one fewer pooled connection. - drop(pool.checkin.try_send(pipe)); - } - // On error, `pipe` is dropped here instead of returned — see - // the module doc comment for why a connection that failed - // mid-round-trip must not be reused. - result - } - - /// Tear down this instance: kill the spawned process. The pipe - /// connection is closed when `self` drops. - /// - /// # Errors - /// Returns an error if the process couldn't be killed. - pub(crate) fn shutdown(mut self) -> Result<()> { - self.child - .kill() - .context("failed to kill content reader process")?; - drop(self.child.wait()); - Ok(()) - } } -impl Drop for ContentReader { - /// Best-effort safety net: if [`Self::shutdown`] was never called - /// explicitly, don't leak the child process. +impl Drop for ReaderSession { fn drop(&mut self) { - drop(self.child.kill()); + if let Some(pipe) = self.pipe.take() { + // Best-effort, matching the pool's own established contract + // (see the module doc comment): this can't actually + // overflow since the pool never holds more than its + // original connection count, and a failure here just means + // one fewer pooled connection. + drop(self.checkin.try_send(pipe)); + } } } diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 045317e21..92c7936b8 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -76,18 +76,18 @@ fn fs_content_source_reads_bounded_ranges_and_reports_eof() { .expect("enumerate must succeed"); let entry = entries.first().expect("one entry expected"); - let first_half = FsContentSource - .read_at(entry, 0, 0, 5) - .expect("read first half"); + let mut session = FsContentSource + .begin_read(entry, 0) + .expect("begin_read must succeed"); + + let first_half = session.read_at(0, 5).expect("read first half"); assert_eq!(first_half, b"01234"); - let second_half = FsContentSource - .read_at(entry, 0, 5, 5) - .expect("read second half"); + let second_half = session.read_at(5, 5).expect("read second half"); assert_eq!(second_half, b"56789"); - let past_eof = FsContentSource - .read_at(entry, 0, 10, 5) + let past_eof = session + .read_at(10, 5) .expect("read past EOF must not error"); assert!(past_eof.is_empty(), "read at EOF must return no bytes"); } diff --git a/crates/uffs-content/src/job/workflow/pipeline.rs b/crates/uffs-content/src/job/workflow/pipeline.rs index 7ab7e6a19..114cfb2ff 100644 --- a/crates/uffs-content/src/job/workflow/pipeline.rs +++ b/crates/uffs-content/src/job/workflow/pipeline.rs @@ -374,6 +374,25 @@ fn read_one_candidate( } log_candidate_size_if_notable(entry, candidate_id); + let mut session = match content_source.begin_read(entry, candidate_id) { + Ok(session) => session, + Err(err) => { + tracing::warn!( + candidate_id, + path = %entry.relative_path.display(), + error = %err, + "content read: failed to begin read session" + ); + return CandidateContent { + chunks: Vec::new(), + total_read: 0, + digest: IncrementalDigest::new().finalize(), + read_error: Some(err), + read_mode: ReadMode::LogicalSnapshot, + }; + } + }; + let mut hasher = IncrementalDigest::new(); let mut offset = 0_u64; let mut chunk_sequence = 0_u64; @@ -391,7 +410,7 @@ fn read_one_candidate( read_started_at, &mut last_stall_warning_at, ); - match content_source.read_at(entry, candidate_id, offset, max_chunk_bytes) { + match session.read_at(offset, max_chunk_bytes) { Ok(bytes) if bytes.is_empty() => break, Ok(bytes) => { let read_len = super::len_as_u64(bytes.len()); @@ -420,6 +439,8 @@ fn read_one_candidate( } } } + // `session` drops here, returning its pinned connection (if still + // framing-aligned) to the pool. CandidateContent { chunks, From 43b7beacfbc8f9678af6062421f8a77e0eb72369 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:00:46 -0700 Subject: [PATCH 78/98] fix(client): carry file_reference through the shmem search-result channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real-hardware full-system content-read scans showed ~40% of candidates on one drive failing OpenFileById with ERROR_INVALID_PARAMETER, all sharing file_reference == 0. Bisected against a plain ad-hoc CLI search for one of the affected files (small result set, delivered inline) which reported the correct nonzero file_reference — proving the MFT parse and compact-index build were never at fault. Root cause: uffs-client's shmem transport (used once a search's result set crosses SHMEM_THRESHOLD, which any full multi-drive scan does) never carried file_reference in its compact ShmemRecord — the reader hardcoded 0 under the assumption that no shmem-path consumer needed it. That assumption predates uffs-content, which now needs file_reference on every candidate to OpenFileById against a VSS snapshot. 0 is never a valid NTFS file reference (it's the reserved $MFT record), so the reader correctly rejected every one of these reads. Add file_reference to ShmemRecord (88 -> 96 bytes, format version 3 -> 4), populate it on write and consume it on read instead of hardcoding 0, and change the round-trip test's fixture to a nonzero value so a regression here is actually caught. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-client/src/shmem.rs | 37 +++++++++++++++++++++------ crates/uffs-client/src/shmem_tests.rs | 11 +++++++- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/uffs-client/src/shmem.rs b/crates/uffs-client/src/shmem.rs index 5bd43abd1..e1f78dddd 100644 --- a/crates/uffs-client/src/shmem.rs +++ b/crates/uffs-client/src/shmem.rs @@ -12,7 +12,7 @@ //! //! ```text //! [ShmemHeader: 48 bytes] -//! [ShmemRecord × row_count: 80 bytes each] +//! [ShmemRecord × row_count: 96 bytes each] //! [String table: concatenated UTF-8 bytes] //! ``` //! @@ -63,7 +63,20 @@ const MAGIC: u32 = 0x5346_4655; // b"UFFS" LE /// the bump guards against an old reader interpreting stale padding as flags. /// Shmem blobs are transient per-query temp files, so no migration is needed — /// a stale blob is simply rejected and regenerated. -const VERSION: u32 = 3; +/// +/// v4: adds a `file_reference` field (88 → 96 bytes). Real hardware found +/// that a full multi-drive content-read job (`uffs-content`), whose search +/// results routinely exceed [`SHMEM_THRESHOLD`], got `file_reference: 0` +/// back for every shmem-delivered row — the field simply wasn't in the +/// compact record, so the reader hardcoded it to `0` under the assumption +/// (correct at the time, wrong now) that no shmem-path consumer needed it. +/// `0` is never a valid NTFS file reference (it's the reserved `$MFT` +/// record), so every one of those candidates then failed `OpenFileById` +/// with `ERROR_INVALID_PARAMETER` on the reader side. Small result sets +/// (e.g. an ad-hoc single-filename search) stay under the threshold and +/// are delivered inline instead, which is why this only ever showed up on +/// large scans. +const VERSION: u32 = 4; // ── On-disk structures ──────────────────────────────────────────────────── @@ -89,7 +102,7 @@ struct ShmemHeader { _reserved: u32, } -/// Per-row fixed-size record — 88 bytes, naturally aligned. +/// Per-row fixed-size record — 96 bytes, naturally aligned. #[repr(C)] #[derive(Clone, Copy, Debug)] pub(crate) struct ShmemRecord { @@ -115,6 +128,11 @@ pub(crate) struct ShmemRecord { created: i64, /// Last-access timestamp (Unix µs). accessed: i64, + /// Packed NTFS file reference (FRS + sequence number) — see + /// [`VERSION`]'s v4 note for why this is carried: content-read jobs + /// (`uffs-content`) need it to `OpenFileById` against a VSS snapshot, + /// and their result sets routinely go through this shmem path. + file_reference: u64, /// Descendant count (dirs only). descendants: u32, /// Padding. @@ -139,8 +157,8 @@ const _: () = assert!( "ShmemHeader layout changed — binary format requires exactly 48 bytes" ); const _: () = assert!( - size_of::() == 88, - "ShmemRecord layout changed — binary format requires exactly 88 bytes" + size_of::() == 96, + "ShmemRecord layout changed — binary format requires exactly 96 bytes" ); // ── Public API ──────────────────────────────────────────────────────────── @@ -226,6 +244,7 @@ pub fn write_search_results( modified: row.modified, created: row.created, accessed: row.accessed, + file_reference: row.file_reference, descendants: row.descendants, _pad2: 0, treesize: row.treesize, @@ -409,10 +428,12 @@ pub fn read_search_results(path: &Path) -> io::Result { // rare field; it is served via the JSON projection path instead. malformed: rec.malformed != 0, malformed_path: rec.malformed_path != 0, + // Not carried by the compact shmem record: adding a + // variable-length hex region would cost every row, for a + // vanishingly rare field. Served via the JSON projection + // path instead. name_hex: None, - // Not carried by the compact shmem record, same rationale as - // `name_hex` above — no shmem-path consumer needs it today. - file_reference: 0, + file_reference: rec.file_reference, }); } diff --git a/crates/uffs-client/src/shmem_tests.rs b/crates/uffs-client/src/shmem_tests.rs index f2f6d03c9..e3cbafd2d 100644 --- a/crates/uffs-client/src/shmem_tests.rs +++ b/crates/uffs-client/src/shmem_tests.rs @@ -81,7 +81,12 @@ fn sample_row(name: &str) -> SearchRow { malformed: false, malformed_path: false, name_hex: None, - file_reference: 0, + // Nonzero on purpose: 0 is never a valid NTFS file reference (it's + // the reserved `$MFT` record), so a round-trip test that used 0 + // here would not have caught the v3-era bug where the shmem + // reader hardcoded `file_reference: 0` regardless of what was + // written (see `VERSION`'s v4 doc note in `shmem.rs`). + file_reference: 0x0002_0000_0000_2AF8, } } @@ -101,6 +106,10 @@ fn shmem_round_trip_deletes_file() { let second = round_tripped.get(1).expect("expected at least 2 rows"); assert_eq!(first.name, "a.txt"); assert_eq!(second.name, "b.txt"); + assert_eq!( + first.file_reference, 0x0002_0000_0000_2AF8, + "file_reference must round-trip through shmem, not come back as 0" + ); // The file must be gone now. assert!( From d6baf902bfff0fe7b14a27679037a2599de61bd7 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:52:08 -0700 Subject: [PATCH 79/98] chore(diag): add FRS-vs-physical-layout correlation checker Step 1 of validating the "reads candidates in ascending FRS order == near-sequential physical disk access" assumption for uffs-content's read scheduling, before investing in an FRS-sort read-order change. Takes uffs --format json output (path + file_reference), samples a subset, and cross-references each file's on-disk starting LCN via `fsutil file queryextents` to report the Spearman rank correlation between FRS order and physical order. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.ps1 | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/windows/check_frs_vs_lcn.ps1 diff --git a/scripts/windows/check_frs_vs_lcn.ps1 b/scripts/windows/check_frs_vs_lcn.ps1 new file mode 100644 index 000000000..7d554804d --- /dev/null +++ b/scripts/windows/check_frs_vs_lcn.ps1 @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025-2026 SKY, LLC. +# +# Checks whether ascending NTFS file-reference (FRS) order correlates +# with ascending on-disk physical location, for a sample of files. +# +# Step 1 of the "does reading candidates in ascending FRS order actually +# give us near-sequential physical disk access" investigation. +# +# Reads `uffs --format json` output (path + file_reference per row), +# samples a subset, and runs `fsutil file queryextents` on each sampled +# file to find its first extent's starting LCN (logical cluster number +# -- i.e. where it actually sits on the volume). Reports the Spearman +# rank correlation between FRS order and LCN order: a strong positive +# correlation means ascending-FRS read order is a good proxy for +# physical order (sorting reads by FRS should meaningfully cut seeks); +# a weak/no correlation means it won't help -- the files are physically +# scattered independent of allocation order. +# +# Usage: +# uffs.exe "*.txt" --drive D --format json > d_files.jsonl +# .\check_frs_vs_lcn.ps1 -JsonPath d_files.jsonl -SampleSize 500 +# +# Parameters: +# -JsonPath Path to `uffs --format json` output, one JSON object per line. +# -SampleSize How many files to sample (querying extents on hundreds of +# thousands of files would take far too long; a few hundred +# is enough for a reliable Spearman estimate). Default 500. +param( + [Parameter(Mandatory = $true)] + [string]$JsonPath, + + [int]$SampleSize = 500 +) + +function Get-Frs { + param([UInt64]$FileReference) + # Low 48 bits are the FRS (MFT record number); high 16 bits are the + # sequence number (slot-reuse generation) -- mirrors + # CompactRecord::pack_file_reference in uffs-core. + return $FileReference -band 0x0000FFFFFFFFFFFF +} + +function Get-FirstExtentLcn { + param([string]$Path) + $output = & fsutil file queryextents "$Path" 2>&1 + if ($LASTEXITCODE -ne 0) { + return $null + } + foreach ($line in $output) { + # fsutil's exact wording/case/hex-vs-decimal has drifted across + # Windows versions, so match loosely: the word "Lcn" (any case), + # optional colon/space, then either a 0x-hex or plain decimal run. + if ($line -match '(?i)Lcn\s*:?\s*(0x[0-9A-Fa-f]+|\d+)') { + $raw = $matches[1] + if ($raw.StartsWith('0x')) { + return [Convert]::ToUInt64($raw.Substring(2), 16) + } + return [UInt64]$raw + } + } + return $null +} + +Write-Host "Reading $JsonPath ..." +$rows = Get-Content $JsonPath | ForEach-Object { + try { $_ | ConvertFrom-Json } catch { $null } +} | Where-Object { $null -ne $_ -and $_.file_reference -and [UInt64]$_.file_reference -ne 0 } + +Write-Host "Loaded $($rows.Count) rows with a nonzero file_reference." + +if ($rows.Count -eq 0) { + Write-Error "No usable rows -- check that JsonPath came from 'uffs ... --format json' (needs the file_reference field)." + exit 1 +} + +$sample = $rows | Get-Random -Count ([Math]::Min($SampleSize, $rows.Count)) +Write-Host "Sampling $($sample.Count) files; querying extents (this hits the filesystem once per file)..." + +$results = @() +$unresolved = 0 +$i = 0 +foreach ($row in $sample) { + $i++ + if ($i % 50 -eq 0) { Write-Host " ... $i / $($sample.Count)" } + + $frs = $null + try { $frs = Get-Frs -FileReference ([UInt64]$row.file_reference) } catch { } + if ($null -eq $frs) { $unresolved++; continue } + + $lcn = Get-FirstExtentLcn -Path $row.path + if ($null -eq $lcn) { $unresolved++; continue } + + $results += [PSCustomObject]@{ Path = $row.path; Frs = $frs; Lcn = $lcn } +} + +Write-Host "" +Write-Host "Got extents for $($results.Count) / $($sample.Count) sampled files ($unresolved unresolved -- deleted/locked/no-extent files are skipped)." + +if ($results.Count -lt 10) { + Write-Error "Too few resolvable extents to compute a meaningful correlation." + exit 1 +} + +# Spearman correlation: rank both columns independently, then Pearson- +# correlate the ranks via the standard tied-rank-free shortcut formula +# (valid when ranks are a permutation of 1..n, i.e. no duplicate FRS/LCN +# collisions -- close enough for this diagnostic sample size). +$byFrs = $results | Sort-Object Frs +$frsRank = @{} +for ($r = 0; $r -lt $byFrs.Count; $r++) { $frsRank[$byFrs[$r].Path] = $r } + +$byLcn = $results | Sort-Object Lcn +$lcnRank = @{} +for ($r = 0; $r -lt $byLcn.Count; $r++) { $lcnRank[$byLcn[$r].Path] = $r } + +$n = $results.Count +$sumDSq = 0 +foreach ($row in $results) { + $d = $frsRank[$row.Path] - $lcnRank[$row.Path] + $sumDSq += $d * $d +} +$spearman = 1 - (6 * $sumDSq) / [double]($n * ($n * $n - 1)) + +Write-Host "" +Write-Host "=== Result ===" +Write-Host "Sampled files with resolvable extents: $n" +Write-Host ("Spearman correlation (FRS order vs. physical LCN order): {0:N3}" -f $spearman) +Write-Host "" +if ($spearman -gt 0.7) { + Write-Host "Strong positive correlation -- ascending FRS order is a good proxy for physical order on this volume. Sorting reads by FRS should meaningfully reduce seeks." +} elseif ($spearman -gt 0.3) { + Write-Host "Weak-to-moderate correlation -- FRS-sorted reads might help somewhat but won't eliminate seeking; this volume has likely been reorganized/fragmented since these files were created." +} else { + Write-Host "Little to no correlation -- FRS order will NOT meaningfully help; the files are physically scattered independent of allocation order (heavy fragmentation, moves, or FRS-slot reuse)." +} + +$outCsv = Join-Path (Split-Path $JsonPath -Parent) "frs_vs_lcn_sample.csv" +$results | Export-Csv -Path $outCsv -NoTypeInformation +Write-Host "" +Write-Host "Full sample written to $outCsv for inspection/plotting." From afc9736883423a36fa9bf1eb8aa3f72bd4cd2aec Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:00:59 -0700 Subject: [PATCH 80/98] chore(diag): rewrite FRS-vs-LCN checker as rust-script, not PowerShell Same diagnostic as the previous check_frs_vs_lcn.ps1 (sample uffs --format json rows, cross-reference each file's on-disk LCN via fsutil file queryextents, report the Spearman correlation between FRS order and physical order) -- rewritten in Rust via rust-script per project preference over PowerShell tooling. Also fixes a bug in the original: the CSV output path construction failed when json_path was a bare filename with no directory component (Join-Path on an empty parent). Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.ps1 | 141 --------------- scripts/windows/check_frs_vs_lcn.rs | 261 +++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 141 deletions(-) delete mode 100644 scripts/windows/check_frs_vs_lcn.ps1 create mode 100644 scripts/windows/check_frs_vs_lcn.rs diff --git a/scripts/windows/check_frs_vs_lcn.ps1 b/scripts/windows/check_frs_vs_lcn.ps1 deleted file mode 100644 index 7d554804d..000000000 --- a/scripts/windows/check_frs_vs_lcn.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2025-2026 SKY, LLC. -# -# Checks whether ascending NTFS file-reference (FRS) order correlates -# with ascending on-disk physical location, for a sample of files. -# -# Step 1 of the "does reading candidates in ascending FRS order actually -# give us near-sequential physical disk access" investigation. -# -# Reads `uffs --format json` output (path + file_reference per row), -# samples a subset, and runs `fsutil file queryextents` on each sampled -# file to find its first extent's starting LCN (logical cluster number -# -- i.e. where it actually sits on the volume). Reports the Spearman -# rank correlation between FRS order and LCN order: a strong positive -# correlation means ascending-FRS read order is a good proxy for -# physical order (sorting reads by FRS should meaningfully cut seeks); -# a weak/no correlation means it won't help -- the files are physically -# scattered independent of allocation order. -# -# Usage: -# uffs.exe "*.txt" --drive D --format json > d_files.jsonl -# .\check_frs_vs_lcn.ps1 -JsonPath d_files.jsonl -SampleSize 500 -# -# Parameters: -# -JsonPath Path to `uffs --format json` output, one JSON object per line. -# -SampleSize How many files to sample (querying extents on hundreds of -# thousands of files would take far too long; a few hundred -# is enough for a reliable Spearman estimate). Default 500. -param( - [Parameter(Mandatory = $true)] - [string]$JsonPath, - - [int]$SampleSize = 500 -) - -function Get-Frs { - param([UInt64]$FileReference) - # Low 48 bits are the FRS (MFT record number); high 16 bits are the - # sequence number (slot-reuse generation) -- mirrors - # CompactRecord::pack_file_reference in uffs-core. - return $FileReference -band 0x0000FFFFFFFFFFFF -} - -function Get-FirstExtentLcn { - param([string]$Path) - $output = & fsutil file queryextents "$Path" 2>&1 - if ($LASTEXITCODE -ne 0) { - return $null - } - foreach ($line in $output) { - # fsutil's exact wording/case/hex-vs-decimal has drifted across - # Windows versions, so match loosely: the word "Lcn" (any case), - # optional colon/space, then either a 0x-hex or plain decimal run. - if ($line -match '(?i)Lcn\s*:?\s*(0x[0-9A-Fa-f]+|\d+)') { - $raw = $matches[1] - if ($raw.StartsWith('0x')) { - return [Convert]::ToUInt64($raw.Substring(2), 16) - } - return [UInt64]$raw - } - } - return $null -} - -Write-Host "Reading $JsonPath ..." -$rows = Get-Content $JsonPath | ForEach-Object { - try { $_ | ConvertFrom-Json } catch { $null } -} | Where-Object { $null -ne $_ -and $_.file_reference -and [UInt64]$_.file_reference -ne 0 } - -Write-Host "Loaded $($rows.Count) rows with a nonzero file_reference." - -if ($rows.Count -eq 0) { - Write-Error "No usable rows -- check that JsonPath came from 'uffs ... --format json' (needs the file_reference field)." - exit 1 -} - -$sample = $rows | Get-Random -Count ([Math]::Min($SampleSize, $rows.Count)) -Write-Host "Sampling $($sample.Count) files; querying extents (this hits the filesystem once per file)..." - -$results = @() -$unresolved = 0 -$i = 0 -foreach ($row in $sample) { - $i++ - if ($i % 50 -eq 0) { Write-Host " ... $i / $($sample.Count)" } - - $frs = $null - try { $frs = Get-Frs -FileReference ([UInt64]$row.file_reference) } catch { } - if ($null -eq $frs) { $unresolved++; continue } - - $lcn = Get-FirstExtentLcn -Path $row.path - if ($null -eq $lcn) { $unresolved++; continue } - - $results += [PSCustomObject]@{ Path = $row.path; Frs = $frs; Lcn = $lcn } -} - -Write-Host "" -Write-Host "Got extents for $($results.Count) / $($sample.Count) sampled files ($unresolved unresolved -- deleted/locked/no-extent files are skipped)." - -if ($results.Count -lt 10) { - Write-Error "Too few resolvable extents to compute a meaningful correlation." - exit 1 -} - -# Spearman correlation: rank both columns independently, then Pearson- -# correlate the ranks via the standard tied-rank-free shortcut formula -# (valid when ranks are a permutation of 1..n, i.e. no duplicate FRS/LCN -# collisions -- close enough for this diagnostic sample size). -$byFrs = $results | Sort-Object Frs -$frsRank = @{} -for ($r = 0; $r -lt $byFrs.Count; $r++) { $frsRank[$byFrs[$r].Path] = $r } - -$byLcn = $results | Sort-Object Lcn -$lcnRank = @{} -for ($r = 0; $r -lt $byLcn.Count; $r++) { $lcnRank[$byLcn[$r].Path] = $r } - -$n = $results.Count -$sumDSq = 0 -foreach ($row in $results) { - $d = $frsRank[$row.Path] - $lcnRank[$row.Path] - $sumDSq += $d * $d -} -$spearman = 1 - (6 * $sumDSq) / [double]($n * ($n * $n - 1)) - -Write-Host "" -Write-Host "=== Result ===" -Write-Host "Sampled files with resolvable extents: $n" -Write-Host ("Spearman correlation (FRS order vs. physical LCN order): {0:N3}" -f $spearman) -Write-Host "" -if ($spearman -gt 0.7) { - Write-Host "Strong positive correlation -- ascending FRS order is a good proxy for physical order on this volume. Sorting reads by FRS should meaningfully reduce seeks." -} elseif ($spearman -gt 0.3) { - Write-Host "Weak-to-moderate correlation -- FRS-sorted reads might help somewhat but won't eliminate seeking; this volume has likely been reorganized/fragmented since these files were created." -} else { - Write-Host "Little to no correlation -- FRS order will NOT meaningfully help; the files are physically scattered independent of allocation order (heavy fragmentation, moves, or FRS-slot reuse)." -} - -$outCsv = Join-Path (Split-Path $JsonPath -Parent) "frs_vs_lcn_sample.csv" -$results | Export-Csv -Path $outCsv -NoTypeInformation -Write-Host "" -Write-Host "Full sample written to $outCsv for inspection/plotting." diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs new file mode 100644 index 000000000..51b9f7fc7 --- /dev/null +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -0,0 +1,261 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [dependencies] +//! serde_json = "1.0" +//! rand = "0.8" +//! ``` +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Checks whether ascending NTFS file-reference (FRS) order correlates +//! with ascending on-disk physical location, for a sample of files. +//! +//! Step 1 of the "does reading candidates in ascending FRS order actually +//! give us near-sequential physical disk access" investigation. Rust +//! replacement for the original `check_frs_vs_lcn.ps1` (same logic). +//! +//! Reads `uffs --format json` output (path + file_reference per line), +//! samples a subset, and runs `fsutil file queryextents` on each sampled +//! file to find its first extent's starting LCN (logical cluster number +//! -- i.e. where it actually sits on the volume). Reports the Spearman +//! rank correlation between FRS order and LCN order: a strong positive +//! correlation means ascending-FRS read order is a good proxy for +//! physical order (sorting reads by FRS should meaningfully cut seeks); +//! a weak/no correlation means it won't help -- the files are physically +//! scattered independent of allocation order. +//! +//! # Usage +//! ```text +//! uffs.exe "*.txt" --drive D --format json > d_files.jsonl +//! rust-script scripts/windows/check_frs_vs_lcn.rs d_files.jsonl [sample_size] +//! ``` + +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; +use std::{env, fs}; + +use rand::seq::SliceRandom; + +/// Low 48 bits are the FRS (MFT record number); high 16 bits are the +/// sequence number (slot-reuse generation) -- mirrors +/// `CompactRecord::pack_file_reference` in `uffs-core`. +const FRS_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; + +struct Sample { + path: String, + frs: u64, +} + +struct Resolved { + path: String, + frs: u64, + lcn: u64, +} + +fn main() { + let args: Vec = env::args().collect(); + let Some(json_path) = args.get(1) else { + eprintln!( + "usage: check_frs_vs_lcn.rs [sample_size=500]\n\ + \n\ + json_path must be `uffs --format json` output (one JSON object per line, \ + with a `path` and nonzero `file_reference` field)." + ); + std::process::exit(2); + }; + let sample_size: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(500); + + println!("Reading {json_path} ..."); + let content = fs::read_to_string(json_path).unwrap_or_else(|err| { + eprintln!("failed to read {json_path}: {err}"); + std::process::exit(1); + }); + + let rows: Vec = content + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter_map(|value| { + let path = value.get("path")?.as_str()?.to_owned(); + let file_reference = value.get("file_reference")?.as_u64()?; + if file_reference == 0 { + return None; + } + Some(Sample { + path, + frs: file_reference & FRS_MASK, + }) + }) + .collect(); + + println!("Loaded {} rows with a nonzero file_reference.", rows.len()); + if rows.is_empty() { + eprintln!( + "No usable rows -- check that json_path came from 'uffs ... --format json' \ + (needs the file_reference field)." + ); + std::process::exit(1); + } + + let mut rng = rand::thread_rng(); + let take = sample_size.min(rows.len()); + let mut indices: Vec = (0..rows.len()).collect(); + indices.shuffle(&mut rng); + indices.truncate(take); + + println!("Sampling {take} files; querying extents (this hits the filesystem once per file)..."); + + let mut resolved = Vec::with_capacity(take); + let mut unresolved = 0_usize; + for (i, &idx) in indices.iter().enumerate() { + if (i + 1) % 50 == 0 { + println!(" ... {} / {take}", i + 1); + } + let row = &rows[idx]; + match query_first_lcn(&row.path) { + Some(lcn) => resolved.push(Resolved { + path: row.path.clone(), + frs: row.frs, + lcn, + }), + None => unresolved += 1, + } + } + + println!(); + println!( + "Got extents for {} / {take} sampled files ({unresolved} unresolved -- deleted/locked/\ + resident-no-extent files are skipped).", + resolved.len() + ); + + if resolved.len() < 10 { + eprintln!("Too few resolvable extents to compute a meaningful correlation."); + std::process::exit(1); + } + + let spearman = spearman_correlation(&resolved); + + println!(); + println!("=== Result ==="); + println!("Sampled files with resolvable extents: {}", resolved.len()); + println!("Spearman correlation (FRS order vs. physical LCN order): {spearman:.3}"); + println!(); + if spearman > 0.7 { + println!( + "Strong positive correlation -- ascending FRS order is a good proxy for physical \ + order on this volume. Sorting reads by FRS should meaningfully reduce seeks." + ); + } else if spearman > 0.3 { + println!( + "Weak-to-moderate correlation -- FRS-sorted reads might help somewhat but won't \ + eliminate seeking; this volume has likely been reorganized/fragmented since these \ + files were created." + ); + } else { + println!( + "Little to no correlation -- FRS order will NOT meaningfully help; the files are \ + physically scattered independent of allocation order (heavy fragmentation, moves, \ + or FRS-slot reuse)." + ); + } + + let out_csv = default_output_csv(json_path); + write_csv(&out_csv, &resolved); + println!(); + println!( + "Full sample written to {} for inspection/plotting.", + out_csv.display() + ); +} + +/// Run `fsutil file queryextents` and pull out the first `Lcn` value it +/// prints -- tolerant of hex (`0x...`) or decimal, and of the label's +/// exact wording/case, since this has drifted across Windows versions. +fn query_first_lcn(path: &str) -> Option { + let output = Command::new("fsutil") + .args(["file", "queryextents", path]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let lower = line.to_ascii_lowercase(); + if let Some(idx) = lower.find("lcn") { + let after = line.get(idx + 3..)?; + let after = after.trim_start_matches([':', ' ', '\t']); + if let Some(hex) = after + .strip_prefix("0x") + .or_else(|| after.strip_prefix("0X")) + { + let digits: String = hex.chars().take_while(char::is_ascii_hexdigit).collect(); + if let Ok(value) = u64::from_str_radix(&digits, 16) { + return Some(value); + } + } else { + let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(value) = digits.parse::() { + return Some(value); + } + } + } + } + None +} + +/// Spearman rank correlation between FRS order and LCN order across +/// `resolved` -- ranks both columns independently, then Pearson- +/// correlates the ranks via the standard tied-rank-free shortcut +/// formula (valid when ranks are a permutation of `0..n`, i.e. no +/// duplicate FRS/LCN collisions -- close enough for this sample size). +fn spearman_correlation(resolved: &[Resolved]) -> f64 { + let mut by_frs: Vec = (0..resolved.len()).collect(); + by_frs.sort_by_key(|&i| resolved[i].frs); + let mut frs_rank: HashMap = HashMap::new(); + for (rank, &i) in by_frs.iter().enumerate() { + frs_rank.insert(i, rank); + } + + let mut by_lcn: Vec = (0..resolved.len()).collect(); + by_lcn.sort_by_key(|&i| resolved[i].lcn); + let mut lcn_rank: HashMap = HashMap::new(); + for (rank, &i) in by_lcn.iter().enumerate() { + lcn_rank.insert(i, rank); + } + + let n = resolved.len() as f64; + let sum_d_sq: f64 = (0..resolved.len()) + .map(|i| { + let d = frs_rank[&i] as f64 - lcn_rank[&i] as f64; + d * d + }) + .sum(); + + 1.0 - (6.0 * sum_d_sq) / (n * (n * n - 1.0)) +} + +/// Default CSV output path: alongside `json_path`, falling back to the +/// current directory when `json_path` has no parent component (e.g. a +/// bare filename like `d_files.jsonl`). +fn default_output_csv(json_path: &str) -> std::path::PathBuf { + let parent = Path::new(json_path) + .parent() + .filter(|p| !p.as_os_str().is_empty()); + parent + .unwrap_or_else(|| Path::new(".")) + .join("frs_vs_lcn_sample.csv") +} + +fn write_csv(path: &Path, resolved: &[Resolved]) { + let mut out = String::from("Path,Frs,Lcn\n"); + for row in resolved { + // Paths can contain commas/quotes -- quote and escape per RFC 4180. + let escaped_path = row.path.replace('"', "\"\""); + out.push_str(&format!("\"{escaped_path}\",{},{}\n", row.frs, row.lcn)); + } + if let Err(err) = fs::write(path, out) { + eprintln!("failed to write {}: {err}", path.display()); + } +} From 8f53501c2f7a4248502ea0ad80d7b2db5fa12c3f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:12:38 -0700 Subject: [PATCH 81/98] chore(diag): compare natural/FRS/oracle-LCN seek distance in FRS checker Extends check_frs_vs_lcn.rs beyond a single correlation coefficient: now also reports the total seek distance (sum of |delta LCN| touring the sample once) under three orderings -- the search response's natural order (what the read pipeline processes today), ascending-FRS order, and the oracle (true ascending-LCN order, the unbeatable lower bound). This turns "FRS correlates weakly/moderately with LCN" into a concrete, actionable number: what fraction of the achievable seek-distance reduction does cheap FRS-sorting actually capture, versus how much is only reachable by a real LCN-resolution pass. Also converts to MiB of head travel via `fsutil fsinfo ntfsinfo` when available. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.rs | 158 ++++++++++++++++++++++++++-- 1 file changed, 148 insertions(+), 10 deletions(-) diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs index 51b9f7fc7..5d042cc05 100644 --- a/scripts/windows/check_frs_vs_lcn.rs +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -8,21 +8,31 @@ // Copyright (c) 2025-2026 SKY, LLC. //! Checks whether ascending NTFS file-reference (FRS) order correlates -//! with ascending on-disk physical location, for a sample of files. +//! with ascending on-disk physical location, for a sample of files -- +//! and how much read-order headroom is actually on the table. //! //! Step 1 of the "does reading candidates in ascending FRS order actually //! give us near-sequential physical disk access" investigation. Rust -//! replacement for the original `check_frs_vs_lcn.ps1` (same logic). +//! replacement for the original `check_frs_vs_lcn.ps1` (same logic, plus +//! the natural-vs-FRS-vs-oracle seek-distance comparison below). //! //! Reads `uffs --format json` output (path + file_reference per line), //! samples a subset, and runs `fsutil file queryextents` on each sampled //! file to find its first extent's starting LCN (logical cluster number -//! -- i.e. where it actually sits on the volume). Reports the Spearman -//! rank correlation between FRS order and LCN order: a strong positive -//! correlation means ascending-FRS read order is a good proxy for -//! physical order (sorting reads by FRS should meaningfully cut seeks); -//! a weak/no correlation means it won't help -- the files are physically -//! scattered independent of allocation order. +//! -- i.e. where it actually sits on the volume). Reports two things: +//! +//! 1. The Spearman rank correlation between FRS order and LCN order: a strong +//! positive correlation means ascending-FRS read order is a good proxy for +//! physical order; weak/no correlation means it won't help -- the files are +//! physically scattered independent of allocation order. +//! 2. The total seek distance (sum of `|Δ LCN|` between consecutive reads) +//! under three orderings of the same sample: the order the search response +//! actually returned them in ("natural" -- what the read pipeline processes +//! today), ascending-FRS order, and the oracle (true ascending-LCN order -- +//! the unbeatable lower bound). This turns "FRS correlates weakly/moderately +//! with LCN" into a concrete number: how much of the *achievable* seek +//! reduction does cheap FRS-sorting actually capture, versus what only a +//! real LCN-resolution pass (querying physical location up front) could get. //! //! # Usage //! ```text @@ -49,6 +59,11 @@ struct Sample { struct Resolved { path: String, + /// Position in `rows` -- i.e. the order the search response actually + /// returned this file in, which is what the read pipeline processes + /// today (see `candidate_source.rs`: candidates keep the search + /// response's row order). + natural_index: usize, frs: u64, lcn: u64, } @@ -115,6 +130,7 @@ fn main() { match query_first_lcn(&row.path) { Some(lcn) => resolved.push(Resolved { path: row.path.clone(), + natural_index: idx, frs: row.frs, lcn, }), @@ -160,6 +176,8 @@ fn main() { ); } + print_seek_distance_comparison(&resolved); + let out_csv = default_output_csv(json_path); write_csv(&out_csv, &resolved); println!(); @@ -169,6 +187,123 @@ fn main() { ); } +/// Sum of `|Δ LCN|` between consecutive entries of `ordered` -- a proxy +/// for total head movement (in clusters) touring this sample once in +/// the given order. +fn total_seek_distance(ordered: &[&Resolved]) -> u64 { + ordered + .windows(2) + .map(|pair| pair[0].lcn.abs_diff(pair[1].lcn)) + .sum() +} + +/// Prints the natural-vs-FRS-vs-oracle seek-distance comparison: how +/// much of the seek reduction that's actually achievable (natural -> +/// oracle) does cheap ascending-FRS sorting capture on its own. +fn print_seek_distance_comparison(resolved: &[Resolved]) { + let mut by_natural: Vec<&Resolved> = resolved.iter().collect(); + by_natural.sort_by_key(|r| r.natural_index); + + let mut by_frs: Vec<&Resolved> = resolved.iter().collect(); + by_frs.sort_by_key(|r| r.frs); + + // The oracle: true ascending-LCN order. For a strictly ascending + // sequence, sum of |Δ| collapses to (max - min) -- the unbeatable + // lower bound for touring every point once. + let mut by_lcn: Vec<&Resolved> = resolved.iter().collect(); + by_lcn.sort_by_key(|r| r.lcn); + + let natural_total = total_seek_distance(&by_natural); + let frs_total = total_seek_distance(&by_frs); + let oracle_total = total_seek_distance(&by_lcn); + + let bytes_per_cluster = resolved + .first() + .and_then(|r| drive_root(&r.path)) + .and_then(|root| query_bytes_per_cluster(&root)); + + println!(); + println!( + "=== Total seek distance (sum of |Δ LCN| touring the sample once; lower is better) ===" + ); + print_distance_line( + "Natural (search-response) order", + natural_total, + bytes_per_cluster, + ); + print_distance_line("Ascending-FRS order", frs_total, bytes_per_cluster); + print_distance_line( + "Oracle: true ascending-LCN order", + oracle_total, + bytes_per_cluster, + ); + println!(); + + let achievable = natural_total.saturating_sub(oracle_total); + if achievable == 0 { + println!( + "Natural order already matches the oracle on this sample -- no seek-distance headroom to capture." + ); + return; + } + let frs_captured = natural_total.saturating_sub(frs_total); + let capture_pct = 100.0 * frs_captured as f64 / achievable as f64; + println!( + "Ascending-FRS order captures {capture_pct:.1}% of the max possible seek-distance \ + reduction (natural -> oracle) on this sample. The remaining {:.1}% is only reachable \ + by actually resolving true LCN per candidate before ordering reads.", + 100.0 - capture_pct + ); +} + +fn print_distance_line(label: &str, clusters: u64, bytes_per_cluster: Option) { + match bytes_per_cluster { + Some(bpc) => { + let mib = (clusters as f64 * bpc as f64) / (1024.0 * 1024.0); + println!("{label}: {clusters} clusters (~{mib:.1} MiB of head travel)"); + } + None => println!( + "{label}: {clusters} clusters (bytes/cluster unknown -- couldn't convert to MiB)" + ), + } +} + +/// Extracts `"D:"` from a path like `"D:\Dropbox\..."`, for the +/// `fsutil fsinfo ntfsinfo` call below. `None` for anything that doesn't +/// look like a drive-letter-rooted Windows path. +fn drive_root(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + Some(format!("{}:", &path[0..1])) + } else { + None + } +} + +/// Runs `fsutil fsinfo ntfsinfo ` and pulls out "Bytes Per +/// Cluster" so seek-distance numbers can be shown in MiB, not just raw +/// cluster counts. Best-effort: `None` if the command or parse fails, +/// callers fall back to reporting clusters only. +fn query_bytes_per_cluster(drive_root: &str) -> Option { + let output = Command::new("fsutil") + .args(["fsinfo", "ntfsinfo", drive_root]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.to_ascii_lowercase().contains("bytes per cluster") { + let digits: String = line.chars().filter(char::is_ascii_digit).collect(); + if let Ok(value) = digits.parse::() { + return Some(value); + } + } + } + None +} + /// Run `fsutil file queryextents` and pull out the first `Lcn` value it /// prints -- tolerant of hex (`0x...`) or decimal, and of the label's /// exact wording/case, since this has drifted across Windows versions. @@ -249,11 +384,14 @@ fn default_output_csv(json_path: &str) -> std::path::PathBuf { } fn write_csv(path: &Path, resolved: &[Resolved]) { - let mut out = String::from("Path,Frs,Lcn\n"); + let mut out = String::from("Path,NaturalIndex,Frs,Lcn\n"); for row in resolved { // Paths can contain commas/quotes -- quote and escape per RFC 4180. let escaped_path = row.path.replace('"', "\"\""); - out.push_str(&format!("\"{escaped_path}\",{},{}\n", row.frs, row.lcn)); + out.push_str(&format!( + "\"{escaped_path}\",{},{},{}\n", + row.natural_index, row.frs, row.lcn + )); } if let Err(err) = fs::write(path, out) { eprintln!("failed to write {}: {err}", path.display()); From f35c5845e54d8baf273c14afa3867cf58625b55f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:16:37 -0700 Subject: [PATCH 82/98] chore(diag): add block-sampling mode to FRS/LCN checker Adds a 3rd/4th arg (mode + optional offset): "random" (default, unchanged -- true uniform draw across every row) or "block" (sample_size *consecutive* rows in search-response order, from a given or random offset). Block mode tests a different, more operationally relevant question than global random sampling: are candidates as the read pipeline's bounded sliding window would actually encounter them together physically clustered on disk, versus whether FRS order tracks LCN order across the whole result set. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.rs | 41 +++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs index 5d042cc05..50de2c558 100644 --- a/scripts/windows/check_frs_vs_lcn.rs +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -34,10 +34,21 @@ //! reduction does cheap FRS-sorting actually capture, versus what only a //! real LCN-resolution pass (querying physical location up front) could get. //! +//! Two sampling modes are supported (3rd arg): +//! - `random` (default): a true uniform random draw across every row in the +//! file -- answers "does global FRS order track global LCN order." +//! - `block`: `sample_size` *consecutive* rows (in search-response order) +//! starting at a given or random offset -- answers a different question: are +//! candidates *as the pipeline's bounded sliding window would actually +//! encounter them together* physically clustered. This is the more +//! operationally relevant test, since the read pipeline only ever has a +//! handful of candidates in flight at once, not the freedom to globally +//! reorder the whole run. +//! //! # Usage //! ```text //! uffs.exe "*.txt" --drive D --format json > d_files.jsonl -//! rust-script scripts/windows/check_frs_vs_lcn.rs d_files.jsonl [sample_size] +//! rust-script scripts/windows/check_frs_vs_lcn.rs d_files.jsonl [sample_size] [random|block] [offset] //! ``` use std::collections::HashMap; @@ -45,6 +56,7 @@ use std::path::Path; use std::process::Command; use std::{env, fs}; +use rand::Rng; use rand::seq::SliceRandom; /// Low 48 bits are the FRS (MFT record number); high 16 bits are the @@ -72,7 +84,7 @@ fn main() { let args: Vec = env::args().collect(); let Some(json_path) = args.get(1) else { eprintln!( - "usage: check_frs_vs_lcn.rs [sample_size=500]\n\ + "usage: check_frs_vs_lcn.rs [sample_size=500] [random|block] [offset]\n\ \n\ json_path must be `uffs --format json` output (one JSON object per line, \ with a `path` and nonzero `file_reference` field)." @@ -80,6 +92,8 @@ fn main() { std::process::exit(2); }; let sample_size: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(500); + let mode = args.get(3).map(String::as_str).unwrap_or("random"); + let fixed_offset: Option = args.get(4).and_then(|s| s.parse().ok()); println!("Reading {json_path} ..."); let content = fs::read_to_string(json_path).unwrap_or_else(|err| { @@ -114,9 +128,26 @@ fn main() { let mut rng = rand::thread_rng(); let take = sample_size.min(rows.len()); - let mut indices: Vec = (0..rows.len()).collect(); - indices.shuffle(&mut rng); - indices.truncate(take); + let indices: Vec = match mode { + "block" => { + let max_start = rows.len().saturating_sub(take); + let start = fixed_offset + .unwrap_or_else(|| rng.gen_range(0..=max_start)) + .min(max_start); + let total = rows.len(); + println!( + "Block-sampling {take} consecutive rows starting at natural index {start} \ + (of {total} total)." + ); + (start..start + take).collect() + } + _ => { + let mut shuffled: Vec = (0..rows.len()).collect(); + shuffled.shuffle(&mut rng); + shuffled.truncate(take); + shuffled + } + }; println!("Sampling {take} files; querying extents (this hits the filesystem once per file)..."); From 82d559ac56e1c89283e0247a1952666774e49a3b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:20:09 -0700 Subject: [PATCH 83/98] fix(diag): fix bytes/cluster parsing in FRS/LCN checker's MiB conversion query_bytes_per_cluster's digit extraction collected every digit character anywhere on the matching fsutil line, not just the value -- fragile to whatever exact formatting fsutil uses. Real-hardware run produced an implied ~40972 bytes/cluster (not a valid NTFS cluster size, which must always be a power of two between 512 B and 2 MiB), which silently inflated the printed MiB-of-head-travel numbers ~10x. Rewrite to only parse what follows the last ':' on the matching line (handling 0x-hex values too), and add a hard sanity check: reject any parsed value that isn't a power-of-two NTFS cluster size instead of silently reporting a nonsense conversion. Falls back to "unknown" if the format still doesn't match on a given Windows version, same as before. The bug only affected the MiB display -- the underlying cluster counts, Spearman correlation, and seek-distance-reduction percentage were always correct. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.rs | 32 ++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs index 50de2c558..71006d010 100644 --- a/scripts/windows/check_frs_vs_lcn.rs +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -325,9 +325,35 @@ fn query_bytes_per_cluster(drive_root: &str) -> Option { } let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { - if line.to_ascii_lowercase().contains("bytes per cluster") { - let digits: String = line.chars().filter(char::is_ascii_digit).collect(); - if let Ok(value) = digits.parse::() { + if !line.to_ascii_lowercase().contains("bytes per cluster") { + continue; + } + // Only look at what follows the last ':' on this line -- the + // label itself never contains digits, but scoping to the value + // side avoids picking up stray digits from anywhere else on the + // line if the format has more on it than expected. + let Some(colon) = line.rfind(':') else { + continue; + }; + let after = line[colon + 1..].trim(); + let parsed = if let Some(hex) = after + .strip_prefix("0x") + .or_else(|| after.strip_prefix("0X")) + { + u64::from_str_radix(hex.trim(), 16).ok() + } else { + after + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .ok() + }; + // NTFS cluster sizes are always a power of two in [512 B, 2 MiB]. + // Reject anything else as a parse failure rather than silently + // reporting a nonsense MiB conversion downstream. + if let Some(value) = parsed { + if (512..=2 * 1024 * 1024).contains(&value) && value.is_power_of_two() { return Some(value); } } From 5e2a731f657340146e6c765de3deebdd73087a73 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:12:30 -0700 Subject: [PATCH 84/98] feat(daemon): resolve true physical location (LCN) for content-read jobs Real-hardware benchmarking (check_frs_vs_lcn.rs) found ascending-FRS read order only weakly-to-moderately correlates with actual on-disk physical layout (Spearman 0.56-0.68 across independent random samples) and captures only ~28-29% of the achievable seek-distance reduction on a volume reorganized over years. The rest requires resolving true LCN. Adds an opt-in daemon-side post-search step instead of teaching uffs-content-reader to parse the MFT itself: uffsd already performs a full MFT parse to build its search index (an already-open, already-broker-authorized volume handle), so grafting one more targeted-record-read pass onto that is far cheaper than giving the intentionally narrow, non-elevated uffs-content-reader a whole new MFT-parsing capability it would otherwise never need, and avoids re-deriving everything from scratch in a second process. - uffs-mft: new `lcn_resolve` module. `resolve_frs_to_lcn` (Windows-only) opens the MFT's own extents and does targeted per-FRS record reads (ascending order, so the reads stay close to sequential within $MFT itself) to find each file's first non-sparse $DATA run's LCN. The underlying `first_data_lcn` byte-parser is pure and cross-platform, kept separately testable without Windows. - uffs-client: new `SearchParams::resolve_lcn_order` flag (opt-in, off by default -- interactive searches never set it). - uffs-daemon: new `IndexManager::device_paths` map (tracks which drives were loaded from a VSS snapshot device vs. a live volume, since VolumeHandle::get_mft_extents needs the same one reopened) and a new `physical_order` module: a plain post-processing pass over the already-built row list, run only when the flag is set, so every other search request's code path is completely unaffected. Grouped per drive (LCN only means something within one volume) and never fails the search -- a drive whose volume can't be reopened just keeps its rows in original order with a warning logged. - uffs-content: sets the flag when building its per-drive enumeration SearchParams. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-client/src/protocol/cli_args.rs | 5 + crates/uffs-client/src/protocol/mod.rs | 14 + crates/uffs-client/src/protocol/tests.rs | 26 ++ .../uffs-content/src/job/candidate_source.rs | 8 + crates/uffs-core/src/compact/record.rs | 9 + crates/uffs-daemon/src/index/constructors.rs | 1 + crates/uffs-daemon/src/index/loading.rs | 11 + crates/uffs-daemon/src/index/mod.rs | 16 + .../uffs-daemon/src/index/physical_order.rs | 153 ++++++++++ crates/uffs-daemon/src/index/search.rs | 7 +- crates/uffs-mft/src/lcn_resolve.rs | 282 ++++++++++++++++++ crates/uffs-mft/src/lib.rs | 11 +- 12 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 crates/uffs-daemon/src/index/physical_order.rs create mode 100644 crates/uffs-mft/src/lcn_resolve.rs diff --git a/crates/uffs-client/src/protocol/cli_args.rs b/crates/uffs-client/src/protocol/cli_args.rs index 6ec1f44d4..298a1086e 100644 --- a/crates/uffs-client/src/protocol/cli_args.rs +++ b/crates/uffs-client/src/protocol/cli_args.rs @@ -735,6 +735,11 @@ impl RawCliArgs { // Row precedence (high → low): --rows (on) > agg (off) > --no-output (off) > default // (on). include_rows: force_rows || (agg_specs.is_empty() && !self.no_output), + // Not exposed as a CLI flag: interactive searches never need + // physical-location ordering, only uffs-content's bulk + // content-read jobs do (set directly on the `SearchParams` + // they build, bypassing this CLI-args constructor). + resolve_lcn_order: false, agg_cursor: self.agg_cursor, agg_page_size: self.agg_page_size, // Direct file output diff --git a/crates/uffs-client/src/protocol/mod.rs b/crates/uffs-client/src/protocol/mod.rs index 1450bee53..27e4326c3 100644 --- a/crates/uffs-client/src/protocol/mod.rs +++ b/crates/uffs-client/src/protocol/mod.rs @@ -452,6 +452,19 @@ pub struct SearchParams { /// (equivalent to `--count` or `--aggregate` without `--rows`). #[serde(default = "default_true")] pub include_rows: bool, + /// Resolve each matched row's true on-disk physical location (LCN) + /// and return rows sorted by ascending LCN instead of match order. + /// + /// Opt-in and off by default: resolving physical location costs one + /// extra targeted MFT record read per matched row, so this is meant + /// for bulk content-read jobs (`uffs-content`) ordering reads to + /// minimize seeks, not interactive queries. Real-hardware + /// benchmarking found match order (and even ascending-FRS order) + /// captures only a fraction of the achievable seek-distance + /// reduction on a volume that's been reorganized over years — see + /// `docs/architecture/content-stream-tool-design.md`. + #[serde(default)] + pub resolve_lcn_order: bool, // ── Aggregation pagination ───────────────────────────────────── /// Opaque cursor token from a previous response's `next_cursor`. @@ -618,6 +631,7 @@ impl Default for SearchParams { profile: false, aggregations: vec![], include_rows: true, + resolve_lcn_order: false, agg_cursor: None, agg_page_size: None, output_file: None, diff --git a/crates/uffs-client/src/protocol/tests.rs b/crates/uffs-client/src/protocol/tests.rs index f53e601fa..4a7dc568f 100644 --- a/crates/uffs-client/src/protocol/tests.rs +++ b/crates/uffs-client/src/protocol/tests.rs @@ -94,6 +94,32 @@ fn search_params_normalize_malformed_round_trip_and_default() { ); } +/// `resolve_lcn_order` round-trips, and an older payload that omits it +/// deserializes as `false` (backward-compatible via `#[serde(default)]`) -- +/// mirrors `search_params_normalize_malformed_round_trip_and_default`. +#[test] +fn search_params_resolve_lcn_order_round_trip_and_default() { + let params = SearchParams { + pattern: "*".to_owned(), + resolve_lcn_order: true, + ..Default::default() + }; + let json = serde_json::to_value(¶ms).expect("serialize"); + let parsed: SearchParams = serde_json::from_value(json).expect("deserialize"); + assert!( + parsed.resolve_lcn_order, + "the flag must survive the JSON-RPC round trip" + ); + + // A payload from an older client that never knew the field. + let legacy: SearchParams = + serde_json::from_value(serde_json::json!({ "pattern": "*" })).expect("legacy deserialize"); + assert!( + !legacy.resolve_lcn_order, + "omitted field defaults off (wire backward-compat)" + ); +} + /// The CLI surface: `--normalize-malformed` sets the param; absent → off. #[test] fn from_cli_args_normalize_malformed_flag() { diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 916b64e15..21425886e 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -214,6 +214,14 @@ impl CandidateSource for VssCandidateSource<'_> { older: self.older.clone(), exclude: self.exclude.clone(), attr: self.attr.clone(), + // Real-hardware benchmarking found reading candidates in + // match order (or even ascending-FRS order) leaves most of + // the achievable seek-distance reduction on the table for a + // volume that's been reorganized over years -- see + // docs/architecture/content-stream-tool-design.md. Bulk + // content reads are exactly the workload that benefits from + // this; interactive CLI searches never set it. + resolve_lcn_order: true, ..Default::default() }; tracing::info!( diff --git a/crates/uffs-core/src/compact/record.rs b/crates/uffs-core/src/compact/record.rs index 6a0ad2b4b..284b6852a 100644 --- a/crates/uffs-core/src/compact/record.rs +++ b/crates/uffs-core/src/compact/record.rs @@ -89,6 +89,15 @@ impl CompactRecord { (frs & FRS_MASK) | (u64::from(sequence_number) << 48) } + /// The FRS (MFT slot, low 48 bits) of a raw packed file reference — + /// the free-standing counterpart of [`Self::frs`] for callers that + /// only have the packed `u64` (e.g. `DisplayRow`/`SearchRow`'s + /// `file_reference` field), not a whole `CompactRecord`. + #[must_use] + pub const fn unpack_frs(file_reference: u64) -> u64 { + file_reference & FRS_MASK + } + /// The FRS (MFT slot, low 48 bits) of this record's file reference. #[must_use] pub const fn frs(&self) -> u64 { diff --git a/crates/uffs-daemon/src/index/constructors.rs b/crates/uffs-daemon/src/index/constructors.rs index 9ed07abdf..644e238be 100644 --- a/crates/uffs-daemon/src/index/constructors.rs +++ b/crates/uffs-daemon/src/index/constructors.rs @@ -168,6 +168,7 @@ impl IndexManager { queries_total_us: AtomicU64::new(0), startup_duration_us: AtomicU64::new(0), drive_timings: RwLock::new(std::collections::HashMap::new()), + device_paths: RwLock::new(std::collections::HashMap::new()), body_loader, working_set_trim, prefetch, diff --git a/crates/uffs-daemon/src/index/loading.rs b/crates/uffs-daemon/src/index/loading.rs index df3f0f0b7..4dab1800d 100644 --- a/crates/uffs-daemon/src/index/loading.rs +++ b/crates/uffs-daemon/src/index/loading.rs @@ -219,6 +219,17 @@ impl IndexManager { drives_total: total, }; + // Recorded up front (not just on success) so a later physical- + // location-ordering search request always knows which device this + // drive's index came from, matching this function's whole-batch + // "always a VSS/device source" contract. + { + let mut paths = self.device_paths.write().await; + for (device_path, drive) in devices { + paths.insert(*drive, device_path.clone()); + } + } + let mut join_set = Self::spawn_device_drive_loaders(devices); let mut loaded: usize = 0; diff --git a/crates/uffs-daemon/src/index/mod.rs b/crates/uffs-daemon/src/index/mod.rs index b813fbded..6dccde03f 100644 --- a/crates/uffs-daemon/src/index/mod.rs +++ b/crates/uffs-daemon/src/index/mod.rs @@ -20,6 +20,7 @@ mod hotload; mod info; mod journal; mod loading; +mod physical_order; mod predicates; mod projection; mod refresh; @@ -180,6 +181,21 @@ pub(crate) struct IndexManager { /// Per-drive load timing for `--profile` reporting. drive_timings: RwLock>, + /// Device path (VSS snapshot, via `--device PATH=LETTER`) each loaded + /// drive was actually read from, if any. A drive absent from this map + /// was loaded from its live volume. Needed by + /// [`Self::run_search_over`]'s optional physical-location-ordering + /// step: `VolumeHandle::get_mft_extents` requires opening the *same* + /// device the index came from, or it silently returns the wrong + /// volume's layout (see that method's own doc comment in `uffs-mft`). + #[cfg_attr( + not(windows), + expect( + dead_code, + reason = "only read by physical_order's Windows-only reorder_impl" + ) + )] + device_paths: RwLock>, /// Source for `Parked` / `Cold` shard bodies during /// promote-on-search. Production paths use /// [`crate::cache::body_loader::DiskBodyLoader`]; the diff --git a/crates/uffs-daemon/src/index/physical_order.rs b/crates/uffs-daemon/src/index/physical_order.rs new file mode 100644 index 000000000..4138939a4 --- /dev/null +++ b/crates/uffs-daemon/src/index/physical_order.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Optional post-search reordering by true on-disk physical location +//! (LCN), for `SearchParams::resolve_lcn_order` — the read-order +//! optimization for `uffs-content`'s bulk content-read jobs. +//! +//! Deliberately a plain post-processing pass over the already-built +//! `Vec`, run only when the flag is set, so every other +//! search request's code path (the overwhelming majority: interactive +//! CLI queries) is completely unaffected — no per-record cost, no new +//! branch in the hot per-row loop that builds `rows` in the first +//! place. + +#[cfg(windows)] +use std::collections::HashMap; + +use uffs_client::protocol::SearchParams; +use uffs_client::protocol::response::SearchRow; +#[cfg(windows)] +use uffs_core::compact::CompactRecord; +use uffs_mft::platform::DriveLetter; + +use super::IndexManager; + +impl IndexManager { + /// If `params.resolve_lcn_order` is set, resolves each row's true + /// on-disk physical location and returns `rows` re-sorted by + /// ascending LCN (grouped per drive — LCN only means something + /// within one volume; groups are concatenated back in order of + /// first appearance, since cross-drive ordering has no physical + /// meaning either way). Otherwise returns `rows` unchanged. + /// + /// Never fails the search: if a drive's volume can't be opened or + /// its LCNs can't be resolved, that drive's rows are appended in + /// their original relative order and a warning is logged — the same + /// posture as every other best-effort daemon-side optimization. + pub(crate) async fn reorder_rows_by_physical_location( + &self, + params: &SearchParams, + rows: Vec, + ) -> Vec { + if !params.resolve_lcn_order || rows.is_empty() { + return rows; + } + #[cfg(windows)] + { + self.reorder_impl(rows).await + } + #[cfg(not(windows))] + { + // Non-Windows builds have no `VolumeHandle`/live-MFT access at + // all -- the flag is a no-op here rather than a compile + // error, since the wire-protocol field itself is + // cross-platform. The `.await` on an already-ready future is + // deliberate: keeps this function genuinely `async` on every + // platform rather than needing a separate sync twin the + // caller would have to branch on. + tracing::warn!( + "physical-location ordering was requested but is Windows-only; \ + returning rows in original order" + ); + core::future::ready(rows).await + } + } + + /// Windows implementation: real `VolumeHandle`/MFT-record access. + /// See [`Self::reorder_rows_by_physical_location`] for the contract. + #[cfg(windows)] + async fn reorder_impl(&self, rows: Vec) -> Vec { + let drive_order = first_appearance_order(&rows); + + let mut by_drive: HashMap> = HashMap::new(); + for row in rows { + by_drive.entry(row.drive).or_default().push(row); + } + + // Clone into an owned map up front so the `RwLock` read guard is + // released immediately, not held across the whole (potentially + // slow, per-drive-I/O) loop below. + let device_paths: HashMap = self.device_paths.read().await.clone(); + let mut output = Vec::with_capacity(by_drive.values().map(Vec::len).sum()); + + for drive in drive_order { + let Some(mut group) = by_drive.remove(&drive) else { + continue; + }; + + let opened = device_paths.get(&drive).map_or_else( + || uffs_mft::VolumeHandle::open(drive), + |device_path| uffs_mft::VolumeHandle::open_device_path(device_path, drive), + ); + let volume = match opened { + Ok(volume) => volume, + Err(err) => { + tracing::warn!( + %drive, + error = %err, + "physical-location ordering: failed to open volume, \ + leaving this drive's rows in original order" + ); + output.extend(group); + continue; + } + }; + + let frs_list: Vec = group + .iter() + .map(|row| CompactRecord::unpack_frs(row.file_reference)) + .collect(); + + match uffs_mft::resolve_frs_to_lcn(&volume, &frs_list) { + Ok(lcns) => { + group.sort_by_key(|row| { + let frs = CompactRecord::unpack_frs(row.file_reference); + lcns.get(&frs).copied().flatten() + }); + } + Err(err) => { + tracing::warn!( + %drive, + error = %err, + "physical-location ordering: LCN resolution failed, \ + leaving this drive's rows in original order" + ); + } + } + + output.extend(group); + } + + output + } +} + +/// The distinct drives appearing in `rows`, in order of first +/// appearance — used so `IndexManager::reorder_impl` (Windows-only — not +/// in scope on this platform's rustdoc build) can concatenate each +/// drive's (independently sorted) group back in a stable, deterministic +/// order. +#[cfg_attr( + not(windows), + expect(dead_code, reason = "only consumed by the Windows reorder_impl") +)] +fn first_appearance_order(rows: &[SearchRow]) -> Vec { + let mut seen = Vec::new(); + for row in rows { + if !seen.contains(&row.drive) { + seen.push(row.drive); + } + } + seen +} diff --git a/crates/uffs-daemon/src/index/search.rs b/crates/uffs-daemon/src/index/search.rs index c578ca639..954584ba1 100644 --- a/crates/uffs-daemon/src/index/search.rs +++ b/crates/uffs-daemon/src/index/search.rs @@ -478,7 +478,7 @@ impl IndexManager { // `drive_match_counts` was computed up-front (see block above) // so both dispatch branches share the same per-drive tally. let filtered_len = filtered_rows.len(); - let rows: Vec = if effective_params.include_rows { + let mut rows: Vec = if effective_params.include_rows { filtered_rows .iter() .map(Self::display_row_to_search_row) @@ -487,6 +487,11 @@ impl IndexManager { Vec::new() }; let row_build_us = t_rows.map_or(0, |ts| ts.elapsed().as_micros()); + // Opt-in, off by default (see field doc) -- every other request's + // code path above is completely unaffected. + rows = self + .reorder_rows_by_physical_location(&effective_params, rows) + .await; // Update perf counters. let query_us = query_start.elapsed().as_micros(); diff --git a/crates/uffs-mft/src/lcn_resolve.rs b/crates/uffs-mft/src/lcn_resolve.rs new file mode 100644 index 000000000..f8f4df93e --- /dev/null +++ b/crates/uffs-mft/src/lcn_resolve.rs @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Resolves NTFS file references (FRS) to their on-disk physical +//! location (LCN) — the read-order optimization for bulk content-read +//! jobs (`uffs-content`). +//! +//! Real-hardware benchmarking found that reading matched candidates in +//! whatever order a search happened to return them in (or even sorted by +//! ascending FRS, which only weakly-to-moderately correlates with +//! physical layout on a volume that's been reorganized over years — see +//! `docs/architecture/content-stream-tool-design.md`) leaves most of a +//! drive's achievable seek-distance reduction on the table. Resolving +//! true LCN up front and sorting by it captures the rest. +//! +//! This lives in `uffs-mft`, not `uffs-content`/`uffs-content-reader`, +//! deliberately: `uffsd` already performs a full MFT parse to build its +//! search index (an already-open, already-broker-authorized volume +//! handle, an already-warm process) — grafting one more targeted-read +//! pass onto that is far cheaper than teaching the intentionally narrow, +//! non-elevated `uffs-content-reader` a whole new MFT-parsing capability +//! it would otherwise never need, and would require re-opening the +//! device and re-deriving everything from scratch. + +#[cfg(windows)] +use std::collections::HashMap; + +#[cfg(windows)] +use crate::error::Result; +#[cfg(windows)] +use crate::io::{MftExtentMap, MftRecordReader}; +use crate::ntfs::{AttributeIterator, AttributeType}; +use crate::platform::Lcn; +#[cfg(windows)] +use crate::platform::VolumeHandle; + +/// Resolves each FRS in `frs_list` to the starting LCN of its primary +/// (unnamed) `$DATA` attribute's first real (non-sparse) data run. +/// +/// `volume` must already be open against the *same device* the caller's +/// FRS values came from — a live drive letter via [`VolumeHandle::open`], +/// or (for content-read jobs, which run against a VSS snapshot) +/// [`VolumeHandle::open_device_path`]. [`VolumeHandle::get_mft_extents`] +/// depends on this distinction; opening the wrong one silently corrupts +/// every offset computed from it (see that method's own doc comment). +/// +/// `None` in the returned map for a given FRS means one of: the record +/// couldn't be read (outside the MFT, transient I/O failure), its +/// `$DATA` is resident (a small file — no physical location to speak +/// of, and cheap to read regardless of order), or it has no data runs +/// at all (an empty file). Callers should treat `None` as "no seek-order +/// preference" (e.g. sort first), not as an error. +/// +/// `frs_list` is de-duplicated and read in ascending order internally — +/// this keeps the targeted record reads this performs close to +/// sequential within `$MFT` itself, which is typically far less +/// fragmented than the volume at large, not merely for tidiness. +/// +/// # Errors +/// Returns an error only if `$MFT`'s own extents can't be determined at +/// all (e.g. a bad handle). An individual record's read or parse +/// failure is folded into that FRS's `None` result, never propagated — +/// one unreadable record must not abort resolution for the rest of the +/// want-list. +#[cfg(windows)] +pub fn resolve_frs_to_lcn( + volume: &VolumeHandle, + frs_list: &[u64], +) -> Result>> { + let mut result = HashMap::with_capacity(frs_list.len()); + if frs_list.is_empty() { + return Ok(result); + } + + let extents = volume.get_mft_extents()?; + let extent_map = MftExtentMap::new( + extents, + volume.volume_data().bytes_per_cluster, + volume.volume_data().bytes_per_file_record_segment, + ); + let mut reader = MftRecordReader::new_with_extents(extent_map); + let handle = volume.raw_handle(); + + let mut sorted: Vec = frs_list.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + + for frs in sorted { + let lcn = reader + .read_record(handle, frs) + .ok() + .and_then(first_data_lcn); + result.insert(frs, lcn); + } + + Ok(result) +} + +/// Finds the primary (unnamed) `$DATA` attribute in a raw record buffer +/// and returns the starting LCN of its first real (non-sparse) data run +/// — `None` for resident data, an unparseable record, or a wholly-sparse +/// file. +/// +/// Pure, cross-platform byte parsing (no Windows dependency), kept +/// testable without Windows even though its only non-test caller, +/// `resolve_frs_to_lcn` (Windows-only — not in scope on this platform's +/// rustdoc build), is Windows-only. +#[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only called by resolve_frs_to_lcn (Windows-only) outside tests" + ) +)] +fn first_data_lcn(record: &[u8]) -> Option { + let attrs = AttributeIterator::new(record)?; + let data_attr = attrs + .filter(|attr| attr.attribute_type() == Some(AttributeType::Data)) + .find(|attr| attr.name().is_none())?; + if !data_attr.is_non_resident() { + return None; + } + data_attr + .data_runs() + .into_iter() + .find(|run| !run.is_sparse()) + .map(|run| run.lcn) +} + +#[cfg(test)] +mod tests { + #![expect( + clippy::indexing_slicing, + reason = "test code — relaxed linting for test clarity" + )] + + use core::mem::size_of; + + use super::first_data_lcn; + use crate::ntfs::{ + AttributeRecordHeader, FileRecordSegmentHeader, NonResidentAttributeData, + ResidentAttributeData, + }; + + fn write_u16_le(buffer: &mut [u8], offset: usize, value: u16) { + buffer[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn write_u32_le(buffer: &mut [u8], offset: usize, value: u32) { + buffer[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_i64_le(buffer: &mut [u8], offset: usize, value: i64) { + buffer[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + /// Writes a valid `FILE`-magic record header with `first_attribute_offset` + /// right after the header and `bytes_in_use` covering through + /// `end_marker_offset`'s 4-byte `$END` marker -- mirrors + /// `ntfs::tests::attribute_iterator_reads_resident_attribute_value`'s + /// header construction exactly (this crate's established byte-buffer + /// test convention), reused here since `first_data_lcn` needs a + /// *whole* record, not a standalone attribute slice. + fn write_record_header(record: &mut [u8], end_marker_offset: usize) { + let attr_offset = size_of::(); + record[0..4].copy_from_slice(b"FILE"); + write_u16_le(record, 20, crate::len_to_u16(attr_offset)); + write_u16_le(record, 22, 0x0001); // in-use + write_u32_le( + record, + 24, + crate::len_to_u32(end_marker_offset + size_of::()), + ); + write_u32_le(record, 28, crate::len_to_u32(record.len())); + } + + #[test] + fn first_data_lcn_resolves_non_resident_first_run() { + let attr_offset = size_of::(); + let nr_offset = attr_offset + size_of::(); + let mapping_pairs_rel_offset = + size_of::() + size_of::(); + let attr_len = mapping_pairs_rel_offset + 4; + let end_marker_offset = attr_offset + attr_len; + let mut record = vec![0_u8; end_marker_offset + size_of::()]; + + write_record_header(&mut record, end_marker_offset); + + // Attribute header: unnamed, non-resident $DATA. + write_u32_le( + &mut record, + attr_offset, + crate::ntfs::AttributeType::DATA_TYPE, + ); + write_u32_le(&mut record, attr_offset + 4, crate::len_to_u32(attr_len)); + record[attr_offset + 8] = 1; // is_non_resident + record[attr_offset + 9] = 0; // name_length = 0 (unnamed/primary stream) + write_u16_le(&mut record, attr_offset + 12, 0); + write_u16_le(&mut record, attr_offset + 14, 1); + + // Non-resident header + one real (non-sparse) data run: vcn 7, + // 5 clusters, lcn 10 -- same mapping-pairs bytes as + // `ntfs::tests::non_resident_attribute_helpers_decode_mapping_pairs`. + write_i64_le(&mut record, nr_offset, 7); + write_i64_le(&mut record, nr_offset + 8, 11); + write_u16_le( + &mut record, + nr_offset + 16, + crate::len_to_u16(mapping_pairs_rel_offset), + ); + record[nr_offset + 18] = 0; + write_i64_le(&mut record, nr_offset + 24, 40); + write_i64_le(&mut record, nr_offset + 32, 20); + write_i64_le(&mut record, nr_offset + 40, 20); + record[attr_offset + mapping_pairs_rel_offset..attr_offset + mapping_pairs_rel_offset + 4] + .copy_from_slice(&[0x11, 0x05, 0x0A, 0x00]); + + write_u32_le( + &mut record, + end_marker_offset, + crate::ntfs::AttributeType::END_MARKER, + ); + + assert_eq!(first_data_lcn(&record), Some(crate::platform::Lcn::new(10))); + } + + #[test] + fn first_data_lcn_returns_none_for_resident_data() { + let attr_offset = size_of::(); + let attr_len = size_of::() + size_of::() + 4; + let end_marker_offset = attr_offset + attr_len; + let mut record = vec![0_u8; end_marker_offset + size_of::()]; + + write_record_header(&mut record, end_marker_offset); + + write_u32_le( + &mut record, + attr_offset, + crate::ntfs::AttributeType::DATA_TYPE, + ); + write_u32_le(&mut record, attr_offset + 4, crate::len_to_u32(attr_len)); + record[attr_offset + 8] = 0; // resident + record[attr_offset + 9] = 0; // unnamed + write_u16_le(&mut record, attr_offset + 12, 0); + write_u16_le(&mut record, attr_offset + 14, 1); + write_u32_le(&mut record, attr_offset + 16, 4); // value_length + write_u16_le( + &mut record, + attr_offset + 20, + crate::len_to_u16( + size_of::() + size_of::(), + ), + ); + write_u32_le( + &mut record, + end_marker_offset, + crate::ntfs::AttributeType::END_MARKER, + ); + + assert_eq!( + first_data_lcn(&record), + None, + "resident $DATA has no physical location -- must not be misparsed as a real run" + ); + } + + #[test] + fn first_data_lcn_returns_none_when_no_data_attribute_present() { + let attr_offset = size_of::(); + let end_marker_offset = attr_offset; + let mut record = vec![0_u8; end_marker_offset + size_of::()]; + + write_record_header(&mut record, end_marker_offset); + write_u32_le( + &mut record, + end_marker_offset, + crate::ntfs::AttributeType::END_MARKER, + ); + + assert_eq!(first_data_lcn(&record), None); + } +} diff --git a/crates/uffs-mft/src/lib.rs b/crates/uffs-mft/src/lib.rs index 8c55720fa..ff051aeda 100644 --- a/crates/uffs-mft/src/lib.rs +++ b/crates/uffs-mft/src/lib.rs @@ -247,6 +247,13 @@ pub mod platform; pub mod usn; +// Read-order optimization for bulk content-read jobs: resolves FRS to +// on-disk physical location (LCN). The byte-parsing core is pure and +// cross-platform (testable without Windows); only its `VolumeHandle`- +// driven public entry point is cfg-gated internally, matching that +// type's own gating below. +pub mod lcn_resolve; + pub mod frs; pub mod cache; @@ -305,6 +312,8 @@ pub use io::{ ParsedRecord, ReadChunk, apply_fixup, generate_read_chunks, parse_record_full, parse_record_zero_alloc, }; +#[cfg(windows)] +pub use lcn_resolve::resolve_frs_to_lcn; // Re-export NTFS constants and types (pure Rust data structures, cross-platform) pub use ntfs::SECTOR_SIZE; pub use ntfs::{ @@ -323,12 +332,12 @@ pub use platform::current_euid; // Unix: geteuid() == 0). Exported unconditionally so uffs-cli and // uffs-daemon can gate mutating daemon commands on all targets. pub use platform::is_elevated; -pub use platform::registered_broker_handle_count; // Re-export platform types // Core types (DriveType, MftBitmap, MftExtent) are pure data — available on all platforms // Windows-specific types and functions (VolumeHandle, detect_ntfs_drives, etc.) only on // Windows pub use platform::{DriveType, MftBitmap, MftExtent, SystemMemory, query_system_memory}; +pub use platform::{Lcn, registered_broker_handle_count}; // External-API anchors with cross-crate consumers. Other Windows-only // platform items (NtfsVolumeData, detect_drive_type, infer_drive_from_path, // is_volume_read_only) are pub(crate) and consumed only via From 6c7afdaefb07c528f9d3b18a247ddbcf9a8369a6 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:55:03 -0700 Subject: [PATCH 85/98] feat(client): add --resolve-lcn-order diagnostic CLI flag SearchParams::resolve_lcn_order was only ever set by uffs-content's own job code (bypassing the CLI-args constructor entirely), so there was no way to exercise the daemon's physical-location-ordering feature through the ordinary uffs.exe CLI -- regenerating a JSON dump with the plain CLI always showed unsorted (natural) order regardless of the daemon-side feature, which was confusing during manual verification. Adds --resolve-lcn-order as an explicit, diagnostic-only CLI flag (off by default, same as before) so the read-order optimization can be exercised and measured directly (e.g. via scripts/windows/check_frs_vs_lcn.rs) without running a full uffs-content job. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-client/src/protocol/cli_args.rs | 10 +++++----- crates/uffs-client/src/protocol/tests.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/uffs-client/src/protocol/cli_args.rs b/crates/uffs-client/src/protocol/cli_args.rs index 298a1086e..ddf955baa 100644 --- a/crates/uffs-client/src/protocol/cli_args.rs +++ b/crates/uffs-client/src/protocol/cli_args.rs @@ -52,6 +52,8 @@ impl SearchParams { "--hide-system" => raw.hide_system = true, "--hide-ads" => raw.hide_ads = true, "--normalize-malformed" => raw.normalize_malformed = true, + // Diagnostic only -- uffs-content sets this itself for real jobs. + "--resolve-lcn-order" => raw.resolve_lcn_order = true, // WI-4.4 forensic filters: find ill-formed (non-UTF-8) names. "--malformed" => raw.malformed = Some(true), "--well-formed" => raw.malformed = Some(false), @@ -312,6 +314,8 @@ struct RawCliArgs { hide_system: bool, hide_ads: bool, normalize_malformed: bool, + /// Diagnostic-only flag (see `--resolve-lcn-order` above). + resolve_lcn_order: bool, /// WI-4.4: `Some(true)` from `--malformed`, `Some(false)` from /// `--well-formed`, `None` if neither (no filter). malformed: Option, @@ -735,11 +739,7 @@ impl RawCliArgs { // Row precedence (high → low): --rows (on) > agg (off) > --no-output (off) > default // (on). include_rows: force_rows || (agg_specs.is_empty() && !self.no_output), - // Not exposed as a CLI flag: interactive searches never need - // physical-location ordering, only uffs-content's bulk - // content-read jobs do (set directly on the `SearchParams` - // they build, bypassing this CLI-args constructor). - resolve_lcn_order: false, + resolve_lcn_order: self.resolve_lcn_order, agg_cursor: self.agg_cursor, agg_page_size: self.agg_page_size, // Direct file output diff --git a/crates/uffs-client/src/protocol/tests.rs b/crates/uffs-client/src/protocol/tests.rs index 4a7dc568f..50330e90d 100644 --- a/crates/uffs-client/src/protocol/tests.rs +++ b/crates/uffs-client/src/protocol/tests.rs @@ -134,6 +134,23 @@ fn from_cli_args_normalize_malformed_flag() { assert!(!off.normalize_malformed, "absent flag defaults off"); } +/// The CLI surface: `--resolve-lcn-order` sets the param; absent → off. +/// Diagnostic/manual-verification flag -- see its match-arm comment in +/// `cli_args.rs` for why this exists alongside `uffs-content` setting the +/// same field directly. +#[test] +fn from_cli_args_resolve_lcn_order_flag() { + let on = SearchParams::from_cli_args(&["*.tmp".to_owned(), "--resolve-lcn-order".to_owned()]) + .expect("parse with flag"); + assert!( + on.resolve_lcn_order, + "--resolve-lcn-order must set the flag" + ); + + let off = SearchParams::from_cli_args(&["*.tmp".to_owned()]).expect("parse without flag"); + assert!(!off.resolve_lcn_order, "absent flag defaults off"); +} + /// Canonical helpers preserve legacy single-flag sort semantics. /// /// First field: ascending by default (no `--sort-desc`). From d84ad7be7172c52d5881d0c2b52ee4dac488bf80 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:51:21 -0700 Subject: [PATCH 86/98] fix(content): run all lease runs (drives) concurrently, not sequentially A real-hardware full-drive-set job showed drives were processed strictly one at a time in read_and_emit_all_candidates: a slow HDD-backed lease held up every other drive's candidates, including a fast SSD-backed lease sitting fully idle in queue, even though the two share no connection pool, no volume handle, and no physical device. Each lease run now gets its own thread, coordinated through a Mutex so two drives' candidates can never interleave their frames on the wire -- the protocol only requires per-candidate contiguity (FrameOrdering::None), which this preserves exactly. Also splits the now-826-line workflow.rs into workflow.rs + a new workflow/emit.rs sibling module (mirroring the file's existing workflow/pipeline.rs split), keeping both files under the workspace's 800-line file-size policy without trimming any documentation. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content/src/job/tests.rs | 220 ++++++++++- crates/uffs-content/src/job/vss_job.rs | 2 +- crates/uffs-content/src/job/workflow.rs | 353 +++-------------- crates/uffs-content/src/job/workflow/emit.rs | 379 +++++++++++++++++++ 4 files changed, 642 insertions(+), 312 deletions(-) create mode 100644 crates/uffs-content/src/job/workflow/emit.rs diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 92c7936b8..9291dc4ea 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -7,14 +7,19 @@ //! `crates/uffs-content/tests/e2e_dir_walk_parity_fake_reader.rs` — these //! tests instead cover this module's own internals in isolation. +use core::time::Duration; use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Instant; use uffs_content_protocol::codec::Reader; -use uffs_content_protocol::frame::{FileEnd, FrameEnvelope, FrameType, ReadMode}; +use uffs_content_protocol::frame::{ + ContentChunk, FileBegin, FileEnd, FrameEnvelope, FrameType, ReadMode, +}; use uffs_content_protocol::manifest::{CandidateRecord, ManifestHeader, ManifestTrailer}; -use super::candidate_source::{CandidateSource as _, DirWalkCandidateSource}; -use super::content_source::{ContentSource as _, FsContentSource}; +use super::candidate_source::{CandidateEntry, CandidateSource, DirWalkCandidateSource}; +use super::content_source::{ContentSource, FsContentSource, ReadSession}; use super::intake::JobRequest; use super::manifest_builder::build_manifest; use super::workflow::{ReadConcurrency, run_job}; @@ -36,8 +41,8 @@ fn dir_walk_candidate_source_enumerates_files_not_directories() { .collect(); relative_paths.sort(); assert_eq!(relative_paths, vec![ - std::path::PathBuf::from("a.txt"), - std::path::PathBuf::from("nested/b.txt"), + PathBuf::from("a.txt"), + PathBuf::from("nested/b.txt"), ]); } @@ -319,3 +324,208 @@ fn candidates_over_the_delivery_ceiling_are_reported_metadata_only() { assert_eq!(small_end.read_mode, ReadMode::LogicalSnapshot); assert!(small_end.content_digest.is_some()); } + +/// Test-only [`CandidateSource`] that fabricates a fixed small candidate +/// set per root, tagging each with a `snapshot_lease_id` parsed from the +/// root itself (`"lease:"`) — lets a single test drive multiple +/// concurrent lease runs without touching the filesystem or a real VSS +/// snapshot. +struct MultiLeaseCandidateSource { + /// Candidates to synthesize per lease. + per_lease: usize, +} + +impl CandidateSource for MultiLeaseCandidateSource { + fn enumerate(&self, root: &Path) -> std::io::Result> { + let root_str = root.to_string_lossy(); + let lease_id: u64 = root_str + .strip_prefix("lease:") + .and_then(|suffix| suffix.parse().ok()) + .unwrap_or(0); + Ok((0..self.per_lease) + .map(|i| { + let name = format!("file-{lease_id}-{i}.txt"); + CandidateEntry { + relative_path: PathBuf::from(&name), + absolute_path: PathBuf::from(&name), + logical_size: 4, + mtime_unix_ms: 0, + file_reference: lease_id * 1000 + i as u64, + snapshot_lease_id: lease_id, + } + }) + .collect()) + } +} + +/// Test-only [`ContentSource`] whose every candidate read sleeps +/// `per_candidate_delay` before returning a fixed 4-byte payload — +/// simulates real per-candidate I/O latency without touching a real +/// disk, so a test can assert on wall-clock time to prove concurrent +/// lease runs actually overlap (rather than merely not crashing). +struct SlowContentSource { + /// How long each candidate's one real read takes. + per_candidate_delay: Duration, +} + +impl ContentSource for SlowContentSource { + fn begin_read( + &self, + _candidate: &CandidateEntry, + _candidate_id: u64, + ) -> std::io::Result> { + Ok(Box::new(SlowReadSession { + delay: self.per_candidate_delay, + served: false, + })) + } +} + +/// [`SlowContentSource`]'s session: sleeps once, returns 4 bytes, then +/// signals EOF on every subsequent call. +struct SlowReadSession { + /// How long the one real read takes. + delay: Duration, + /// Whether the 4-byte payload has already been served. + served: bool, +} + +impl ReadSession for SlowReadSession { + fn read_at(&mut self, _offset: u64, _max_len: u32) -> std::io::Result> { + std::thread::sleep(self.delay); + if self.served { + return Ok(Vec::new()); + } + self.served = true; + Ok(b"data".to_vec()) + } +} + +/// Multiple lease runs (drives) must run concurrently, not one fully +/// finishing before the next starts — real-hardware benchmarking found +/// a slow HDD-backed lease holding up every other drive's candidates, +/// including a fast SSD-backed lease sitting idle in queue, even though +/// they share no connection pool or physical device. Proves this two +/// ways: wall-clock time must reflect the *slowest single lease*, not +/// the *sum* of every lease's time, and the emitted frame stream must +/// never let one candidate's frame group be split apart by another's — +/// the one atomicity guarantee concurrent lease runs must still uphold +/// (see `workflow`'s "Concurrent reads, concurrent drives, atomic +/// per-candidate emission" doc section). +#[test] +fn concurrent_lease_runs_actually_overlap_and_never_interleave_a_candidates_frames() { + const CANDIDATES_PER_LEASE: usize = 3; + const PER_CANDIDATE_DELAY: Duration = Duration::from_millis(100); + + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let request = JobRequest { + source_id: "test-source".to_owned(), + roots: vec![PathBuf::from("lease:1"), PathBuf::from("lease:2")], + query: "*".to_owned(), + ..Default::default() + }; + + let candidate_source = MultiLeaseCandidateSource { + per_lease: CANDIDATES_PER_LEASE, + }; + let content_source = SlowContentSource { + per_candidate_delay: PER_CANDIDATE_DELAY, + }; + + let mut frames = Vec::new(); + let started_at = Instant::now(); + let outcome = run_job( + &request, + &candidate_source, + &content_source, + run_dir.path(), + &ReadConcurrency::flat(1), + &[], + 0, + |frame| { + frames.push(frame); + Ok(()) + }, + ) + .expect("run_job must succeed"); + let elapsed = started_at.elapsed(); + + let total_candidates = 2 * CANDIDATES_PER_LEASE; + assert_eq!(outcome.run_summary.candidate_count, total_candidates as u64); + assert_eq!(outcome.run_summary.succeeded_count, total_candidates as u64); + assert_eq!(outcome.run_summary.failed_retryable_count, 0); + + // Sequential-lease processing would cost roughly + // 2 * CANDIDATES_PER_LEASE * PER_CANDIDATE_DELAY (~600ms); concurrent + // lease runs should cost roughly CANDIDATES_PER_LEASE * + // PER_CANDIDATE_DELAY (~300ms), since both leases' single-connection + // (concurrency = 1) reads proceed at the same time. The threshold + // sits comfortably between the two, with slack for scheduling + // jitter on a loaded CI machine. + let sequential_estimate = + PER_CANDIDATE_DELAY * u32::try_from(total_candidates).unwrap_or(u32::MAX); + assert!( + elapsed < sequential_estimate * 3 / 4, + "elapsed {elapsed:?} should be well under the fully-sequential estimate \ + {sequential_estimate:?} -- lease runs (drives) do not appear to be running \ + concurrently" + ); + + // Correctness: decode every frame in emission order and confirm no + // candidate's frame group (FILE_BEGIN..FILE_END) is ever split apart + // by another candidate's frames -- the one atomicity guarantee that + // must hold regardless of how many lease runs execute concurrently. + let mut open_candidate: Option = None; + for frame_bytes in &frames { + let mut reader = Reader::new(frame_bytes); + let Ok((envelope, payload)) = FrameEnvelope::decode(&mut reader, u64::MAX) else { + panic!("every emitted frame must decode"); + }; + match envelope.frame_type { + FrameType::FileBegin => { + assert_eq!( + open_candidate, None, + "a new FILE_BEGIN must never arrive while another candidate is still open" + ); + let file_begin = FileBegin::decode(&mut Reader::new(&payload)) + .expect("decode FILE_BEGIN payload"); + open_candidate = Some(file_begin.candidate_id); + } + FrameType::ContentChunk => { + let chunk = ContentChunk::decode(&mut Reader::new(&payload), u32::MAX) + .expect("decode CONTENT_CHUNK payload"); + assert_eq!( + open_candidate, + Some(chunk.candidate_id), + "a CONTENT_CHUNK must belong to the currently-open candidate" + ); + } + FrameType::FileEnd => { + let file_end = + FileEnd::decode(&mut Reader::new(&payload)).expect("decode FILE_END payload"); + assert_eq!( + open_candidate, + Some(file_end.candidate_id), + "FILE_END must close the currently-open candidate" + ); + open_candidate = None; + } + FrameType::JobBegin | FrameType::JobEnd => {} + other @ (FrameType::FileFailed + | FrameType::FileDeferred + | FrameType::FileAck + | FrameType::Progress + | FrameType::Heartbeat + | FrameType::JobCancel + | FrameType::WindowUpdate + | FrameType::JobResume + | FrameType::JobSubmit) => { + panic!("unexpected frame type in this test's stream: {other:?}") + } + } + } + assert_eq!( + open_candidate, None, + "every candidate must be closed by the end of the stream" + ); +} diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index 832740925..eafcb3697 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -45,7 +45,7 @@ use super::workflow::{JobOutcome, ReadConcurrency, run_job}; /// best-effort before returning. pub fn run_vss_job(request: &JobRequest, run_dir: &Path, emit_frame: F) -> Result where - F: FnMut(Vec) -> std::io::Result<()>, + F: FnMut(Vec) -> std::io::Result<()> + Send, { let job_id = *uuid::Uuid::new_v4().as_bytes(); let ephemeral_id = uuid::Uuid::new_v4().simple().to_string(); diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index 984ed7483..b33b606a3 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -9,7 +9,7 @@ //! those traits' docs for what "swappable" means today (a real vs. fake //! backing). //! -//! # Concurrent reads, sequential emission +//! # Concurrent reads, concurrent drives, atomic per-candidate emission //! //! Candidates are read through a bounded pipeline, one per contiguous //! `snapshot_lease_id` run (see `lease_runs`), sized by that lease's @@ -29,18 +29,38 @@ //! the other `concurrency - 1` connections working through subsequent //! candidates for the whole time the straggler is still streaming. //! -//! Despite the out-of-order reads, frames are still *emitted* strictly -//! in original candidate order, on the caller's own thread, exactly -//! matching the fully-sequential emission order this function has always -//! produced: `read_lease_run_pipelined`'s coordinator loop buffers an -//! out-of-order completion in a small reorder map and only calls the -//! caller's `on_ready` once every earlier candidate in the run has -//! already been handed back. `emit_frame`/`frame_sequence`/`counters`/ -//! `failure_log` are therefore still only ever touched from one thread; -//! no synchronization was added to any of them, and downstream consumers -//! of the frame stream (`crate::serve::stream::Grouped`) see exactly the -//! same per-candidate-contiguous ordering as the fully-sequential (every -//! lease at concurrency `1`) case. +//! **Every lease run (drive) also runs concurrently with every other +//! one**, each on its own thread — not one full drive at a time. A +//! real-hardware full-drive-set job showed this matters just as much as +//! within-drive concurrency: with drives processed strictly sequentially, +//! one slow HDD-backed lease grinding through a heavily-fragmented legacy +//! archive held up every *other* drive's candidates — including a fast +//! SSD-backed lease sitting fully idle in queue — for as long as the slow +//! one took, even though the two share no connection pool, no volume +//! handle, and no physical device. +//! +//! Running drives concurrently means frames from *different* candidates +//! (possibly on different drives) may now interleave on the wire — but +//! never frames belonging to the *same* candidate, and this is exactly +//! what the protocol allows: `JOB_BEGIN.ordering` is fixed to +//! [`FrameOrdering::None`] ("no cross-file ordering contract"), and the +//! one consumer that groups frames back up (`crate::serve::stream::Grouped`) +//! keys purely on each frame's own `candidate_id`, with no assumption +//! about which `candidate_id` shows up next — it only requires that one +//! candidate's `FILE_BEGIN`, its `CONTENT_CHUNK`s, and its +//! `FILE_END`/`FILE_FAILED` never get split apart by another candidate's +//! frames. That per-candidate atomicity is what `EmitState`'s mutex +//! enforces: every lease run's own coordinator thread reads through +//! `read_lease_run_pipelined`'s existing per-drive reorder map exactly as +//! before (so within one drive, candidates are still handed back to +//! `on_ready` in strict order), but the actual *emission* step — assembling +//! and writing one candidate's whole frame group, and updating +//! `frame_sequence`/`counters`/`failure_log` to match — now happens under +//! a shared lock so two drives' coordinator threads can never do it at +//! the same time. The lock is held only for that short, in-memory +//! assembly-and-write step, never for the (comparatively slow, I/O-bound) +//! disk read that produces a candidate's content, so the actual +//! parallelism this exists to unlock is untouched by the lock's presence. //! //! The reorder map's size — and therefore how far the sliding window can //! run ahead of a straggler — is self-bounding to a small constant @@ -58,10 +78,13 @@ //! `lease_runs` splits at each lease boundary rather than letting one //! drive's run absorb another's candidates under the wrong concurrency //! setting. This is what makes an HDD-backed lease's concurrency-`1` -//! setting actually mean "read every candidate strictly one at a time, -//! in the order they were enumerated" — the same order the MFT (and -//! therefore, roughly, on-disk position) produced them in — rather than -//! being diluted by whatever other drives happen to be in the same job. +//! setting actually mean "read every candidate on *this drive* strictly +//! one at a time, in the order they were enumerated" — the same order +//! the MFT (and therefore, roughly, on-disk position) produced them +//! in — rather than being diluted by whatever other drives happen to be +//! in the same job; it says nothing about ordering relative to any +//! *other* drive's candidates, which is exactly the freedom running +//! drives concurrently needs. //! //! # Why `emit_frame` is a callback, not a returned `Vec` //! @@ -83,23 +106,20 @@ use std::io; use std::path::Path; use uffs_content_protocol::codec::{Digest, digest}; -use uffs_content_protocol::error::ErrorCode; use uffs_content_protocol::frame::{ - ContentSemantics, DigestAlgorithm, FailedOutcome, FailureStage, FileBegin, FileEnd, FileFailed, - FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, JobStatus, PROTOCOL_VERSION, - ReadMode, RetryClass, + ContentSemantics, DigestAlgorithm, FrameEnvelope, FrameOrdering, FrameType, JobBegin, JobEnd, + JobStatus, PROTOCOL_VERSION, }; use uffs_content_protocol::manifest::AuthorizationMode; -use uffs_content_protocol::path_encoding::WindowsPath; -use self::pipeline::{CandidateContent, lease_runs, read_lease_run_pipelined}; use super::candidate_source::{CandidateEntry, CandidateSource}; use super::content_source::ContentSource; use super::intake::JobRequest; use super::manifest_builder::build_manifest; +mod emit; mod pipeline; -use crate::run::{FailureLogWriter, FailureRecord, RunCounters, RunSummary}; +use crate::run::{FailureLogWriter, RunCounters, RunSummary}; /// One `CONTENT_CHUNK`'s maximum payload size for a job run. /// @@ -227,7 +247,7 @@ pub struct JobOutcome { /// propagated from `emit_frame` itself (e.g. a downstream transport /// failure). A per-candidate content-read failure is *not* an error /// return — it's recorded as a `FAILED_RETRYABLE` outcome for that -/// candidate instead (a [`FileFailed`] frame plus a [`FailureRecord`]). +/// candidate instead (a `FILE_FAILED` frame plus a `FailureRecord`). #[expect( clippy::too_many_arguments, reason = "snapshot_id/snapshot_created_at are real VSS provenance the caller (run_vss_job) \ @@ -246,7 +266,7 @@ pub fn run_job( mut emit_frame: F, ) -> io::Result where - F: FnMut(Vec) -> io::Result<()>, + F: FnMut(Vec) -> io::Result<()> + Send, { let job_id = *uuid::Uuid::new_v4().as_bytes(); let source_id = source_id_bytes(&request.source_id); @@ -304,7 +324,7 @@ where .iter() .zip(built.candidate_ids.iter().copied()) .collect(); - read_and_emit_all_candidates( + emit::read_and_emit_all_candidates( &candidates, read_concurrency, content_source, @@ -348,168 +368,6 @@ where }) } -/// Read and emit every candidate's content, one [`read_lease_run_pipelined`] -/// call per contiguous [`lease_runs`] group. Extracted from [`run_job`] -/// itself so that function stays under the workspace's `too_many_lines` -/// budget — every parameter here is `run_job`'s own local state, threaded -/// through unchanged. -/// -/// # Errors -/// Propagates the first error from enumerating a lease run's content or -/// from `emit_frame` itself, exactly as `run_job`'s own doc comment -/// describes. -#[expect( - clippy::too_many_arguments, - reason = "the alternative is a bespoke context struct bundling counters/failure_log/ \ - frame_sequence/emit_frame purely to satisfy this lint, for a private helper \ - extracted from run_job with exactly one call site; not worth the indirection" -)] -fn read_and_emit_all_candidates( - candidates: &[(&CandidateEntry, u64)], - read_concurrency: &ReadConcurrency, - content_source: &dyn ContentSource, - max_content_delivery_bytes: Option, - job_id: [u8; 16], - counters: &mut RunCounters, - failure_log: &mut FailureLogWriter, - frame_sequence: &mut u64, - emit_frame: &mut F, -) -> io::Result<()> -where - F: FnMut(Vec) -> io::Result<()>, -{ - let total_candidates = candidates.len(); - let mut emitted_count = 0_usize; - let run_started_at = std::time::Instant::now(); - let mut last_progress_log_at = run_started_at; - let mut last_progress_log_bytes = 0_u64; - - for run in lease_runs(candidates) { - let Some(&(first_entry, _)) = run.first() else { - continue; - }; - let concurrency = read_concurrency.for_lease(first_entry.snapshot_lease_id); - read_lease_run_pipelined( - run, - concurrency, - content_source, - DEFAULT_MAX_CHUNK_BYTES, - max_content_delivery_bytes, - |index, read_result| { - let Some(&(entry, candidate_id)) = run.get(index) else { - return Ok(()); - }; - let result = emit_candidate( - entry, - candidate_id, - read_result, - counters, - failure_log, - job_id, - frame_sequence, - emit_frame, - ); - emitted_count += 1; - log_progress_if_due( - emitted_count, - total_candidates, - counters, - run_started_at, - &mut last_progress_log_at, - &mut last_progress_log_bytes, - ); - result - }, - )?; - } - Ok(()) -} - -/// How often [`read_and_emit_all_candidates`] logs a progress heartbeat, -/// at minimum — never less often than this many wall-clock seconds -/// apart, regardless of candidate count or throughput. Chosen so a job -/// that's silently grinding for a long time (whether genuinely slow or -/// stuck on one candidate — see `pipeline::read_one_candidate`'s own -/// per-candidate stall warning) is never silent for more than about this -/// long between updates. -const PROGRESS_LOG_MIN_INTERVAL: core::time::Duration = core::time::Duration::from_secs(10); - -/// Also log a heartbeat every this many candidates, even if -/// [`PROGRESS_LOG_MIN_INTERVAL`] hasn't elapsed — keeps a very fast run -/// (thousands of tiny files) from having its own progress signal -/// throttled down to nothing. -const PROGRESS_LOG_CANDIDATE_STRIDE: usize = 1000; - -/// Log an `INFO`-level progress line if either [`PROGRESS_LOG_MIN_INTERVAL`] -/// has elapsed since the last one or `emitted_count` just crossed a -/// [`PROGRESS_LOG_CANDIDATE_STRIDE`] boundary — see this module's -/// "Concurrent reads, sequential emission" doc section for why total -/// silence during content reading was a real problem this closes. -/// -/// Also reports `mib_per_sec_since_last_heartbeat` and -/// `mib_per_sec_since_job_start`: `logical_bytes_succeeded` only -/// advances as candidates are actually *emitted* — i.e. bytes handed to -/// `emit_frame`, the real wire write in `--serve` mode — so both figures -/// are a direct measurement of consumer-facing pipe throughput, not an -/// internal per-connection or per-drive read rate. Scan -/// `mib_per_sec_since_last_heartbeat` across a run's log for min/max; -/// the last line's `mib_per_sec_since_job_start` is the run's overall -/// average. -#[expect( - clippy::cast_precision_loss, - reason = "diagnostic-only throughput figures for a log line, not computed against further \ - — same posture as uffs-content's own benchmark report (self_test.rs)" -)] -#[expect( - clippy::float_arithmetic, - reason = "diagnostic-only throughput ratios for a log line, matching self_test.rs's \ - existing benchmark-report precedent" -)] -fn log_progress_if_due( - emitted_count: usize, - total_candidates: usize, - counters: &RunCounters, - run_started_at: std::time::Instant, - last_progress_log_at: &mut std::time::Instant, - last_progress_log_bytes: &mut u64, -) { - let due_by_time = last_progress_log_at.elapsed() >= PROGRESS_LOG_MIN_INTERVAL; - let due_by_count = emitted_count.is_multiple_of(PROGRESS_LOG_CANDIDATE_STRIDE) - || emitted_count == total_candidates; - if !due_by_time && !due_by_count { - return; - } - let interval_secs = last_progress_log_at.elapsed().as_secs_f64(); - let interval_bytes = counters - .logical_bytes_succeeded - .saturating_sub(*last_progress_log_bytes); - let mib_per_sec_since_last_heartbeat = if interval_secs > 0.0_f64 { - (interval_bytes as f64 / (1_024.0_f64 * 1_024.0_f64)) / interval_secs - } else { - 0.0_f64 - }; - let overall_secs = run_started_at.elapsed().as_secs_f64(); - let mib_per_sec_since_job_start = if overall_secs > 0.0_f64 { - (counters.logical_bytes_succeeded as f64 / (1_024.0_f64 * 1_024.0_f64)) / overall_secs - } else { - 0.0_f64 - }; - - *last_progress_log_at = std::time::Instant::now(); - *last_progress_log_bytes = counters.logical_bytes_succeeded; - tracing::info!( - emitted_count, - total_candidates, - succeeded = counters.succeeded_count, - failed_retryable = counters.failed_retryable_count, - failed_terminal = counters.failed_terminal_count, - logical_bytes_succeeded = counters.logical_bytes_succeeded, - mib_per_sec_since_last_heartbeat, - mib_per_sec_since_job_start, - "job: content read progress" - ); -} - /// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and /// advancing the next `frame_sequence`. fn encode_frame( @@ -582,123 +440,6 @@ fn emit_job_end( )) } -/// Emit one already-read candidate's `FILE_BEGIN`, its `CONTENT_CHUNK`s, -/// and its terminal frame (`FILE_END`/`FILE_FAILED`), in that order, on -/// the caller's own thread — see the module doc's "Concurrent reads, -/// sequential emission" section for why this step is never -/// parallelized. Updates `counters` and appends to `failure_log` for a -/// non-success outcome. -#[expect( - clippy::too_many_arguments, - reason = "the alternative is a bespoke context struct bundling job_id/frame_sequence/ \ - emit_frame purely to satisfy this lint, for a private helper with exactly one \ - call site; not worth the indirection" -)] -fn emit_candidate( - entry: &CandidateEntry, - candidate_id: u64, - content: CandidateContent, - counters: &mut RunCounters, - failure_log: &mut FailureLogWriter, - job_id: [u8; 16], - frame_sequence: &mut u64, - emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, -) -> io::Result<()> { - let path = WindowsPath::from_str_lossless(&entry.relative_path.to_string_lossy()); - - let file_begin = FileBegin { - candidate_id, - file_reference: entry.file_reference, - path, - logical_size: entry.logical_size, - mtime: entry.mtime_unix_ms, - read_mode: content.read_mode, - attempt_number: 1, - content_object_id: None, - }; - emit_frame(encode_frame( - job_id, - frame_sequence, - FrameType::FileBegin, - &file_begin.encode(), - ))?; - - let chunk_count = len_as_u64(content.chunks.len()); - for chunk in &content.chunks { - emit_frame(encode_frame( - job_id, - frame_sequence, - FrameType::ContentChunk, - &chunk.encode(), - ))?; - } - - let read_mode = content.read_mode; - match content.read_error { - None => { - // A metadata-only candidate never had real bytes read (see - // `pipeline::read_one_candidate`'s doc comment), so its - // digest is meaningless — report `None`, matching the wire - // contract `ReadMode::MetadataOnly`'s own doc comment - // documents (design-doc's two-tier delivery-ceiling model). - let content_digest = if read_mode == ReadMode::MetadataOnly { - None - } else { - Some(content.digest) - }; - let file_end = FileEnd { - candidate_id, - total_logical_bytes: content.total_read, - content_digest, - read_mode, - chunk_count, - elapsed_ms: 0, - warning_flags: 0, - }; - emit_frame(encode_frame( - job_id, - frame_sequence, - FrameType::FileEnd, - &file_end.encode(), - ))?; - counters.record_succeeded(content.total_read); - } - Some(err) => { - let os_error_code = err.raw_os_error().map(i64::from); - let message = err.to_string(); - let file_failed = FileFailed { - candidate_id, - outcome: FailedOutcome::Retryable, - failure_stage: FailureStage::Read, - error_code: ErrorCode::ReadIoTransient, - os_error_code, - retry_class: RetryClass::RetryNewSnapshot, - bytes_emitted_before_failure: content.total_read, - message: message.clone(), - }; - emit_frame(encode_frame( - job_id, - frame_sequence, - FrameType::FileFailed, - &file_failed.encode(), - ))?; - counters.record_failed_retryable(); - failure_log.append(&FailureRecord::failed( - candidate_id, - FailedOutcome::Retryable, - FailureStage::Read, - ErrorCode::ReadIoTransient, - os_error_code, - RetryClass::RetryNewSnapshot, - content.total_read, - message, - ))?; - } - } - - Ok(()) -} - /// Deterministically derives a manifest `source_id` from an arbitrary /// caller-supplied string, truncating a BLAKE3 digest to 16 bytes (this /// avoids requiring the `uuid` crate's `v5` feature workspace-wide for diff --git a/crates/uffs-content/src/job/workflow/emit.rs b/crates/uffs-content/src/job/workflow/emit.rs new file mode 100644 index 000000000..d8c393b07 --- /dev/null +++ b/crates/uffs-content/src/job/workflow/emit.rs @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Per-candidate, per-lease-run content emission — split out of +//! `workflow` itself purely to stay under this workspace's file-size +//! policy (mirroring `workflow`'s own existing `pipeline` split for the +//! same reason); every item here is still conceptually part of +//! `run_job`'s single content-reading step, just extracted so that one +//! file doesn't hold both the top-level job orchestration and the +//! concurrency machinery beneath it. +//! +//! See `super`'s "Concurrent reads, concurrent drives, atomic +//! per-candidate emission" doc section for why [`EmitState`]'s mutex +//! exists and what atomicity it's protecting. + +use std::io; + +use uffs_content_protocol::error::ErrorCode; +use uffs_content_protocol::frame::{ + FailedOutcome, FailureStage, FileBegin, FileEnd, FileFailed, FrameType, ReadMode, RetryClass, +}; +use uffs_content_protocol::path_encoding::WindowsPath; + +use super::pipeline::{CandidateContent, lease_runs, read_lease_run_pipelined}; +use super::{DEFAULT_MAX_CHUNK_BYTES, ReadConcurrency, encode_frame}; +use crate::job::candidate_source::CandidateEntry; +use crate::job::content_source::ContentSource; +use crate::run::{FailureLogWriter, FailureRecord, RunCounters}; + +/// Read and emit every candidate's content, one [`read_lease_run_pipelined`] +/// call per contiguous [`lease_runs`] group. Extracted from +/// `super::run_job` itself so that function stays under the workspace's +/// `too_many_lines` budget — every parameter here is `run_job`'s own +/// local state, threaded through unchanged. +/// +/// # Errors +/// Propagates the first error from enumerating a lease run's content or +/// from `emit_frame` itself, exactly as `run_job`'s own doc comment +/// describes. +#[expect( + clippy::too_many_arguments, + reason = "the alternative is a bespoke context struct bundling counters/failure_log/ \ + frame_sequence/emit_frame purely to satisfy this lint, for a private helper \ + extracted from run_job with exactly one call site; not worth the indirection" +)] +#[expect( + clippy::significant_drop_tightening, + reason = "the lock IS the intended critical section, per lease-run closure below: it must \ + stay held for one candidate's whole emission (assembling + writing its frames, \ + updating counters/failure_log/frame_sequence together), not something to shrink \ + -- that's exactly what keeps two drives' candidates from interleaving on the wire" +)] +pub(super) fn read_and_emit_all_candidates( + candidates: &[(&CandidateEntry, u64)], + read_concurrency: &ReadConcurrency, + content_source: &dyn ContentSource, + max_content_delivery_bytes: Option, + job_id: [u8; 16], + counters: &mut RunCounters, + failure_log: &mut FailureLogWriter, + frame_sequence: &mut u64, + emit_frame: &mut F, +) -> io::Result<()> +where + F: FnMut(Vec) -> io::Result<()> + Send, +{ + let total_candidates = candidates.len(); + let run_started_at = std::time::Instant::now(); + let emit_state = std::sync::Mutex::new(EmitState { + counters, + failure_log, + frame_sequence, + emit_frame, + emitted_count: 0, + last_progress_log_at: run_started_at, + last_progress_log_bytes: 0, + }); + + let runs = lease_runs(candidates); + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = runs + .iter() + .map(|run| { + let emit_state_ref = &emit_state; + scope.spawn(move || { + let Some(&(first_entry, _)) = run.first() else { + return Ok(()); + }; + let concurrency = read_concurrency.for_lease(first_entry.snapshot_lease_id); + read_lease_run_pipelined( + run, + concurrency, + content_source, + DEFAULT_MAX_CHUNK_BYTES, + max_content_delivery_bytes, + |index, read_result| { + let Some(&(entry, candidate_id)) = run.get(index) else { + return Ok(()); + }; + let mut guard = emit_state_ref + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // Explicit reborrow: the guard's `DerefMut` hides + // field disjointness from the borrow checker, so + // every subsequent access goes through this plain + // `&mut EmitState` instead of `guard` directly. + let state = &mut *guard; + let result = emit_candidate( + entry, + candidate_id, + read_result, + state.counters, + state.failure_log, + job_id, + state.frame_sequence, + state.emit_frame, + ); + state.emitted_count += 1; + log_progress_if_due( + state.emitted_count, + total_candidates, + state.counters, + run_started_at, + &mut state.last_progress_log_at, + &mut state.last_progress_log_bytes, + ); + result + }, + ) + }) + }) + .collect(); + + handles + .into_iter() + .map(|handle| { + handle.join().unwrap_or_else(|panic_payload| { + Err(io::Error::other(format!( + "lease-run thread panicked: {panic_payload:?}" + ))) + }) + }) + .collect() + }); + + for result in results { + result?; + } + Ok(()) +} + +/// Bundles everything one candidate's emission touches — `counters`, +/// `failure_log`, `frame_sequence`, the `emit_frame` callback itself, +/// and the progress-heartbeat state — behind one lock, so concurrently- +/// running lease runs (drives) never interleave two candidates' frame +/// groups on the wire; see `super`'s "Concurrent reads, concurrent +/// drives, atomic per-candidate emission" doc section for why that's +/// exactly the atomicity the protocol requires, no more and no less. +struct EmitState<'a, F> { + /// Run-wide success/failure/byte counters, updated once per emitted + /// candidate. + counters: &'a mut RunCounters, + /// Append-only failure-record log, written to on a failed candidate. + failure_log: &'a mut FailureLogWriter, + /// Next frame's sequence number, advanced by every frame this + /// candidate emits. + frame_sequence: &'a mut u64, + /// The caller-supplied frame sink. + emit_frame: &'a mut F, + /// Candidates emitted so far, across every lease run combined. + emitted_count: usize, + /// Wall-clock time of the last progress heartbeat. + last_progress_log_at: std::time::Instant, + /// `counters.logical_bytes_succeeded` as of the last heartbeat. + last_progress_log_bytes: u64, +} + +/// How often [`read_and_emit_all_candidates`] logs a progress heartbeat, +/// at minimum — never less often than this many wall-clock seconds +/// apart, regardless of candidate count or throughput. Chosen so a job +/// that's silently grinding for a long time (whether genuinely slow or +/// stuck on one candidate — see `pipeline::read_one_candidate`'s own +/// per-candidate stall warning) is never silent for more than about this +/// long between updates. +const PROGRESS_LOG_MIN_INTERVAL: core::time::Duration = core::time::Duration::from_secs(10); + +/// Also log a heartbeat every this many candidates, even if +/// [`PROGRESS_LOG_MIN_INTERVAL`] hasn't elapsed — keeps a very fast run +/// (thousands of tiny files) from having its own progress signal +/// throttled down to nothing. +const PROGRESS_LOG_CANDIDATE_STRIDE: usize = 1000; + +/// Log an `INFO`-level progress line if either [`PROGRESS_LOG_MIN_INTERVAL`] +/// has elapsed since the last one or `emitted_count` just crossed a +/// [`PROGRESS_LOG_CANDIDATE_STRIDE`] boundary — see `super`'s "Concurrent +/// reads, concurrent drives, atomic per-candidate emission" doc section +/// for why total silence during content reading was a real problem this +/// closes. +/// +/// Also reports `mib_per_sec_since_last_heartbeat` and +/// `mib_per_sec_since_job_start`: `logical_bytes_succeeded` only +/// advances as candidates are actually *emitted* — i.e. bytes handed to +/// `emit_frame`, the real wire write in `--serve` mode — so both figures +/// are a direct measurement of consumer-facing pipe throughput, not an +/// internal per-connection or per-drive read rate. Scan +/// `mib_per_sec_since_last_heartbeat` across a run's log for min/max; +/// the last line's `mib_per_sec_since_job_start` is the run's overall +/// average. +#[expect( + clippy::cast_precision_loss, + reason = "diagnostic-only throughput figures for a log line, not computed against further \ + — same posture as uffs-content's own benchmark report (self_test.rs)" +)] +#[expect( + clippy::float_arithmetic, + reason = "diagnostic-only throughput ratios for a log line, matching self_test.rs's \ + existing benchmark-report precedent" +)] +fn log_progress_if_due( + emitted_count: usize, + total_candidates: usize, + counters: &RunCounters, + run_started_at: std::time::Instant, + last_progress_log_at: &mut std::time::Instant, + last_progress_log_bytes: &mut u64, +) { + let due_by_time = last_progress_log_at.elapsed() >= PROGRESS_LOG_MIN_INTERVAL; + let due_by_count = emitted_count.is_multiple_of(PROGRESS_LOG_CANDIDATE_STRIDE) + || emitted_count == total_candidates; + if !due_by_time && !due_by_count { + return; + } + let interval_secs = last_progress_log_at.elapsed().as_secs_f64(); + let interval_bytes = counters + .logical_bytes_succeeded + .saturating_sub(*last_progress_log_bytes); + let mib_per_sec_since_last_heartbeat = if interval_secs > 0.0_f64 { + (interval_bytes as f64 / (1_024.0_f64 * 1_024.0_f64)) / interval_secs + } else { + 0.0_f64 + }; + let overall_secs = run_started_at.elapsed().as_secs_f64(); + let mib_per_sec_since_job_start = if overall_secs > 0.0_f64 { + (counters.logical_bytes_succeeded as f64 / (1_024.0_f64 * 1_024.0_f64)) / overall_secs + } else { + 0.0_f64 + }; + + *last_progress_log_at = std::time::Instant::now(); + *last_progress_log_bytes = counters.logical_bytes_succeeded; + tracing::info!( + emitted_count, + total_candidates, + succeeded = counters.succeeded_count, + failed_retryable = counters.failed_retryable_count, + failed_terminal = counters.failed_terminal_count, + logical_bytes_succeeded = counters.logical_bytes_succeeded, + mib_per_sec_since_last_heartbeat, + mib_per_sec_since_job_start, + "job: content read progress" + ); +} + +/// Emit one already-read candidate's `FILE_BEGIN`, its `CONTENT_CHUNK`s, +/// and its terminal frame (`FILE_END`/`FILE_FAILED`), in that order, on +/// the caller's own thread — see `super`'s "Concurrent reads, concurrent +/// drives, atomic per-candidate emission" doc section for the atomicity +/// this step is part of. Updates `counters` and appends to `failure_log` +/// for a non-success outcome. +#[expect( + clippy::too_many_arguments, + reason = "the alternative is a bespoke context struct bundling job_id/frame_sequence/ \ + emit_frame purely to satisfy this lint, for a private helper with exactly one \ + call site; not worth the indirection" +)] +fn emit_candidate( + entry: &CandidateEntry, + candidate_id: u64, + content: CandidateContent, + counters: &mut RunCounters, + failure_log: &mut FailureLogWriter, + job_id: [u8; 16], + frame_sequence: &mut u64, + emit_frame: &mut dyn FnMut(Vec) -> io::Result<()>, +) -> io::Result<()> { + let path = WindowsPath::from_str_lossless(&entry.relative_path.to_string_lossy()); + + let file_begin = FileBegin { + candidate_id, + file_reference: entry.file_reference, + path, + logical_size: entry.logical_size, + mtime: entry.mtime_unix_ms, + read_mode: content.read_mode, + attempt_number: 1, + content_object_id: None, + }; + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileBegin, + &file_begin.encode(), + ))?; + + let chunk_count = super::len_as_u64(content.chunks.len()); + for chunk in &content.chunks { + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::ContentChunk, + &chunk.encode(), + ))?; + } + + let read_mode = content.read_mode; + match content.read_error { + None => { + // A metadata-only candidate never had real bytes read (see + // `pipeline::read_one_candidate`'s doc comment), so its + // digest is meaningless — report `None`, matching the wire + // contract `ReadMode::MetadataOnly`'s own doc comment + // documents (design-doc's two-tier delivery-ceiling model). + let content_digest = if read_mode == ReadMode::MetadataOnly { + None + } else { + Some(content.digest) + }; + let file_end = FileEnd { + candidate_id, + total_logical_bytes: content.total_read, + content_digest, + read_mode, + chunk_count, + elapsed_ms: 0, + warning_flags: 0, + }; + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileEnd, + &file_end.encode(), + ))?; + counters.record_succeeded(content.total_read); + } + Some(err) => { + let os_error_code = err.raw_os_error().map(i64::from); + let message = err.to_string(); + let file_failed = FileFailed { + candidate_id, + outcome: FailedOutcome::Retryable, + failure_stage: FailureStage::Read, + error_code: ErrorCode::ReadIoTransient, + os_error_code, + retry_class: RetryClass::RetryNewSnapshot, + bytes_emitted_before_failure: content.total_read, + message: message.clone(), + }; + emit_frame(encode_frame( + job_id, + frame_sequence, + FrameType::FileFailed, + &file_failed.encode(), + ))?; + counters.record_failed_retryable(); + failure_log.append(&FailureRecord::failed( + candidate_id, + FailedOutcome::Retryable, + FailureStage::Read, + ErrorCode::ReadIoTransient, + os_error_code, + RetryClass::RetryNewSnapshot, + content.total_read, + message, + ))?; + } + } + + Ok(()) +} From ae3c2e9540b591ff94b9fe87ea8f258697ac1414 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:01:45 -0700 Subject: [PATCH 87/98] feat(diag): add raw-throughput floor + fragmentation diagnostics Real-hardware runs kept showing single-digit MiB/s content-read throughput on some drives even after LCN-sorted ordering, with no way to tell whether that's the drive's actual physical ceiling or still a software problem. measure_raw_throughput.rs streams raw sequential reads straight off a volume/VSS device (dd-style, bypassing the whole read pipeline) at three zones (outer/middle/inner) to measure the real floor to compare pipeline throughput against. check_frs_vs_lcn.rs now also reports fragmentation: how many sampled files span more than one on-disk extent, and the intra-file seek distance that costs even under perfect oracle LCN ordering, since ordering candidates by first-extent LCN says nothing about a file that's itself scattered across the disk. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.rs | 170 +++++++++--- scripts/windows/measure_raw_throughput.rs | 320 ++++++++++++++++++++++ 2 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 scripts/windows/measure_raw_throughput.rs diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs index 71006d010..cb30ff44d 100644 --- a/scripts/windows/check_frs_vs_lcn.rs +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -33,6 +33,14 @@ //! with LCN" into a concrete number: how much of the *achievable* seek //! reduction does cheap FRS-sorting actually capture, versus what only a //! real LCN-resolution pass (querying physical location up front) could get. +//! 3. Fragmentation: even a perfectly LCN-ordered read still pays a seek for +//! every file that's itself split across more than one on-disk extent -- +//! ordering candidates by their *first* extent's LCN says nothing about +//! what happens once a file's own data is scattered. Reports how many of +//! the sampled files have more than one extent and the total intra-file +//! seek distance (the gap between one extent's end and the next extent's +//! start, summed within each file) this costs even under otherwise-perfect +//! ordering. //! //! Two sampling modes are supported (3rd arg): //! - `random` (default): a true uniform random draw across every row in the @@ -77,7 +85,19 @@ struct Resolved { /// response's row order). natural_index: usize, frs: u64, + /// First extent's starting LCN -- what every ordering comparison in + /// this script sorts/measures by. lcn: u64, + /// How many separate on-disk extents this file's `$DATA` attribute + /// is split across (from `fsutil file queryextents`). `1` means + /// contiguous; more means the file itself forces a seek partway + /// through reading it, independent of read *order*. + extent_count: usize, + /// Sum of `|gap|` between one extent's end (`lcn + clusters`) and the + /// next extent's start, across this file's own extents -- the seek + /// distance a perfectly-LCN-ordered read still can't avoid, because + /// it's internal to a single file's layout. + intra_file_seek_clusters: u64, } fn main() { @@ -158,12 +178,15 @@ fn main() { println!(" ... {} / {take}", i + 1); } let row = &rows[idx]; - match query_first_lcn(&row.path) { - Some(lcn) => resolved.push(Resolved { + let extents = query_all_extents(&row.path); + match extents.first() { + Some(&(lcn, _)) => resolved.push(Resolved { path: row.path.clone(), natural_index: idx, frs: row.frs, lcn, + extent_count: extents.len(), + intra_file_seek_clusters: intra_file_seek_distance(&extents), }), None => unresolved += 1, } @@ -208,6 +231,7 @@ fn main() { } print_seek_distance_comparison(&resolved); + print_fragmentation_report(&resolved); let out_csv = default_output_csv(json_path); write_csv(&out_csv, &resolved); @@ -361,40 +385,116 @@ fn query_bytes_per_cluster(drive_root: &str) -> Option { None } -/// Run `fsutil file queryextents` and pull out the first `Lcn` value it -/// prints -- tolerant of hex (`0x...`) or decimal, and of the label's -/// exact wording/case, since this has drifted across Windows versions. -fn query_first_lcn(path: &str) -> Option { - let output = Command::new("fsutil") +/// Run `fsutil file queryextents` and return every `(Lcn, Clusters)` pair +/// it prints, in the order given -- i.e. in ascending VCN (logical +/// offset within the file) order, since that's the order `fsutil` lists +/// a file's runs in. A file with more than one entry is fragmented: its +/// own data is split across non-adjacent runs on disk, so reading it in +/// full requires a seek at each run boundary no matter how well the +/// *candidate* read order is chosen. +/// +/// Tolerant of hex (`0x...`) or decimal values and of the labels' exact +/// wording/case, since both have drifted across Windows versions. +fn query_all_extents(path: &str) -> Vec<(u64, u64)> { + let Ok(output) = Command::new("fsutil") .args(["file", "queryextents", path]) .output() - .ok()?; + else { + return Vec::new(); + }; if !output.status.success() { - return None; + return Vec::new(); } let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let lower = line.to_ascii_lowercase(); - if let Some(idx) = lower.find("lcn") { - let after = line.get(idx + 3..)?; - let after = after.trim_start_matches([':', ' ', '\t']); - if let Some(hex) = after - .strip_prefix("0x") - .or_else(|| after.strip_prefix("0X")) - { - let digits: String = hex.chars().take_while(char::is_ascii_hexdigit).collect(); - if let Ok(value) = u64::from_str_radix(&digits, 16) { - return Some(value); - } - } else { - let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(value) = digits.parse::() { - return Some(value); - } - } - } + stdout + .lines() + .filter_map(|line| { + let lcn = extract_number_after(line, "lcn")?; + let clusters = extract_number_after(line, "cluster").unwrap_or(0); + Some((lcn, clusters)) + }) + .collect() +} + +/// Finds `keyword` (case-insensitive) in `line` and parses the hex +/// (`0x...`) or decimal number immediately following it, skipping over +/// separators (`:`, spaces, tabs) in between. +fn extract_number_after(line: &str, keyword: &str) -> Option { + let lower = line.to_ascii_lowercase(); + let idx = lower.find(keyword)?; + let after = line.get(idx + keyword.len()..)?; + let after = after.trim_start_matches([':', ' ', '\t']); + if let Some(hex) = after + .strip_prefix("0x") + .or_else(|| after.strip_prefix("0X")) + { + let digits: String = hex.chars().take_while(char::is_ascii_hexdigit).collect(); + u64::from_str_radix(&digits, 16).ok() + } else { + let digits: String = after.chars().take_while(char::is_ascii_digit).collect(); + digits.parse().ok() + } +} + +/// Sum of the gap between one extent's end (`lcn + clusters`) and the +/// next extent's start, across `extents` -- `0` for a contiguous +/// (single-extent) file. +fn intra_file_seek_distance(extents: &[(u64, u64)]) -> u64 { + extents + .windows(2) + .map(|pair| { + let (lcn, clusters) = pair[0]; + let next_lcn = pair[1].0; + next_lcn.abs_diff(lcn + clusters) + }) + .sum() +} + +/// Prints how many of the sampled files are fragmented (more than one +/// on-disk extent) and the total intra-file seek distance this forces, +/// even under perfect (oracle) candidate ordering -- the seek cost that +/// LCN-sorting candidate *order* fundamentally cannot remove. +fn print_fragmentation_report(resolved: &[Resolved]) { + let fragmented: Vec<&Resolved> = resolved.iter().filter(|r| r.extent_count > 1).collect(); + let fragmented_pct = 100.0 * fragmented.len() as f64 / resolved.len() as f64; + let total_extents: usize = resolved.iter().map(|r| r.extent_count).sum(); + let avg_extents = total_extents as f64 / resolved.len() as f64; + let total_intra_file_clusters: u64 = resolved.iter().map(|r| r.intra_file_seek_clusters).sum(); + + let bytes_per_cluster = resolved + .first() + .and_then(|r| drive_root(&r.path)) + .and_then(|root| query_bytes_per_cluster(&root)); + + println!(); + println!("=== Fragmentation ==="); + println!( + "{} / {} sampled files ({fragmented_pct:.1}%) span more than one on-disk extent.", + fragmented.len(), + resolved.len() + ); + println!("Average extents per file: {avg_extents:.2} (1.00 = perfectly contiguous)."); + print_distance_line( + "Total intra-file seek distance (unavoidable even under oracle ordering)", + total_intra_file_clusters, + bytes_per_cluster, + ); + + if let Some(worst) = fragmented.iter().max_by_key(|r| r.extent_count) { + println!( + "Most-fragmented sampled file: {} extents -- {}", + worst.extent_count, worst.path + ); + } + + if fragmented.is_empty() { + println!("No fragmentation in this sample -- LCN ordering alone should suffice."); + } else if fragmented_pct > 20.0 { + println!( + "Significant fragmentation -- even a fully LCN-ordered read pipeline will keep \ + seeking mid-file for a meaningful share of candidates on this volume." + ); } - None } /// Spearman rank correlation between FRS order and LCN order across @@ -441,13 +541,17 @@ fn default_output_csv(json_path: &str) -> std::path::PathBuf { } fn write_csv(path: &Path, resolved: &[Resolved]) { - let mut out = String::from("Path,NaturalIndex,Frs,Lcn\n"); + let mut out = String::from("Path,NaturalIndex,Frs,Lcn,ExtentCount,IntraFileSeekClusters\n"); for row in resolved { // Paths can contain commas/quotes -- quote and escape per RFC 4180. let escaped_path = row.path.replace('"', "\"\""); out.push_str(&format!( - "\"{escaped_path}\",{},{},{}\n", - row.natural_index, row.frs, row.lcn + "\"{escaped_path}\",{},{},{},{},{}\n", + row.natural_index, + row.frs, + row.lcn, + row.extent_count, + row.intra_file_seek_clusters )); } if let Err(err) = fs::write(path, out) { diff --git a/scripts/windows/measure_raw_throughput.rs b/scripts/windows/measure_raw_throughput.rs new file mode 100644 index 000000000..95b15a0f4 --- /dev/null +++ b/scripts/windows/measure_raw_throughput.rs @@ -0,0 +1,320 @@ +#!/usr/bin/env rust-script +//! ```cargo +//! [target.'cfg(windows)'.dependencies] +//! windows = { version = "0.62", features = [ +//! "Win32_Foundation", +//! "Win32_Security", +//! "Win32_Storage_FileSystem", +//! "Win32_System_IO", +//! ] } +//! ``` +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Measures the raw sequential-read throughput floor of a physical +//! volume, independent of `uffs-content`'s per-file read pipeline -- +//! a `dd`-style streaming read straight off the block device. +//! +//! Real-hardware benchmarking kept showing sustained content-read +//! throughput in the single-digit MiB/s range on some drives even +//! after ascending-FRS ordering and true-LCN-sorted ordering were both +//! in place (see `check_frs_vs_lcn.rs`). Without a floor measurement, +//! "the pipeline is slow" and "this drive simply cannot go faster than +//! ~8 MiB/s at this access pattern" are indistinguishable -- this tool +//! answers that by reading raw bytes sequentially off the volume, +//! bypassing candidate enumeration, `OpenFileById`, and every other +//! layer of the real pipeline entirely. +//! +//! Reads three zones by default (outer edge, middle, inner edge of the +//! volume) since HDDs are markedly faster at the outer edge (larger +//! track circumference) than the inner one -- a single-point +//! measurement can be misleadingly optimistic or pessimistic depending +//! on where it happens to land. Reports per-zone MiB/s plus the +//! per-chunk min/max within each zone, so a zone whose *average* looks +//! fine but which stalls badly on some chunks (a sign of bad sectors, +//! thermal throttling, or a drive silently retrying) is still visible. +//! +//! # Usage +//! ```text +//! rust-script scripts/windows/measure_raw_throughput.rs [zone_mib=512] [chunk_mib=4] [device_path] +//! ``` +//! `device_path` optionally overrides `\\.\:` -- pass a VSS +//! snapshot device path (e.g. from a `uffs-broker --run` log's +//! `device=\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyNNN` line) to +//! measure the same device `uffs-content` actually reads through, +//! rather than the live volume. + +#[cfg(windows)] +mod imp { + use std::time::Instant; + + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_BEGIN, FILE_FLAG_SEQUENTIAL_SCAN, FILE_GENERIC_READ, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileSizeEx, OPEN_EXISTING, ReadFile, + SetFilePointerEx, + }; + use windows::core::PCWSTR; + + /// One probe location within the volume. + struct Zone { + label: &'static str, + /// Fraction of the volume's total size to seek to before reading + /// (clamped so the read never runs off the end). + fraction: f64, + } + + const ZONES: [Zone; 3] = [ + Zone { + label: "outer edge (start of volume)", + fraction: 0.02, + }, + Zone { + label: "middle of volume", + fraction: 0.50, + }, + Zone { + label: "inner edge (near end of volume)", + fraction: 0.90, + }, + ]; + + pub fn main() { + let args: Vec = std::env::args().collect(); + let Some(drive) = args.get(1) else { + eprintln!( + "usage: measure_raw_throughput.rs [zone_mib=512] [chunk_mib=4] \ + [device_path]\n\ + \n\ + Reads zone_mib of raw sequential data from each of three zones (outer/\n\ + middle/inner) on DriveLetter (or device_path, if given) and reports \n\ + MiB/s -- the raw physical floor, independent of uffs-content's own \n\ + per-file read pipeline." + ); + std::process::exit(2); + }; + let zone_mib: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(512); + let chunk_mib: u64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(4); + let device_override = args.get(4).cloned(); + + let path = device_override.unwrap_or_else(|| format!("\\\\.\\{drive}:")); + println!("Opening {path} ..."); + let handle = match open_read_handle(&path) { + Ok(handle) => handle, + Err(err) => { + eprintln!( + "failed to open {path}: {err}\n(needs Administrator to open a raw volume \ + handle)" + ); + std::process::exit(1); + } + }; + + let volume_bytes = match query_size(handle) { + Ok(size) => size, + Err(err) => { + eprintln!("failed to query volume size: {err}"); + close(handle); + std::process::exit(1); + } + }; + println!( + "Volume size: {:.1} GiB", + volume_bytes as f64 / (1024.0 * 1024.0 * 1024.0) + ); + + let chunk_bytes = chunk_mib * 1024 * 1024; + let zone_bytes = (zone_mib * 1024 * 1024).min(volume_bytes / 4); + let mut zone_results = Vec::with_capacity(ZONES.len()); + + for zone in &ZONES { + let max_offset = volume_bytes.saturating_sub(zone_bytes); + let offset = ((volume_bytes as f64 * zone.fraction) as u64).min(max_offset); + println!(); + println!( + "=== {} (offset {:.1} GiB, reading {} MiB) ===", + zone.label, + offset as f64 / (1024.0 * 1024.0 * 1024.0), + zone_bytes / (1024 * 1024) + ); + match read_zone(handle, offset, zone_bytes, chunk_bytes) { + Ok(result) => { + println!( + " {:.1} MiB/s average ({} chunks; fastest chunk {:.1} MiB/s, slowest \ + chunk {:.1} MiB/s)", + result.mib_per_sec, + result.chunk_count, + result.fastest_chunk_mib_per_sec, + result.slowest_chunk_mib_per_sec + ); + zone_results.push((zone.label, result.mib_per_sec)); + } + Err(err) => eprintln!(" read failed: {err}"), + } + } + + close(handle); + + if zone_results.is_empty() { + eprintln!("No zone read succeeded -- can't report a floor."); + std::process::exit(1); + } + + println!(); + println!("=== Summary ==="); + let slowest = zone_results + .iter() + .copied() + .fold(f64::INFINITY, |acc, (_, mib)| acc.min(mib)); + let fastest = zone_results + .iter() + .copied() + .fold(0.0_f64, |acc, (_, mib)| acc.max(mib)); + for (label, mib) in &zone_results { + println!(" {label}: {mib:.1} MiB/s"); + } + println!(); + println!( + "Raw sequential floor for this device: ~{slowest:.1} MiB/s (slowest zone) to \ + ~{fastest:.1} MiB/s (fastest zone)." + ); + println!( + "Compare against uffs-content's own \"mib_per_sec_since_job_start\" progress lines \ + for the same drive: if the pipeline number is close to this floor, the drive itself \ + is the bottleneck (ordering/concurrency can't help further); if the pipeline number \ + is far below even the slowest zone here, something in the read pattern (seeking, \ + per-file open/close overhead, fragmentation -- see check_frs_vs_lcn.rs) is still \ + costing real throughput." + ); + } + + /// One zone's read result. + struct ZoneResult { + mib_per_sec: f64, + chunk_count: usize, + fastest_chunk_mib_per_sec: f64, + slowest_chunk_mib_per_sec: f64, + } + + /// Seeks to `offset` and reads `total_bytes` sequentially in + /// `chunk_bytes`-sized calls, timing the whole zone and each + /// individual chunk. + fn read_zone( + handle: HANDLE, + offset: u64, + total_bytes: u64, + chunk_bytes: u64, + ) -> Result { + seek(handle, offset)?; + + let mut buf = vec![0_u8; usize::try_from(chunk_bytes).unwrap_or(4 * 1024 * 1024)]; + let mut remaining = total_bytes; + let mut chunk_count = 0_usize; + let mut fastest_mib_per_sec = 0.0_f64; + let mut slowest_mib_per_sec = f64::INFINITY; + let zone_started_at = Instant::now(); + + while remaining > 0 { + let want = remaining.min(chunk_bytes); + let want_len = usize::try_from(want).unwrap_or(buf.len()); + let dest = &mut buf[..want_len]; + let chunk_started_at = Instant::now(); + let mut bytes_read = 0_u32; + // SAFETY: `handle` is a valid, open, synchronous file handle + // for the duration of this call; `dest` is a valid, writable + // buffer sized to the requested read length. + let result = + unsafe { ReadFile(handle, Some(dest), Some(&raw mut bytes_read), None) }; + result.map_err(|err| format!("ReadFile failed: {err}"))?; + if bytes_read == 0 { + break; // hit end of volume before filling this zone + } + let chunk_secs = chunk_started_at.elapsed().as_secs_f64(); + if chunk_secs > 0.0 { + let chunk_mib_per_sec = (f64::from(bytes_read) / (1024.0 * 1024.0)) / chunk_secs; + fastest_mib_per_sec = fastest_mib_per_sec.max(chunk_mib_per_sec); + slowest_mib_per_sec = slowest_mib_per_sec.min(chunk_mib_per_sec); + } + chunk_count += 1; + remaining = remaining.saturating_sub(u64::from(bytes_read)); + } + + let zone_secs = zone_started_at.elapsed().as_secs_f64(); + let bytes_actually_read = total_bytes.saturating_sub(remaining); + let mib_per_sec = if zone_secs > 0.0 { + (bytes_actually_read as f64 / (1024.0 * 1024.0)) / zone_secs + } else { + 0.0 + }; + + Ok(ZoneResult { + mib_per_sec, + chunk_count, + fastest_chunk_mib_per_sec: fastest_mib_per_sec, + slowest_chunk_mib_per_sec: if slowest_mib_per_sec.is_finite() { + slowest_mib_per_sec + } else { + 0.0 + }, + }) + } + + /// Opens `path` (a `\\.\:` volume path or a VSS device path) + /// for sequential read access. + fn open_read_handle(path: &str) -> Result { + let wide: Vec = path + .encode_utf16() + .chain(core::iter::once(0)) + .collect(); + // SAFETY: `wide` is UTF-16 and NUL-terminated for the duration of + // this call; no other pointers are passed. + let handle = unsafe { + CreateFileW( + PCWSTR::from_raw(wide.as_ptr()), + FILE_GENERIC_READ.0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN, + None, + ) + }; + handle.map_err(|err| err.to_string()) + } + + /// Total size in bytes of the volume/device behind `handle`. + fn query_size(handle: HANDLE) -> Result { + let mut size: i64 = 0; + // SAFETY: `handle` is a valid, open handle; `size` is a valid + // out-pointer for the duration of the call. + unsafe { GetFileSizeEx(handle, &raw mut size) }.map_err(|err| err.to_string())?; + u64::try_from(size).map_err(|err| err.to_string()) + } + + /// Moves `handle`'s file pointer to `offset` bytes from the start. + fn seek(handle: HANDLE, offset: u64) -> Result<(), String> { + let distance = i64::try_from(offset).map_err(|err| err.to_string())?; + // SAFETY: `handle` is a valid, open handle; no output pointer is + // requested. + unsafe { SetFilePointerEx(handle, distance, None, FILE_BEGIN) } + .map_err(|err| err.to_string()) + } + + /// Closes `handle`, ignoring the (practically infallible) result. + fn close(handle: HANDLE) { + // SAFETY: `handle` was returned by a successful `CreateFileW` + // call above and is closed exactly once, here. + let _ = unsafe { CloseHandle(handle) }; + } +} + +#[cfg(windows)] +fn main() { + imp::main(); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("measure_raw_throughput.rs opens raw Windows volume handles -- Windows only."); + std::process::exit(1); +} From 018665920d9db85b195d0aaead7c3237c73960b8 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:08:47 -0700 Subject: [PATCH 88/98] fix(diag): query volume size via FSCTL_GET_NTFS_VOLUME_DATA, not GetFileSizeEx Real-hardware run against E:/M: failed immediately with ERROR_INVALID_PARAMETER: GetFileSizeEx does not work on raw volume/device handles, only regular file handles. Volume size now comes from FSCTL_GET_NTFS_VOLUME_DATA's TotalClusters * BytesPerCluster, the same ioctl uffs-mft's own VolumeHandle::get_ntfs_volume_data already uses against these exact handles for this exact reason. Verified compiling clean against the Windows target (cargo xwin check + clippy); still no live-hardware run available from this session. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/measure_raw_throughput.rs | 45 +++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/scripts/windows/measure_raw_throughput.rs b/scripts/windows/measure_raw_throughput.rs index 95b15a0f4..af28c7253 100644 --- a/scripts/windows/measure_raw_throughput.rs +++ b/scripts/windows/measure_raw_throughput.rs @@ -6,6 +6,7 @@ //! "Win32_Security", //! "Win32_Storage_FileSystem", //! "Win32_System_IO", +//! "Win32_System_Ioctl", //! ] } //! ``` // SPDX-License-Identifier: MPL-2.0 @@ -51,9 +52,10 @@ mod imp { use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Storage::FileSystem::{ CreateFileW, FILE_BEGIN, FILE_FLAG_SEQUENTIAL_SCAN, FILE_GENERIC_READ, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, GetFileSizeEx, OPEN_EXISTING, ReadFile, - SetFilePointerEx, + FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, ReadFile, SetFilePointerEx, }; + use windows::Win32::System::IO::DeviceIoControl; + use windows::Win32::System::Ioctl::{FSCTL_GET_NTFS_VOLUME_DATA, NTFS_VOLUME_DATA_BUFFER}; use windows::core::PCWSTR; /// One probe location within the volume. @@ -283,12 +285,41 @@ mod imp { } /// Total size in bytes of the volume/device behind `handle`. + /// + /// `GetFileSizeEx` does not work on raw volume/device handles (it + /// fails with `ERROR_INVALID_PARAMETER`) -- volume size instead + /// comes from `FSCTL_GET_NTFS_VOLUME_DATA`'s `TotalClusters * + /// BytesPerCluster`, the same ioctl `uffs-mft`'s own + /// `VolumeHandle::get_ntfs_volume_data` uses for the same reason. fn query_size(handle: HANDLE) -> Result { - let mut size: i64 = 0; - // SAFETY: `handle` is a valid, open handle; `size` is a valid - // out-pointer for the duration of the call. - unsafe { GetFileSizeEx(handle, &raw mut size) }.map_err(|err| err.to_string())?; - u64::try_from(size).map_err(|err| err.to_string()) + let mut volume_data = NTFS_VOLUME_DATA_BUFFER::default(); + let mut bytes_returned: u32 = 0; + let buffer_size = + u32::try_from(size_of::()).unwrap_or(u32::MAX); + + // SAFETY: `handle` is a valid, open volume handle; `volume_data` + // points to valid writable storage of `buffer_size` bytes; and + // `bytes_returned` is a valid out-pointer for the duration of + // this call. + unsafe { + DeviceIoControl( + handle, + FSCTL_GET_NTFS_VOLUME_DATA, + None, + 0, + Some(core::ptr::from_mut(&mut volume_data).cast()), + buffer_size, + Some(&raw mut bytes_returned), + None, + ) + } + .map_err(|err| format!("FSCTL_GET_NTFS_VOLUME_DATA failed: {err}"))?; + + let total_clusters = volume_data.TotalClusters.cast_unsigned(); + let bytes_per_cluster = u64::from(volume_data.BytesPerCluster); + total_clusters + .checked_mul(bytes_per_cluster) + .ok_or_else(|| "volume size overflowed u64".to_owned()) } /// Moves `handle`'s file pointer to `offset` bytes from the start. From ba8076bf312265706632fb75090b6cb1f8b4c805 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:20:23 -0700 Subject: [PATCH 89/98] fix(diag): align raw-throughput zone offsets to sector boundaries Real-hardware run against C:/D: showed the outer-edge zone (2% offset) failing with ERROR_INVALID_PARAMETER while middle/inner zones (0.5/0.9 fractions) happened to succeed: volume-handle I/O requires a sector-aligned offset even for buffered reads, and an arbitrary fraction of the volume size essentially never lands on one by chance. Rounds every zone's offset down to a 1 MiB boundary before seeking, and changes read_zone to only ever issue full chunk_bytes-sized reads (dropping a less-than-a-full-chunk remainder) so a ragged final read can't hit the same alignment failure from the length side. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/measure_raw_throughput.rs | 34 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/windows/measure_raw_throughput.rs b/scripts/windows/measure_raw_throughput.rs index af28c7253..81142e172 100644 --- a/scripts/windows/measure_raw_throughput.rs +++ b/scripts/windows/measure_raw_throughput.rs @@ -131,7 +131,16 @@ mod imp { for zone in &ZONES { let max_offset = volume_bytes.saturating_sub(zone_bytes); - let offset = ((volume_bytes as f64 * zone.fraction) as u64).min(max_offset); + let raw_offset = ((volume_bytes as f64 * zone.fraction) as u64).min(max_offset); + // Volume-handle I/O requires a sector-aligned offset (fails + // with ERROR_INVALID_PARAMETER otherwise) even for buffered + // reads -- unlike a regular file, there's no cache-manager + // layer translating an arbitrary byte offset for you. A + // fraction like 0.02 or 0.9 essentially never lands on a + // sector boundary by chance, so round down to a 1 MiB + // boundary, comfortably covering every real sector/stripe + // size in use today. + let offset = align_down(raw_offset, OFFSET_ALIGNMENT); println!(); println!( "=== {} (offset {:.1} GiB, reading {} MiB) ===", @@ -198,9 +207,24 @@ mod imp { slowest_chunk_mib_per_sec: f64, } + /// Byte offsets and read lengths against a raw volume handle must be + /// sector-aligned (Windows rejects anything else with + /// `ERROR_INVALID_PARAMETER`, even for buffered/cached access) -- + /// 1 MiB comfortably covers every real physical/logical sector or + /// stripe size in use today. + const OFFSET_ALIGNMENT: u64 = 1024 * 1024; + + /// Rounds `value` down to the nearest multiple of `alignment`. + const fn align_down(value: u64, alignment: u64) -> u64 { + value - (value % alignment) + } + /// Seeks to `offset` and reads `total_bytes` sequentially in /// `chunk_bytes`-sized calls, timing the whole zone and each - /// individual chunk. + /// individual chunk. Only ever issues full `chunk_bytes`-sized reads + /// -- a short final read would need its own (smaller) alignment + /// reasoning, so any less-than-a-full-chunk remainder is simply left + /// unread rather than risking a second alignment failure mode. fn read_zone( handle: HANDLE, offset: u64, @@ -216,10 +240,8 @@ mod imp { let mut slowest_mib_per_sec = f64::INFINITY; let zone_started_at = Instant::now(); - while remaining > 0 { - let want = remaining.min(chunk_bytes); - let want_len = usize::try_from(want).unwrap_or(buf.len()); - let dest = &mut buf[..want_len]; + while remaining >= chunk_bytes { + let dest = &mut buf[..]; let chunk_started_at = Instant::now(); let mut bytes_read = 0_u32; // SAFETY: `handle` is a valid, open, synchronous file handle From 042ed9f3cff37f716beb8c76c90bb1fe1fb5f135 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:41:16 -0700 Subject: [PATCH 90/98] fix(diag): filter sparse-hole LCN sentinel from fragmentation stats Real-hardware runs against E:/M: produced an absurd "72 quadrillion MiB" intra-file seek distance -- traced to fsutil file queryextents reporting sparse-file holes with Lcn = 0xFFFFFFFFFFFFFFFF (the standard "no on-disk allocation" sentinel, not a real physical location). Any file with such a hole swamped the whole sample's total since u64::MAX dwarfs every real LCN by many orders of magnitude. query_all_extents now drops extents at that sentinel entirely (they carry no real seek cost, since there's nothing to seek to), and intra_file_seek_distance uses saturating arithmetic as defense in depth against any other unexpected value. Verified with a standalone rustc test simulating a real-extent/hole/real-extent sequence. Co-Authored-By: Claude Sonnet 5 --- scripts/windows/check_frs_vs_lcn.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/windows/check_frs_vs_lcn.rs b/scripts/windows/check_frs_vs_lcn.rs index cb30ff44d..feff5334b 100644 --- a/scripts/windows/check_frs_vs_lcn.rs +++ b/scripts/windows/check_frs_vs_lcn.rs @@ -385,13 +385,22 @@ fn query_bytes_per_cluster(drive_root: &str) -> Option { None } +/// NTFS's convention (shared with `FSCTL_GET_RETRIEVAL_POINTERS`) for "this +/// VCN range has no on-disk allocation" -- a sparse-file hole -- is an `Lcn` +/// of all-ones (`-1` as a signed 64-bit quantity). `fsutil file queryextents` +/// prints that literally, and it is NOT a real physical location: treating +/// it as one previously corrupted every seek-distance sum it appeared in, +/// since `u64::MAX` swamps every real LCN by many orders of magnitude. +const SPARSE_HOLE_LCN: u64 = u64::MAX; + /// Run `fsutil file queryextents` and return every `(Lcn, Clusters)` pair /// it prints, in the order given -- i.e. in ascending VCN (logical /// offset within the file) order, since that's the order `fsutil` lists /// a file's runs in. A file with more than one entry is fragmented: its /// own data is split across non-adjacent runs on disk, so reading it in /// full requires a seek at each run boundary no matter how well the -/// *candidate* read order is chosen. +/// *candidate* read order is chosen. Sparse-file holes ([`SPARSE_HOLE_LCN`]) +/// are dropped, not just any other extent -- see that const's doc comment. /// /// Tolerant of hex (`0x...`) or decimal values and of the labels' exact /// wording/case, since both have drifted across Windows versions. @@ -410,6 +419,9 @@ fn query_all_extents(path: &str) -> Vec<(u64, u64)> { .lines() .filter_map(|line| { let lcn = extract_number_after(line, "lcn")?; + if lcn == SPARSE_HOLE_LCN { + return None; + } let clusters = extract_number_after(line, "cluster").unwrap_or(0); Some((lcn, clusters)) }) @@ -445,9 +457,9 @@ fn intra_file_seek_distance(extents: &[(u64, u64)]) -> u64 { .map(|pair| { let (lcn, clusters) = pair[0]; let next_lcn = pair[1].0; - next_lcn.abs_diff(lcn + clusters) + next_lcn.abs_diff(lcn.saturating_add(clusters)) }) - .sum() + .fold(0_u64, u64::saturating_add) } /// Prints how many of the sampled files are fragmented (more than one From c596e5c6cc181e49c48e6d590bc332ce9f2c5fc3 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:54:32 -0700 Subject: [PATCH 91/98] feat(content-reader): instrument per-file open/close timing Real-hardware benchmarking against E:/M: showed pipeline throughput far below their own raw sequential floor even with zero fragmentation, pointing at per-file open/close overhead as the likely cause -- but that was still a theory, not a measurement. read_logical now times each phase of a cache-miss (open_volume_hint, open_file_by_id, file_size) plus the read itself, and logs one structured debug-level line per call for later aggregation. That data was previously unreachable: uffs-content-reader's own tracing subscriber writes to stderr with no level cap, but its stderr was piped to Stdio::null() by the Coordinator, discarding every event including this new timing. reader_client.rs now redirects it to a discoverable temp log file instead (mirroring ephemeral_daemon's --log-file for uffsd), and logs that path in the Coordinator's own log. Diagnostic instrumentation only -- no behavior change to the read path itself. Full gate (host + xwin check/clippy plain and pedantic/nursery, fmt, file-size, workspace tests, rustdoc, doctests, xwin test --no-run) passes clean. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content-reader/src/reader/logical.rs | 46 +++++++++++++++++++ crates/uffs-content/src/job/reader_client.rs | 16 ++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/uffs-content-reader/src/reader/logical.rs b/crates/uffs-content-reader/src/reader/logical.rs index b8db88df4..197e88d15 100644 --- a/crates/uffs-content-reader/src/reader/logical.rs +++ b/crates/uffs-content-reader/src/reader/logical.rs @@ -48,7 +48,9 @@ //! whether that string was ever handed out at all. use core::mem::size_of; +use core::time::Duration; use std::os::windows::ffi::OsStrExt as _; +use std::time::Instant; use uffs_content_reader_protocol::ActualReadMode; use windows::Win32::Foundation::{CloseHandle, HANDLE}; @@ -277,15 +279,33 @@ pub(crate) fn read_logical( maximum_logical_length: u32, cache: ReadHandleCache, ) -> anyhow::Result<(Vec, ActualReadMode, ReadHandleCache)> { + let call_started_at = Instant::now(); + let cache_hit = matches!( + &cache.0, + Some(cached) if cached.full_file_reference == full_file_reference + ); + + let mut open_volume_hint_time = Duration::ZERO; + let mut open_file_by_id_time = Duration::ZERO; + let mut file_size_time = Duration::ZERO; let (file_handle, eof) = match cache.0 { Some(cached) if cached.full_file_reference == full_file_reference => { (cached.handle, cached.eof) } _ => { + let volume_hint_started_at = Instant::now(); let volume_hint = open_volume_hint(device_path)?; + open_volume_hint_time = volume_hint_started_at.elapsed(); + + let open_by_id_started_at = Instant::now(); let file_handle = open_file_by_id(&volume_hint, full_file_reference)?; + open_file_by_id_time = open_by_id_started_at.elapsed(); drop(volume_hint); + + let file_size_started_at = Instant::now(); let eof = file_size(&file_handle)?; + file_size_time = file_size_started_at.elapsed(); + (file_handle, eof) } }; @@ -295,14 +315,33 @@ pub(crate) fn read_logical( .map_err(|err| anyhow::anyhow!("{err}"))?; let mut payload = Vec::with_capacity(plan.total_len() as usize); + let read_started_at = Instant::now(); if plan.real_bytes > 0 { seek(&file_handle, logical_offset)?; let mut real_buf = vec![0_u8; plan.real_bytes as usize]; read_exact(&file_handle, &mut real_buf)?; payload.extend_from_slice(&real_buf); } + let read_time = read_started_at.elapsed(); payload.resize(payload.len() + plan.zero_bytes as usize, 0); + // PROFILING (temporary — see the "confirm the per-file open/close + // overhead theory" investigation): one structured line per call, + // broken down by phase, so a real run's log can be aggregated + // (`grep 'read_logical: per-phase timing'` + average each field) to + // see whether open/close overhead or actual disk I/O dominates total + // read time for a given drive's workload. + tracing::debug!( + cache_hit, + real_bytes = plan.real_bytes, + open_volume_hint_us = duration_micros(open_volume_hint_time), + open_file_by_id_us = duration_micros(open_file_by_id_time), + file_size_us = duration_micros(file_size_time), + read_us = duration_micros(read_time), + total_us = duration_micros(call_started_at.elapsed()), + "read_logical: per-phase timing" + ); + let updated_cache = ReadHandleCache(Some(CachedFileHandle { full_file_reference, handle: file_handle, @@ -311,3 +350,10 @@ pub(crate) fn read_logical( Ok((payload, ActualReadMode::Logical, updated_cache)) } + +/// Converts `duration` to whole microseconds, saturating instead of +/// panicking — only used for diagnostic log fields, where a saturated +/// value is still obviously "very large" rather than a silent wrap. +fn duration_micros(duration: Duration) -> u64 { + u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) +} diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index 23b2aeb20..b03d5abd4 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -158,11 +158,24 @@ impl ContentReader { ); let exe = find_reader_exe(); + // uffs-content-reader's own tracing subscriber writes to its + // process's stderr with no level cap (see its main.rs), but + // stderr used to be piped to Stdio::null() — discarding every + // one of its events, including the per-phase read timing this + // crate's own `logical.rs` can emit at debug level. Redirecting + // to a discoverable file (mirroring `ephemeral_daemon`'s + // `--log-file` for uffsd) makes that timing data actually + // retrievable for a real-hardware investigation instead of + // silently vanishing. + let job_id_str = uuid::Uuid::from_bytes(job_id).simple().to_string(); + let log_file = std::env::temp_dir().join(format!("uffs-content-reader-{job_id_str}.log")); + let log_file_handle = std::fs::File::create(&log_file) + .with_context(|| format!("failed to create {}", log_file.display()))?; let mut command = Command::new(&exe); command .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::from(log_file_handle)); for (device_path, lease_id, _pool_size) in devices { command .arg("--device") @@ -171,6 +184,7 @@ impl ContentReader { tracing::info!( exe = %exe.display(), device_count = devices.len(), + log_file = %log_file.display(), "content reader: spawning uffs-content-reader" ); let child = command From a903e6d0d2addcea68c54e81d9bcb6f8f73639f5 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:04:18 -0700 Subject: [PATCH 92/98] perf(content-reader): cache the volume-hint handle across candidates open_volume_hint's CreateFileW was being opened and closed fresh for every single candidate, even though it only identifies the volume (never the file) and every candidate on one connection is read from the same volume -- one physical connection is only ever drawn from one lease's pool, so device_path never actually changes mid-connection. ReadHandleCache now caches this handle the same way it already caches the per-file handle, reopening only if device_path ever changed (a belt-and-suspenders check, not something that happens in practice). Removes one whole CreateFileW+CloseHandle cycle per candidate at no correctness cost -- real-hardware benchmarking against small-file-heavy drives found this handle was pure per-file waste today. Full gate (host + xwin check/clippy plain and pedantic/nursery, xwin doc for the cfg(windows)-gated intra-doc links, fmt, file-size, workspace tests, doctests, xwin test --no-run) passes clean. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content-reader/src/reader/logical.rs | 92 +++++++++++++++---- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/crates/uffs-content-reader/src/reader/logical.rs b/crates/uffs-content-reader/src/reader/logical.rs index 197e88d15..507008cc5 100644 --- a/crates/uffs-content-reader/src/reader/logical.rs +++ b/crates/uffs-content-reader/src/reader/logical.rs @@ -29,6 +29,18 @@ //! `uffs-content::job::reader_client`'s module doc) rather than //! round-robining a chunk at a time across the pool. //! +//! [`ReadHandleCache`] also caches [`open_volume_hint`]'s handle, +//! independent of which file is currently being read: real-hardware +//! benchmarking against small-file-heavy drives (thousands of tiny +//! `.txt`/driver-readme files, each its own candidate) found this handle +//! was being opened and closed **fresh for every single candidate**, +//! even though it only identifies the *volume* — never the file — and +//! every candidate on one connection is read from the same volume (one +//! connection is only ever drawn from one lease's pool, so `device_path` +//! never actually changes mid-connection). Caching it removes one whole +//! `CreateFileW`+`CloseHandle` cycle per candidate at no correctness +//! cost, on top of the file-handle caching above. +//! //! # v1 simplifications (documented, not silent) //! //! - **VDL is treated as equal to EOF.** Getting the true NTFS valid data @@ -105,13 +117,31 @@ unsafe impl Send for OwnedHandle {} /// `pub(crate)` (rather than living entirely inside this module) because /// `pipe_server` owns one instance per connection and threads it through /// every request on that connection, across a `spawn_blocking` boundary. +/// +/// Also caches the [`open_volume_hint`] handle used to resolve +/// `OpenFileById` calls, independent of which file is being read — +/// real-hardware benchmarking against small-file-heavy drives (drives +/// dominated by tiny `.txt`/driver-readme files) found this handle was +/// being opened and closed **fresh for every single candidate**, even +/// though it identifies only the *volume*, not the file, and every +/// candidate on one connection is read from the same volume. Caching it +/// removes one whole `CreateFileW`+`CloseHandle` cycle per candidate +/// with no correctness cost. #[derive(Default)] -pub(crate) struct ReadHandleCache(Option); +pub(crate) struct ReadHandleCache { + /// The cached open file handle, if any — see [`CachedFileHandle`]. + file: Option, + /// The cached volume-hint handle, if any — see [`CachedVolumeHint`]. + volume_hint: Option, +} impl ReadHandleCache { /// A fresh cache holding nothing — one per new connection. pub(crate) const fn empty() -> Self { - Self(None) + Self { + file: None, + volume_hint: None, + } } } @@ -133,6 +163,20 @@ struct CachedFileHandle { eof: u64, } +/// One cached [`open_volume_hint`] handle plus the `device_path` it was +/// opened against — [`read_logical`] reuses it for every candidate on +/// this connection, only reopening if `device_path` ever actually +/// changes (never happens in practice, since one physical connection is +/// only ever drawn from one lease's pool — see +/// `uffs-content::job::reader_client`'s module doc — but checking rather +/// than assuming keeps this correct even if that ever changed). +struct CachedVolumeHint { + /// The device path this handle was opened against. + device_path: String, + /// The open handle itself. + handle: OwnedHandle, +} + /// Encode `text` as a NUL-terminated UTF-16 buffer for `PCWSTR` FFI calls. fn to_wide_null(text: &str) -> Vec { std::ffi::OsStr::new(text) @@ -142,8 +186,11 @@ fn to_wide_null(text: &str) -> Vec { } /// Open a handle to the snapshot device's volume root — used only as -/// `OpenFileById`'s volume hint, then dropped immediately after that -/// call returns (the returned file handle is independent of it). +/// `OpenFileById`'s volume hint. The file handle `OpenFileById` returns +/// is entirely independent of this one once opened, so the caller is +/// free to keep this handle around and reuse it across many +/// `OpenFileById` calls rather than reopening it per file — see +/// [`CachedVolumeHint`]. fn open_volume_hint(device_path: &str) -> anyhow::Result { let wide = to_wide_null(device_path); #[expect( @@ -281,26 +328,34 @@ pub(crate) fn read_logical( ) -> anyhow::Result<(Vec, ActualReadMode, ReadHandleCache)> { let call_started_at = Instant::now(); let cache_hit = matches!( - &cache.0, + &cache.file, Some(cached) if cached.full_file_reference == full_file_reference ); let mut open_volume_hint_time = Duration::ZERO; + let volume_hint = match cache.volume_hint { + Some(cached) if cached.device_path == device_path => cached, + _ => { + let volume_hint_started_at = Instant::now(); + let handle = open_volume_hint(device_path)?; + open_volume_hint_time = volume_hint_started_at.elapsed(); + CachedVolumeHint { + device_path: device_path.to_owned(), + handle, + } + } + }; + let mut open_file_by_id_time = Duration::ZERO; let mut file_size_time = Duration::ZERO; - let (file_handle, eof) = match cache.0 { + let (file_handle, eof) = match cache.file { Some(cached) if cached.full_file_reference == full_file_reference => { (cached.handle, cached.eof) } _ => { - let volume_hint_started_at = Instant::now(); - let volume_hint = open_volume_hint(device_path)?; - open_volume_hint_time = volume_hint_started_at.elapsed(); - let open_by_id_started_at = Instant::now(); - let file_handle = open_file_by_id(&volume_hint, full_file_reference)?; + let file_handle = open_file_by_id(&volume_hint.handle, full_file_reference)?; open_file_by_id_time = open_by_id_started_at.elapsed(); - drop(volume_hint); let file_size_started_at = Instant::now(); let eof = file_size(&file_handle)?; @@ -342,11 +397,14 @@ pub(crate) fn read_logical( "read_logical: per-phase timing" ); - let updated_cache = ReadHandleCache(Some(CachedFileHandle { - full_file_reference, - handle: file_handle, - eof, - })); + let updated_cache = ReadHandleCache { + file: Some(CachedFileHandle { + full_file_reference, + handle: file_handle, + eof, + }), + volume_hint: Some(volume_hint), + }; Ok((payload, ActualReadMode::Logical, updated_cache)) } From d5ed62108048ed042d42cf57437ade5a40671805 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:50:51 -0700 Subject: [PATCH 93/98] feat(content-reader): opt-in known_logical_size skips GetFileSizeEx Real-hardware benchmarking against small-file-heavy drives found GetFileSizeEx a real fraction of per-candidate time even after the volume-hint and file-handle caching already in place. The manifest size the real VSS Coordinator already knows for each candidate (read from the exact same frozen snapshot the Reader opens the file against) makes that re-query redundant for the one real production caller. ReadRequest gains known_logical_size: Option -- opt-in per request, never a blanket trust-model change to read_logical itself. Only VssCandidateSource/ContentReader::begin_read populates it (via CandidateEntry::logical_size); any other/future caller that leaves it None gets the original always-re-verify GetFileSizeEx behavior with no code changes needed on its part. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content-reader-protocol/src/lib.rs | 54 +++++++++++++++++++ crates/uffs-content-reader/src/reader.rs | 1 + .../uffs-content-reader/src/reader/logical.rs | 39 +++++++++++--- crates/uffs-content/src/job/content_source.rs | 1 + crates/uffs-content/src/job/reader_client.rs | 18 ++++++- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/crates/uffs-content-reader-protocol/src/lib.rs b/crates/uffs-content-reader-protocol/src/lib.rs index 628e8b4b7..e17446b05 100644 --- a/crates/uffs-content-reader-protocol/src/lib.rs +++ b/crates/uffs-content-reader-protocol/src/lib.rs @@ -41,6 +41,29 @@ pub const MAX_IDENTIFIER_BYTES: u32 = 512; /// Maximum byte length for a free-text diagnostic message. const MAX_MESSAGE_BYTES: u16 = 4096; +/// Append an `Option` as a presence byte followed by the value if +/// present — mirrors `uffs-content-protocol::frame::write_optional_u64` +/// (this crate deliberately duplicates rather than depends on that +/// Layer-0 crate; see [`codec`]'s own module doc). +fn write_optional_u64(out: &mut Vec, value: Option) { + match value { + Some(present_value) => { + out.push(1); + write_u64_le(out, present_value); + } + None => out.push(0), + } +} + +/// Read an `Option` encoded by [`write_optional_u64`]. +fn read_optional_u64(reader: &mut Reader<'_>) -> Result, DecodeError> { + let present = reader.read_u8()?; + match present { + 0 => Ok(None), + _ => Ok(Some(reader.read_u64_le()?)), + } +} + /// A volume's identity, as carried in a [`ReadRequest`] (addendum §2.3). #[derive(Debug, Clone, PartialEq, Eq)] pub struct VolumeIdentity { @@ -252,6 +275,18 @@ pub struct ReadRequest { pub volume_identity: VolumeIdentity, /// Full NTFS file reference (never a bare MFT record index). pub full_file_reference: u64, + /// The candidate's logical size, if the Coordinator already knows it + /// from the manifest that named this candidate. `Some` lets the + /// Reader skip its own `GetFileSizeEx` re-resolution and trust this + /// value directly — a real (if rare) trust tradeoff, so this is + /// opt-in per request, not a blanket assumption: only the real VSS + /// Coordinator (`uffs-content::job::content_source::VssContentSource`) + /// populates it today, since its manifest size was itself read from + /// the same frozen snapshot this request targets. Any other/future + /// caller that leaves this `None` gets the Reader's original + /// always-re-verify behavior with no code changes required on its + /// part — see `uffs-content-reader::reader::logical`'s module doc. + pub known_logical_size: Option, /// Which stream to read. pub stream_kind: StreamKind, /// Logical byte offset to start reading at. @@ -274,6 +309,7 @@ impl ReadRequest { write_u64_le(&mut out, self.candidate_id); self.volume_identity.encode(&mut out); write_u64_le(&mut out, self.full_file_reference); + write_optional_u64(&mut out, self.known_logical_size); out.push(self.stream_kind.encode()); write_u64_le(&mut out, self.logical_offset); write_u32_le(&mut out, self.maximum_logical_length); @@ -292,6 +328,7 @@ impl ReadRequest { let candidate_id = reader.read_u64_le()?; let volume_identity = VolumeIdentity::decode(reader)?; let full_file_reference = reader.read_u64_le()?; + let known_logical_size = read_optional_u64(reader)?; let stream_kind_byte = reader.read_u8()?; let stream_kind = StreamKind::decode(stream_kind_byte).map_err(|byte| { DecodeError::UnknownDiscriminant { @@ -315,6 +352,7 @@ impl ReadRequest { candidate_id, volume_identity, full_file_reference, + known_logical_size, stream_kind, logical_offset, maximum_logical_length, @@ -446,6 +484,7 @@ mod tests { volume_guid: b"{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}".to_vec(), }, full_file_reference: 0xABCD_EF01_2345_6789, + known_logical_size: Some(65536), stream_kind: StreamKind::UnnamedData, logical_offset: 4096, maximum_logical_length: 65536, @@ -464,6 +503,19 @@ mod tests { assert_eq!(reader.remaining(), 0); } + #[test] + fn read_request_with_no_known_logical_size_round_trips() { + let request = ReadRequest { + known_logical_size: None, + ..sample_request() + }; + let bytes = request.encode(); + let mut reader = Reader::new(&bytes); + let decoded = ReadRequest::decode(&mut reader).unwrap(); + assert_eq!(decoded, request); + assert_eq!(reader.remaining(), 0); + } + #[test] fn read_response_bytes_round_trips() { let response = ReadResponse::Bytes { @@ -555,6 +607,7 @@ mod tests { candidate_id: u64, volume_serial: u64, full_file_reference: u64, + known_logical_size: Option, logical_offset: u64, maximum_logical_length: u32, request_nonce: u64, @@ -568,6 +621,7 @@ mod tests { volume_guid: b"{guid}".to_vec(), }, full_file_reference, + known_logical_size, stream_kind: StreamKind::UnnamedData, logical_offset, maximum_logical_length, diff --git a/crates/uffs-content-reader/src/reader.rs b/crates/uffs-content-reader/src/reader.rs index 4316b4f21..be75823f3 100644 --- a/crates/uffs-content-reader/src/reader.rs +++ b/crates/uffs-content-reader/src/reader.rs @@ -78,6 +78,7 @@ fn dispatch_request( match logical::read_logical( device_path, request.full_file_reference, + request.known_logical_size, request.logical_offset, request.maximum_logical_length, cache, diff --git a/crates/uffs-content-reader/src/reader/logical.rs b/crates/uffs-content-reader/src/reader/logical.rs index 507008cc5..2f9e4eba7 100644 --- a/crates/uffs-content-reader/src/reader/logical.rs +++ b/crates/uffs-content-reader/src/reader/logical.rs @@ -41,6 +41,22 @@ //! `CreateFileW`+`CloseHandle` cycle per candidate at no correctness //! cost, on top of the file-handle caching above. //! +//! # Trusting a caller-supplied size (opt-in per request) +//! +//! [`read_logical`]'s `known_logical_size` parameter, when `Some`, skips +//! the `GetFileSizeEx` re-resolution on a cache miss entirely and uses +//! the caller's value as `EOF` directly — real-hardware benchmarking +//! against small-file-heavy drives found `GetFileSizeEx` a real fraction +//! of per-candidate time even after the caching above. This is a genuine +//! (if rare) trust tradeoff: the value could theoretically be stale, so +//! it is opt-in per request via `ReadRequest::known_logical_size`, never +//! a blanket change to this function's own default behavior. Only the +//! real Coordinator populates it (`VssCandidateSource`'s manifest size +//! comes from parsing the exact same frozen snapshot this read targets, +//! so the two should always agree); any request that leaves it `None` +//! gets the original always-re-verify behavior with no code changes +//! required on the caller's part. +//! //! # v1 simplifications (documented, not silent) //! //! - **VDL is treated as equal to EOF.** Getting the true NTFS valid data @@ -301,9 +317,11 @@ fn read_exact(handle: &OwnedHandle, buf: &mut [u8]) -> anyhow::Result<()> { /// Perform one logical read: reuse `cache`'s open handle if it's already /// open against `full_file_reference` (see this module's doc comment), -/// else open fresh by file reference and re-resolve `EOF`; apply the -/// VDL/EOF rule, and read the resulting real-byte range (zero-extending -/// per the plan). +/// else open fresh by file reference and resolve `EOF` — trusting +/// `known_logical_size` directly if `Some`, else re-querying via +/// `GetFileSizeEx` (see the "Trusting a caller-supplied size" doc +/// section for the tradeoff); apply the VDL/EOF rule, and read the +/// resulting real-byte range (zero-extending per the plan). /// /// Returns the (possibly newly-opened) handle back as an updated /// [`ReadHandleCache`] for the caller to reuse on its next call — on @@ -322,6 +340,7 @@ fn read_exact(handle: &OwnedHandle, buf: &mut [u8]) -> anyhow::Result<()> { pub(crate) fn read_logical( device_path: &str, full_file_reference: u64, + known_logical_size: Option, logical_offset: u64, maximum_logical_length: u32, cache: ReadHandleCache, @@ -348,6 +367,7 @@ pub(crate) fn read_logical( let mut open_file_by_id_time = Duration::ZERO; let mut file_size_time = Duration::ZERO; + let mut trusted_known_size = false; let (file_handle, eof) = match cache.file { Some(cached) if cached.full_file_reference == full_file_reference => { (cached.handle, cached.eof) @@ -357,9 +377,15 @@ pub(crate) fn read_logical( let file_handle = open_file_by_id(&volume_hint.handle, full_file_reference)?; open_file_by_id_time = open_by_id_started_at.elapsed(); - let file_size_started_at = Instant::now(); - let eof = file_size(&file_handle)?; - file_size_time = file_size_started_at.elapsed(); + let eof = if let Some(size) = known_logical_size { + trusted_known_size = true; + size + } else { + let file_size_started_at = Instant::now(); + let eof = file_size(&file_handle)?; + file_size_time = file_size_started_at.elapsed(); + eof + }; (file_handle, eof) } @@ -388,6 +414,7 @@ pub(crate) fn read_logical( // read time for a given drive's workload. tracing::debug!( cache_hit, + trusted_known_size, real_bytes = plan.real_bytes, open_volume_hint_us = duration_micros(open_volume_hint_time), open_file_by_id_us = duration_micros(open_file_by_id_time), diff --git a/crates/uffs-content/src/job/content_source.rs b/crates/uffs-content/src/job/content_source.rs index 7374c46d8..ab0d45ead 100644 --- a/crates/uffs-content/src/job/content_source.rs +++ b/crates/uffs-content/src/job/content_source.rs @@ -168,6 +168,7 @@ impl ContentSource for VssContentSource { candidate.snapshot_lease_id, candidate_id, candidate.file_reference, + candidate.logical_size, ) .map_err(|err| io::Error::other(err.to_string()))?; Ok(Box::new(session)) diff --git a/crates/uffs-content/src/job/reader_client.rs b/crates/uffs-content/src/job/reader_client.rs index b03d5abd4..bfff41666 100644 --- a/crates/uffs-content/src/job/reader_client.rs +++ b/crates/uffs-content/src/job/reader_client.rs @@ -20,8 +20,8 @@ //! //! Each pool is a bounded [`crossbeam_channel`] of already-open //! connections. A candidate's whole sequential read pins exactly one -//! connection for its entire duration — see [`ContentReader::begin_read`]/ -//! [`ReaderSession`] — rather than checking one out fresh per chunk: +//! connection for its entire duration — see `ContentReader::begin_read`/ +//! `ReaderSession` — rather than checking one out fresh per chunk: //! real-hardware benchmarking found `uffs-content-reader` caches its //! open NTFS file handle per connection across consecutive requests for //! the same file (see that crate's `reader/logical.rs`), so consecutive @@ -220,6 +220,12 @@ impl ContentReader { /// if every connection in this drive's pool is currently checked /// out. /// + /// `known_logical_size` is the candidate's size as the manifest + /// already knows it — forwarded to the Reader so it can skip its own + /// `GetFileSizeEx` re-resolution; see + /// `uffs-content-reader-protocol::ReadRequest::known_logical_size`'s + /// doc comment for the trust reasoning. + /// /// # Errors /// Returns an error if `snapshot_lease_id` has no pool, or every /// connection in that pool has already failed and been dropped. @@ -228,6 +234,7 @@ impl ContentReader { snapshot_lease_id: u64, candidate_id: u64, full_file_reference: u64, + known_logical_size: u64, ) -> Result { let pool = self.connections.get(&snapshot_lease_id).ok_or_else(|| { anyhow::anyhow!( @@ -247,6 +254,7 @@ impl ContentReader { snapshot_lease_id, candidate_id, full_file_reference, + known_logical_size, next_nonce: Arc::clone(&self.next_nonce), }) } @@ -298,6 +306,11 @@ pub(crate) struct ReaderSession { candidate_id: u64, /// This session's file, echoed into every `ReadRequest`. full_file_reference: u64, + /// This candidate's manifest-known logical size, forwarded as + /// `ReadRequest::known_logical_size` on every request so the Reader + /// can skip its own `GetFileSizeEx` re-resolution on a cache miss — + /// see that field's own doc comment for the trust reasoning. + known_logical_size: u64, /// Shared with every other live session (see /// [`ContentReader::next_nonce`]'s own doc comment). next_nonce: Arc, @@ -332,6 +345,7 @@ impl ReaderSession { volume_guid: Vec::new(), }, full_file_reference: self.full_file_reference, + known_logical_size: Some(self.known_logical_size), stream_kind: StreamKind::UnnamedData, logical_offset, maximum_logical_length, From 9fc523f1e61152460f41540e962507d21ddcc7e2 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 08:51:29 -0700 Subject: [PATCH 94/98] docs: fix broken/private/redundant rustdoc intra-doc links (xwin doc) RUSTDOCFLAGS="-Dwarnings" cargo xwin doc --workspace --document-private-items had apparently never been run against this workspace before -- every one of these was a real, pre-existing latent bug across uffs-mft, uffs-security, uffs-broker, uffs-client, uffs-daemon, uffs-cli, and uffs-content, invisible on the host (macOS) doc build because the affected code is #[cfg(windows)]-gated. Three distinct root causes, fixed accordingly: - Bare-name intra-doc links inside a MODULE-level (//!) doc comment resolve relative to something other than that module's own scope in this rustdoc version -- even for items defined in the very same file. Fixed by fully-qualifying via the item's real crate-rooted path (crate::module::Item), matching how other already-working links in the same files were already written. - Links to items that are genuinely private (module-private or pub(crate)) from public-facing doc comments -- de-linked to plain backtick text, since the private item can't be a real clickable destination in a normal (non---document-private-items) doc build. - A handful of genuinely stale references: stream::run (renamed to stream::spawn), MftReader::new_for_volume (renamed to MftReader::open), and DirWalkCandidateSource (referenced via a bare name never imported into that file's scope) -- fixed to the current real names/paths, not just silenced. cargo xwin doc --workspace --all-features --no-deps --document-private-items now passes clean end to end with -D warnings. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-broker/src/broker.rs | 2 +- .../uffs-cli/src/commands/uninstall/sweep.rs | 7 ++--- crates/uffs-client/src/broker_probe.rs | 10 +++---- crates/uffs-client/src/windows_deadline.rs | 24 ++++++++--------- .../uffs-content/src/job/candidate_source.rs | 6 ++--- crates/uffs-content/src/job/self_test.rs | 9 ++++--- crates/uffs-content/src/job/vss_job.rs | 4 +-- crates/uffs-content/src/serve/mod.rs | 6 ++--- crates/uffs-daemon/src/cache/pressure.rs | 8 +++--- crates/uffs-mft/src/cache_dataframe.rs | 7 ++--- crates/uffs-mft/src/io/readers/mft_file.rs | 2 +- .../src/io/readers/parallel/bulk_iocp.rs | 2 +- crates/uffs-mft/src/io/readers/pipelined.rs | 4 +-- crates/uffs-mft/src/platform/volume.rs | 6 ++--- crates/uffs-mft/src/reader.rs | 14 +++++----- .../uffs-mft/src/reader/dataframe_timing.rs | 6 ++--- crates/uffs-mft/src/reader/index_cache.rs | 6 ++--- .../src/reader/persistence_capture.rs | 20 +++++++------- crates/uffs-mft/src/usn/windows.rs | 4 +-- crates/uffs-security/src/pipe.rs | 26 ++++++++++--------- 20 files changed, 89 insertions(+), 84 deletions(-) diff --git a/crates/uffs-broker/src/broker.rs b/crates/uffs-broker/src/broker.rs index 41fa977e3..dc2787791 100644 --- a/crates/uffs-broker/src/broker.rs +++ b/crates/uffs-broker/src/broker.rs @@ -161,7 +161,7 @@ fn self_test_vss_dir(args: &[String]) -> Option { /// runtime on this machine. /// /// # Errors -/// Returns an error (and prints "FAIL: ") if any stage of the +/// Returns an error (and prints `"FAIL: "`) if any stage of the /// round trip fails. #[cfg(windows)] #[expect( diff --git a/crates/uffs-cli/src/commands/uninstall/sweep.rs b/crates/uffs-cli/src/commands/uninstall/sweep.rs index 1a8463e36..b5ef0edbe 100644 --- a/crates/uffs-cli/src/commands/uninstall/sweep.rs +++ b/crates/uffs-cli/src/commands/uninstall/sweep.rs @@ -7,9 +7,10 @@ //! explicit second confirmation** (a `uffs.exe` under `Downloads` might be the //! user's own copy, so they never ride the main plan's single yes — design §8). //! -//! The dedup logic is pure + unit-tested against a fake [`Search`]; the live -//! backend ([`DaemonSearch`]) is best-effort (no daemon ⇒ no hits, never a -//! hard failure). +//! The dedup logic is pure + unit-tested against a fake +//! [`crate::commands::uninstall::sweep::Search`]; the live backend +//! ([`crate::commands::uninstall::sweep::DaemonSearch`]) is best-effort +//! (no daemon ⇒ no hits, never a hard failure). use core::time::Duration; use std::ffi::OsStr; diff --git a/crates/uffs-client/src/broker_probe.rs b/crates/uffs-client/src/broker_probe.rs index 41f27e356..4b380b7fc 100644 --- a/crates/uffs-client/src/broker_probe.rs +++ b/crates/uffs-client/src/broker_probe.rs @@ -4,11 +4,11 @@ //! Detect a running UFFS Access Broker. //! //! The broker is an elevated Windows service that vends read-only NTFS -//! volume handles to a non-elevated daemon. [`broker_pipe_present`] lets -//! the client's daemon-spawn logic ([`crate::daemon_spawn`]) decide -//! whether a non-elevated `uffs` can start the daemon anyway (the daemon -//! then obtains handles from the broker) instead of returning -//! [`crate::error::ClientError::DaemonNeedsElevation`]. +//! volume handles to a non-elevated daemon. +//! [`crate::broker_probe::broker_pipe_present`] lets the client's daemon-spawn +//! logic ([`crate::daemon_spawn`]) decide whether a non-elevated `uffs` can +//! start the daemon anyway (the daemon then obtains handles from the broker) +//! instead of returning [`crate::error::ClientError::DaemonNeedsElevation`]. //! //! This whole module is `#[cfg(windows)]` (declared so in `lib.rs`), so //! it carries no per-item cfg gates. diff --git a/crates/uffs-client/src/windows_deadline.rs b/crates/uffs-client/src/windows_deadline.rs index 791baed0c..e3353e9d3 100644 --- a/crates/uffs-client/src/windows_deadline.rs +++ b/crates/uffs-client/src/windows_deadline.rs @@ -21,13 +21,13 @@ //! * The guard stores a stable, duplicated handle to the thread that owns the //! [`crate::connect_sync::UffsClientSync`] — this handle is valid for the //! lifetime of the guard even after the thread exits. -//! * An [`AtomicU64`] carries the absolute `GetTickCount64` tick at which the -//! current RPC should be aborted. `0` means "no RPC in flight", i.e. -//! disarmed. -//! * The watchdog thread wakes every [`WATCHDOG_POLL_MS`] ms, checks the -//! atomic, and calls `CancelSynchronousIo` on the target thread when the -//! deadline has passed. It consumes the deadline (via `compare_exchange`) so -//! each arm fires at most once. +//! * An [`core::sync::atomic::AtomicU64`] carries the absolute `GetTickCount64` +//! tick at which the current RPC should be aborted. `0` means "no RPC in +//! flight", i.e. disarmed. +//! * The watchdog thread wakes every `WATCHDOG_POLL_MS` ms, checks the atomic, +//! and calls `CancelSynchronousIo` on the target thread when the deadline has +//! passed. It consumes the deadline (via `compare_exchange`) so each arm +//! fires at most once. //! * `CancelSynchronousIo` causes the blocked `ReadFile` / `WriteFile` on the //! target thread to return `ERROR_OPERATION_ABORTED` (`0x4D3`), which bubbles //! up through `std::io::Read` / `Write` as a regular I/O error — the caller's @@ -48,12 +48,12 @@ //! * Per guard (per `UffsClientSync` lifetime): one watchdog thread, ~20 //! wake-ups / s, negligible CPU. //! * Per RPC (arm + disarm): two atomic stores, nanoseconds. -//! * Worst-case deadline overshoot: [`WATCHDOG_POLL_MS`] ms. +//! * Worst-case deadline overshoot: `WATCHDOG_POLL_MS` ms. //! * Drop latency: < 1 ms. The watchdog blocks on an -//! [`mpsc::Receiver::recv_timeout`] pairing the 50 ms poll with an instant -//! shutdown wake, so [`Drop`] no longer stalls waiting for the next poll -//! cycle. Before this fix the CLI hot path was paying ~48 ms median on every -//! invocation (Run 11 bisect — 60 % of the entire wall-clock). +//! [`std::sync::mpsc::Receiver::recv_timeout`] pairing the 50 ms poll with an +//! instant shutdown wake, so [`Drop`] no longer stalls waiting for the next +//! poll cycle. Before this fix the CLI hot path was paying ~48 ms median on +//! every invocation (Run 11 bisect — 60 % of the entire wall-clock). #![cfg(windows)] diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 21425886e..17ac4c8f2 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -123,9 +123,9 @@ const fn file_identity(_metadata: &fs::Metadata) -> u64 { } /// Evaluates a job's query against the ephemeral, VSS-snapshot-backed -/// `uffsd` instance -/// [`super::vss_orchestrator::prepare_ephemeral_daemon_for_roots`] -/// spawned — the real production `CandidateSource`. +/// `uffsd` instance `prepare_ephemeral_daemon_for_roots` +/// ([`super::vss_orchestrator`]) spawned — the real production +/// `CandidateSource`. /// /// Windows-only: VSS snapshots, and the ephemeral daemon that queries /// them, don't exist on any other platform — matching diff --git a/crates/uffs-content/src/job/self_test.rs b/crates/uffs-content/src/job/self_test.rs index 3af4382aa..f603b38d6 100644 --- a/crates/uffs-content/src/job/self_test.rs +++ b/crates/uffs-content/src/job/self_test.rs @@ -104,7 +104,7 @@ pub fn self_test_vss_playback(test_dir: &Path) -> Result<()> { /// manifest's own `logical_size` fields must sum to the ground-truth /// total, and the bytes actually streamed over `CONTENT_CHUNK` frames /// must also sum to that same total. Ground truth comes from -/// [`walk_tolerating_denied`] — a permissive `std::fs` walker reading the +/// `walk_tolerating_denied` — a permissive `std::fs` walker reading the /// **live** volume rather than the job's VSS snapshot; on a quiescent /// drive the two are expected to match exactly. /// @@ -307,9 +307,10 @@ pub fn self_test_reader_benchmark( /// the size of every regular file whose extension case-insensitively /// matches `extension`. /// -/// Deliberately **not** [`DirWalkCandidateSource`] (used elsewhere in this -/// crate for synthetic test fixtures, where an access-denied error is -/// itself a bug worth failing loud on): a real, pre-existing drive +/// Deliberately **not** [`super::candidate_source::DirWalkCandidateSource`] +/// (used elsewhere in this crate for synthetic test fixtures, where an +/// access-denied error is itself a bug worth failing loud on): a real, +/// pre-existing drive /// routinely has OS-reserved, ACL-locked directories (`System Volume /// Information`, `$RECYCLE.BIN`) that plain `std::fs::read_dir` can't /// enter but that the real MFT-based query engine reads regardless (it diff --git a/crates/uffs-content/src/job/vss_job.rs b/crates/uffs-content/src/job/vss_job.rs index eafcb3697..dd514c0a4 100644 --- a/crates/uffs-content/src/job/vss_job.rs +++ b/crates/uffs-content/src/job/vss_job.rs @@ -8,8 +8,8 @@ //! enumerate candidates against it, spawn the privileged content Reader, //! stream content through [`super::workflow::run_job`], and tear //! everything down — in the right order (content Reader/leases outlive -//! candidate enumeration; see -//! [`super::vss_orchestrator::EphemeralJobResources`] for why daemon and leases +//! candidate enumeration; see `EphemeralJobResources` +//! ([`super::vss_orchestrator`]) for why daemon and leases //! are bundled into one teardown step). //! //! Windows-only: every piece this wires together already is. diff --git a/crates/uffs-content/src/serve/mod.rs b/crates/uffs-content/src/serve/mod.rs index e13141f20..942258225 100644 --- a/crates/uffs-content/src/serve/mod.rs +++ b/crates/uffs-content/src/serve/mod.rs @@ -43,7 +43,7 @@ use tokio::sync::mpsc; use crate::job::registry::JobRegistry; /// A signal the command pipe delivers to the active job's streaming task -/// ([`stream::run`]). +/// ([`stream::spawn`]). pub(crate) enum ControlSignal { /// `WINDOW_UPDATE`: raise the send budget by this many bytes. WindowGrant(u64), @@ -57,7 +57,7 @@ pub(crate) enum ControlSignal { /// Handle to the currently-active job, from the command pipe's point of /// view. struct ActiveJob { - /// The producer-assigned id for this job (see [`stream::run`]'s doc + /// The producer-assigned id for this job (see [`stream::spawn`]'s doc /// comment for why the producer, not the consumer, assigns it). job_id: [u8; 16], /// Delivers [`ControlSignal`]s to the streaming task. @@ -76,7 +76,7 @@ struct ServerState { /// Run the command pipe server for the process's whole lifetime. Each /// `JOB_SUBMIT` spawns a job-owned data-pipe streaming task -/// ([`stream::run`]) alongside it. +/// ([`stream::spawn`]) alongside it. /// /// # Errors /// Returns an error only if the command pipe itself cannot be created at diff --git a/crates/uffs-daemon/src/cache/pressure.rs b/crates/uffs-daemon/src/cache/pressure.rs index aab8ff4b4..1921d815a 100644 --- a/crates/uffs-daemon/src/cache/pressure.rs +++ b/crates/uffs-daemon/src/cache/pressure.rs @@ -332,7 +332,7 @@ mod windows_handles { /// The returned `HANDLE` is a Copy bit-pattern — callers must /// **not** close it; ownership stays with `self` and the /// underlying kernel handle is released exactly once via - /// [`OwnedHandle::Drop`]. + /// its own `Drop`. const fn raw(&self) -> HANDLE { self.0 } @@ -591,8 +591,8 @@ mod windows_handles { /// /// Wraps [`CreateMemoryResourceNotification`] and returns the /// resulting `HANDLE` boxed in an [`OwnedHandle`] so the watcher - /// thread closes it exactly once on exit via - /// [`OwnedHandle::Drop`]. Maps any windows-rs error into + /// thread closes it exactly once on exit via its own `Drop`. + /// Maps any windows-rs error into /// `io::Error::other` so the surrounding orchestrator's error /// path stays platform-agnostic. fn create_memory_resource_notification( @@ -613,7 +613,7 @@ mod windows_handles { /// Create an unnamed manual-reset Win32 event handle. /// /// Used as the watcher thread's shutdown signal: the owning - /// [`super::PlatformPressureSignal::Drop`] calls + /// [`super::PlatformPressureSignal`]'s own `Drop` calls /// [`signal_shutdown`] (which pulses `SetEvent`) so the next /// `WaitForMultipleObjects` returns and the thread exits. The /// returned [`OwnedHandle`] closes the event on its own `Drop` diff --git a/crates/uffs-mft/src/cache_dataframe.rs b/crates/uffs-mft/src/cache_dataframe.rs index 0e244ec6f..d45c32d22 100644 --- a/crates/uffs-mft/src/cache_dataframe.rs +++ b/crates/uffs-mft/src/cache_dataframe.rs @@ -104,7 +104,7 @@ fn load_cached_dataframe( } /// Read the MFT fresh, kick off a background cache save, and convert the -/// resulting index into a [`DataFrame`]. Used after a cache miss. +/// resulting index into a [`uffs_polars::DataFrame`]. Used after a cache miss. #[cfg(windows)] fn build_fresh_dataframe( drive: crate::platform::DriveLetter, @@ -123,7 +123,8 @@ fn build_fresh_dataframe( } /// Open the MFT reader for `drive` and synchronously read every record -/// into an [`MftIndex`]. Wraps the tracing pair around the slow read. +/// into an [`crate::index::MftIndex`]. Wraps the tracing pair around the slow +/// read. #[cfg(windows)] fn read_fresh_index(drive: crate::platform::DriveLetter) -> crate::Result { use crate::reader::MftReader; @@ -144,7 +145,7 @@ fn read_fresh_index(drive: crate::platform::DriveLetter) -> crate::Result` compatible with the legacy pipeline /// (`from_parsed_records`). diff --git a/crates/uffs-mft/src/io/readers/parallel/bulk_iocp.rs b/crates/uffs-mft/src/io/readers/parallel/bulk_iocp.rs index 078be79f1..584e2ada6 100644 --- a/crates/uffs-mft/src/io/readers/parallel/bulk_iocp.rs +++ b/crates/uffs-mft/src/io/readers/parallel/bulk_iocp.rs @@ -14,7 +14,7 @@ use super::prelude::*; struct BulkOverlappedRead { /// Win32 `OVERLAPPED` struct passed to `ReadFile` and matched on the /// IOCP completion side. Addressed by raw pointer until the - /// completion is dequeued, hence the [`Pin>`] wrapping at the + /// completion is dequeued, hence the `Pin>` wrapping at the /// owning sites. overlapped: windows::Win32::System::IO::OVERLAPPED, } diff --git a/crates/uffs-mft/src/io/readers/pipelined.rs b/crates/uffs-mft/src/io/readers/pipelined.rs index f04297077..17253eb30 100644 --- a/crates/uffs-mft/src/io/readers/pipelined.rs +++ b/crates/uffs-mft/src/io/readers/pipelined.rs @@ -310,8 +310,8 @@ impl PipelinedMftReader { /// Pre-computed plan for a single pipelined-parallel read. /// -/// Bundled into a struct so [`read_all_pipelined_parallel`] can hand the -/// fields off to its sub-helpers without exceeding clippy's +/// Bundled into a struct so [`PipelinedMftReader::read_all_pipelined_parallel`] +/// can hand the fields off to its sub-helpers without exceeding clippy's /// `too_many_arguments` threshold. struct PipelinedReadPlan { /// Bitmap-aware [`ReadChunk`] schedule (in disk order) handed to the diff --git a/crates/uffs-mft/src/platform/volume.rs b/crates/uffs-mft/src/platform/volume.rs index c2ff2d2c3..1ac9ebfc1 100644 --- a/crates/uffs-mft/src/platform/volume.rs +++ b/crates/uffs-mft/src/platform/volume.rs @@ -71,7 +71,7 @@ static BROKER_HANDLES: std::sync::OnceLock bool { @@ -1030,7 +1030,7 @@ impl VolumeHandle { /// `FILE_FLAG_NO_BUFFERING` bypasses the cache manager entirely and /// issues I/O directly to the device driver, which only requires /// sector-aligned buffers and offsets (already guaranteed by - /// [`AlignedBuffer`]). + /// [`crate::io::AlignedBuffer`]). /// /// Re-opens [`Self::opened_path`] when this handle was opened against /// a real path (live volume or VSS snapshot device) — critically, diff --git a/crates/uffs-mft/src/reader.rs b/crates/uffs-mft/src/reader.rs index 06baf8c90..a4cbea95f 100644 --- a/crates/uffs-mft/src/reader.rs +++ b/crates/uffs-mft/src/reader.rs @@ -215,7 +215,7 @@ impl MftReader { /// Open an arbitrary device path for MFT reading — e.g. a VSS snapshot /// device (`\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyN`), rather /// than a live drive letter's `\\.\:` path. See - /// [`crate::platform::volume::VolumeHandle::open_device_path`] for the + /// [`crate::platform::VolumeHandle::open_device_path`] for the /// full contract (no Access Broker fast-path; caller must already be /// elevated) and what `volume` (a diagnostic label only) is for. /// @@ -296,12 +296,12 @@ impl MftReader { /// /// # Errors /// - /// Returns [`MftError::InvalidInput`] if the reader was constructed via - /// [`MftReader::from_file`] rather than [`MftReader::new_for_volume`]. In - /// production this is a contract violation: the IOCP read pipelines that - /// call this method are dispatched only after construction guarantees a - /// live volume. A typed error keeps the contract enforceable without - /// panicking. + /// Returns [`crate::error::MftError::InvalidInput`] if the reader was + /// constructed via [`MftReader::from_file`] rather than + /// [`MftReader::open`]. In production this is a contract violation: + /// the IOCP read pipelines that call this method are dispatched only + /// after construction guarantees a live volume. A typed error keeps + /// the contract enforceable without panicking. #[cfg(windows)] pub(crate) fn require_handle(&self) -> Result<&VolumeHandle> { match &self.source { diff --git a/crates/uffs-mft/src/reader/dataframe_timing.rs b/crates/uffs-mft/src/reader/dataframe_timing.rs index cc8391fd0..71092bda7 100644 --- a/crates/uffs-mft/src/reader/dataframe_timing.rs +++ b/crates/uffs-mft/src/reader/dataframe_timing.rs @@ -305,9 +305,9 @@ impl MftReader { /// Output of [`MftReader::benchmark_phase1_open`]. /// /// Bundles the extent map, optional bitmap, detected drive type, and the -/// pre-computed [`DriveCharacteristics`] / `mft_size_bytes` / `open_ms` -/// so the benchmark orchestrator can move them into the read+parse phase -/// without juggling several positional values. +/// pre-computed [`crate::reader::DriveCharacteristics`] / `mft_size_bytes` / +/// `open_ms` so the benchmark orchestrator can move them into the read+parse +/// phase without juggling several positional values. #[cfg(windows)] struct Phase1Snapshot { /// MFT extent map handed off to [`crate::io::ParallelMftReader`]. diff --git a/crates/uffs-mft/src/reader/index_cache.rs b/crates/uffs-mft/src/reader/index_cache.rs index 9a1200f3f..c16793fe5 100644 --- a/crates/uffs-mft/src/reader/index_cache.rs +++ b/crates/uffs-mft/src/reader/index_cache.rs @@ -170,9 +170,9 @@ impl MftReader { /// /// Mirrors /// [`crate::reader::multi_drive::MultiDriveMftReader::apply_or_skip_usn_changes`] - /// but reuses the caller-supplied [`VolumeHandle`] instead of opening a - /// fresh one — the cached single-drive path already holds a live handle - /// from `apply_usn_updates_to_fresh_index`. + /// but reuses the caller-supplied [`crate::platform::VolumeHandle`] instead + /// of opening a fresh one — the cached single-drive path already holds + /// a live handle from `apply_usn_updates_to_fresh_index`. #[cfg(windows)] fn apply_or_skip_usn_changes( drive: crate::platform::DriveLetter, diff --git a/crates/uffs-mft/src/reader/persistence_capture.rs b/crates/uffs-mft/src/reader/persistence_capture.rs index 78687af84..745e3ef8f 100644 --- a/crates/uffs-mft/src/reader/persistence_capture.rs +++ b/crates/uffs-mft/src/reader/persistence_capture.rs @@ -336,8 +336,8 @@ fn plan_iocp_capture_chunks( } /// Block on `GetQueuedCompletionStatus` for `iocp`, locate the slot whose -/// pinned [`OverlappedRead`] matches the returned OVERLAPPED pointer, and -/// take ownership of that op out of `in_flight`. +/// pinned [`crate::io::OverlappedRead`] matches the returned OVERLAPPED +/// pointer, and take ownership of that op out of `in_flight`. /// /// Returns: /// - `Ok(Some((slot_idx, op)))` for a normal completion. @@ -407,8 +407,8 @@ unsafe fn wait_for_completion( } /// Slice the unaligned chunk payload out of the completed -/// [`OverlappedRead`]'s buffer, hand the bytes to `writer`, and return -/// `Ok(())` on success. +/// [`crate::io::OverlappedRead`]'s buffer, hand the bytes to `writer`, and +/// return `Ok(())` on success. /// /// Returns [`MftError::Io`] when the buffer is shorter than the expected /// post-alignment payload (which would indicate a short read or buffer- @@ -443,12 +443,12 @@ fn record_completed_chunk( } /// Allocate an aligned buffer for `chunk`, wrap it in a pinned -/// [`OverlappedRead`], submit a [`ReadFile`] against `handle`, and return -/// the pinned op so the caller can park it in their `in_flight` slot. +/// [`crate::io::OverlappedRead`], submit a `ReadFile` against `handle`, and +/// return the pinned op so the caller can park it in their `in_flight` slot. /// -/// `buffer_size` must already include the [`SECTOR_SIZE`] head-room +/// `buffer_size` must already include the [`crate::io::SECTOR_SIZE`] head-room /// required for sector-aligned reads. `slot_idx` is forwarded to the -/// [`OverlappedRead`] for completion-port routing. +/// [`crate::io::OverlappedRead`] for completion-port routing. /// /// On `ERROR_IO_PENDING` the read is considered successfully queued. /// Any other failure is returned as [`MftError::Io`] without closing @@ -459,8 +459,8 @@ fn record_completed_chunk( /// Caller must guarantee: /// - `handle` is a live overlapped file handle associated with the completion /// port that drives the surrounding event loop. -/// - The returned [`OverlappedRead`] outlives the in-flight read until -/// `GetQueuedCompletionStatus` reports its completion. +/// - The returned [`crate::io::OverlappedRead`] outlives the in-flight read +/// until `GetQueuedCompletionStatus` reports its completion. #[cfg(windows)] #[expect( unsafe_code, diff --git a/crates/uffs-mft/src/usn/windows.rs b/crates/uffs-mft/src/usn/windows.rs index 25911e27c..bee74e460 100644 --- a/crates/uffs-mft/src/usn/windows.rs +++ b/crates/uffs-mft/src/usn/windows.rs @@ -12,8 +12,8 @@ //! ## Why this is its own file //! //! Split out of the parent `usn.rs` so the DTO-side surface (the -//! [`Usn`](super::Usn) newtype + [`UsnJournalInfo`](super::UsnJournalInfo) / -//! [`UsnRecord`](super::UsnRecord) / aggregation helpers / non-Windows stubs / +//! `Usn` newtype + [`UsnJournalInfo`] / +//! [`UsnRecord`] / aggregation helpers / non-Windows stubs / //! tests) stays under the workspace file-size policy without needing an //! exception entry. The Win32 FFI surface — `#[repr(C)]` mirror structs, //! `CreateFileW` / `DeviceIoControl` calls, fixed-size record-decode loop — is diff --git a/crates/uffs-security/src/pipe.rs b/crates/uffs-security/src/pipe.rs index 4dca141df..5861d2ccd 100644 --- a/crates/uffs-security/src/pipe.rs +++ b/crates/uffs-security/src/pipe.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. +#![cfg(windows)] + //! Windows named-pipe security helpers. //! //! UFFS uses a Windows named pipe (`\\.\pipe\uffs-`) as the IPC @@ -33,21 +35,21 @@ //! //! # API surface //! -//! * [`PipeName`] — validated newtype wrapping a Windows named-pipe path of the -//! form `\\.\pipe\`. The canonical constructor -//! [`PipeName::for_current_user`] computes the deterministic per-user pipe -//! path; [`PipeName::parse`] validates an arbitrary string. -//! * [`current_user_sid_string`] — linked-or-current token user SID as a Win32 +//! * [`crate::pipe::PipeName`] — validated newtype wrapping a Windows +//! named-pipe path of the form `\\.\pipe\`. The canonical constructor +//! [`crate::pipe::PipeName::for_current_user`] computes the deterministic +//! per-user pipe path; [`crate::pipe::PipeName::parse`] validates an +//! arbitrary string. +//! * `current_user_sid_string` — linked-or-current token user SID as a Win32 //! SDDL-compatible string (`"S-1-5-21-..."`). -//! * [`OwnerOnlySd`] — RAII wrapper for a `SECURITY_DESCRIPTOR` granting -//! `GENERIC_ALL` to a single user SID. Pass `as_security_attributes()` to +//! * [`crate::pipe::OwnerOnlySd`] — RAII wrapper for a `SECURITY_DESCRIPTOR` +//! granting `GENERIC_ALL` to a single user SID. Pass +//! `as_security_attributes()` to //! `ServerOptions::create_with_security_attributes_raw`. //! //! Every unsafe Win32 call in the UFFS named-pipe stack lives in this //! file. Keep it that way. -#![cfg(windows)] - use core::fmt; use std::io; @@ -265,7 +267,7 @@ pub struct OwnerOnlySd { impl OwnerOnlySd { /// Build a DACL granting `GENERIC_ALL` to the current user (resolved - /// via [`current_user_sid_string`]). + /// via `current_user_sid_string`). /// /// # Errors /// @@ -327,8 +329,8 @@ impl OwnerOnlySd { /// Raw pointer to the `SECURITY_ATTRIBUTES` — kept alive by `self`. /// - /// Prefer [`as_security_attributes`] unless the target API takes a - /// raw `*mut c_void`. + /// Prefer [`OwnerOnlySd::as_security_attributes`] unless the target API + /// takes a raw `*mut c_void`. #[must_use] pub const fn raw_security_descriptor(&self) -> *mut core::ffi::c_void { self.sd.0 From b9a8662bc12b33235797aab4e7a8300b92cc753f Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:09:01 -0700 Subject: [PATCH 95/98] fix(content-reader): raise tracing max level to debug Real-hardware run confirmed the running binary had the per-phase timing instrumentation built in (version=... c596e5c6c-dirty) and processed 118,847 candidates, yet the reader's own log file had zero "read_logical: per-phase timing" lines -- only INFO-level connection events. tracing_subscriber::fmt()'s default level caps below debug! when no max level is explicitly configured, silently dropping every one of the new timing events regardless of how the code was built. with_max_level(DEBUG) makes the instrumentation this crate already ships actually reach its own per-job temp log file. Co-Authored-By: Claude Sonnet 5 --- crates/uffs-content-reader/src/main.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/uffs-content-reader/src/main.rs b/crates/uffs-content-reader/src/main.rs index f69bb43ed..16a9baf49 100644 --- a/crates/uffs-content-reader/src/main.rs +++ b/crates/uffs-content-reader/src/main.rs @@ -84,7 +84,17 @@ fn main() { #[cfg(windows)] { + // `.with_max_level(DEBUG)`: without an explicit level, this + // subscriber's own default caps below `read_logical`'s per-phase + // timing instrumentation (`tracing::debug!` in + // `reader/logical.rs`) -- real-hardware runs confirmed zero of + // those lines ever reached the log file despite candidates + // actually being read, even though the binary had the + // instrumentation built in. This is diagnostic-only: every event + // still lands in this job's own per-run temp log file (see + // `uffs-content::job::reader_client`), not anywhere persistent. let _guard = tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) .with_writer(std::io::stderr) .try_init(); let result = parse_device_args().and_then(|devices| reader::run(&devices)); From e6497b5666e3d7fdd67887377c6e2fb19efb2598 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:38:29 -0700 Subject: [PATCH 96/98] perf(content): enumerate all roots concurrently, not sequentially run_job's enumeration loop processed request.roots strictly one at a time -- each root's whole search-and-collect cycle blocking the next root from even starting, even though each enumerate() call opens its own independent connection to the daemon and shares no mutable state with any other call. Real-hardware benchmarking on a two-drive job showed ~15s + ~13s back to back (~28s total) for enumeration alone; running both concurrently cuts that to ~max(15s, 13s), and the effect compounds with every additional root a job touches. CandidateSource now requires Sync (mirroring ContentSource's own bound, already proven safe for exactly this kind of cross-thread sharing), and enumerate_all_roots_concurrently spawns one thread per root via std::thread::scope, joining and concatenating results back in roots' own order -- same shape as the earlier concurrent-lease-runs fix for content reads. Verified with a dedicated test (SlowEnumerateCandidateSource, 4 roots at 100ms each) proving wall-clock time reflects the slowest single root, not the sum -- 8/8 consecutive runs at ~140ms, comfortably under the ~300ms sequential-detection threshold. Co-Authored-By: Claude Sonnet 5 --- .../uffs-content/src/job/candidate_source.rs | 9 +- crates/uffs-content/src/job/tests.rs | 93 +++++++++++++++++++ crates/uffs-content/src/job/workflow.rs | 55 ++++++++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/crates/uffs-content/src/job/candidate_source.rs b/crates/uffs-content/src/job/candidate_source.rs index 17ac4c8f2..39cafc48e 100644 --- a/crates/uffs-content/src/job/candidate_source.rs +++ b/crates/uffs-content/src/job/candidate_source.rs @@ -41,7 +41,14 @@ pub struct CandidateEntry { /// which is exactly right for testing the Coordinator's own logic (this /// is `uffs-ingest-implementation-plan.md` §9.5's "fast" harness) but is /// not how a shipped job runs against NTFS. -pub trait CandidateSource { +/// +/// `Sync`: `run_job` enumerates every root concurrently (one thread per +/// root via `std::thread::scope`, mirroring the same shape as +/// [`super::content_source::ContentSource`]'s own `Sync` bound) — real-hardware +/// benchmarking found root-by-root enumeration strictly sequential today, +/// even though each `enumerate` call opens its own independent connection +/// to the daemon and shares no mutable state with any other call. +pub trait CandidateSource: Sync { /// Enumerate every regular file under `root`. /// /// # Errors diff --git a/crates/uffs-content/src/job/tests.rs b/crates/uffs-content/src/job/tests.rs index 9291dc4ea..e0dd35023 100644 --- a/crates/uffs-content/src/job/tests.rs +++ b/crates/uffs-content/src/job/tests.rs @@ -529,3 +529,96 @@ fn concurrent_lease_runs_actually_overlap_and_never_interleave_a_candidates_fram "every candidate must be closed by the end of the stream" ); } + +/// Test-only [`CandidateSource`] whose every `enumerate` call sleeps +/// `per_root_delay` before returning one fixed candidate for `root` — +/// simulates real per-root search latency (a synchronous round trip to +/// the daemon) without touching a real daemon, so a test can assert on +/// wall-clock time to prove root enumeration actually overlaps. +struct SlowEnumerateCandidateSource { + /// How long each root's `enumerate` call takes. + per_root_delay: Duration, +} + +impl CandidateSource for SlowEnumerateCandidateSource { + fn enumerate(&self, root: &Path) -> std::io::Result> { + std::thread::sleep(self.per_root_delay); + let root_str = root.to_string_lossy(); + let root_index: u64 = root_str + .strip_prefix("root:") + .and_then(|suffix| suffix.parse().ok()) + .unwrap_or(0); + let name = format!("file-{root_index}.txt"); + Ok(vec![CandidateEntry { + relative_path: PathBuf::from(&name), + absolute_path: PathBuf::from(&name), + logical_size: 4, + mtime_unix_ms: 0, + file_reference: root_index, + snapshot_lease_id: 0, + }]) + } +} + +/// Enumerating multiple roots must run concurrently, not one root's +/// whole search-and-collect cycle blocking the next — real-hardware +/// benchmarking found a two-drive job's enumeration costing ~15s + ~13s +/// back to back (~28s total) even though each root's `enumerate` call +/// opens its own independent connection to the daemon and shares no +/// mutable state with any other call (see +/// `workflow::enumerate_all_roots_concurrently`'s own doc comment). +#[test] +fn root_enumeration_actually_overlaps_across_roots() { + const ROOT_COUNT: usize = 4; + const PER_ROOT_DELAY: Duration = Duration::from_millis(100); + + let run_dir = tempfile::tempdir().expect("create run temp dir"); + let roots: Vec = (0..ROOT_COUNT) + .map(|i| PathBuf::from(format!("root:{i}"))) + .collect(); + let request = JobRequest { + source_id: "test-source".to_owned(), + roots, + query: "*".to_owned(), + ..Default::default() + }; + + let candidate_source = SlowEnumerateCandidateSource { + per_root_delay: PER_ROOT_DELAY, + }; + let content_source = SlowContentSource { + per_candidate_delay: Duration::ZERO, + }; + + let started_at = Instant::now(); + let outcome = run_job( + &request, + &candidate_source, + &content_source, + run_dir.path(), + &ReadConcurrency::flat(1), + &[], + 0, + |_frame| Ok(()), + ) + .expect("run_job must succeed"); + let elapsed = started_at.elapsed(); + + assert_eq!(outcome.run_summary.candidate_count, ROOT_COUNT as u64); + assert_eq!(outcome.run_summary.succeeded_count, ROOT_COUNT as u64); + assert_eq!(outcome.run_summary.failed_retryable_count, 0); + + // Sequential enumeration would cost roughly + // ROOT_COUNT * PER_ROOT_DELAY (~400ms); concurrent enumeration + // should cost roughly PER_ROOT_DELAY (~100ms), since every root's + // `enumerate` call runs on its own thread at the same time. The + // threshold sits comfortably between the two, with slack for + // scheduling jitter on a loaded CI machine. + let sequential_estimate = PER_ROOT_DELAY * u32::try_from(ROOT_COUNT).unwrap_or(u32::MAX); + assert!( + elapsed < sequential_estimate * 3 / 4, + "elapsed {elapsed:?} should be well under the fully-sequential estimate \ + {sequential_estimate:?} -- root enumeration does not appear to be running \ + concurrently" + ); +} diff --git a/crates/uffs-content/src/job/workflow.rs b/crates/uffs-content/src/job/workflow.rs index b33b606a3..f17db37eb 100644 --- a/crates/uffs-content/src/job/workflow.rs +++ b/crates/uffs-content/src/job/workflow.rs @@ -279,10 +279,7 @@ where root_count = request.roots.len(), "job: starting candidate enumeration" ); - let mut entries = Vec::new(); - for root in &request.roots { - entries.extend(candidate_source.enumerate(root)?); - } + let entries = enumerate_all_roots_concurrently(candidate_source, &request.roots)?; let candidate_count = len_as_u64(entries.len()); tracing::info!( candidate_count, @@ -368,6 +365,56 @@ where }) } +/// Enumerate every root in `roots` concurrently (one thread per root) and +/// concatenate the results back in `roots`' own order. +/// +/// Real-hardware benchmarking found this step strictly sequential — +/// root-by-root, one full search-and-collect cycle blocking the next — +/// even though each [`CandidateSource::enumerate`] call opens its own +/// independent connection to the daemon (see +/// `VssCandidateSource::enumerate`) and shares no mutable state with any +/// other call. A two-root job showed ~15s + ~13s back to back (~28s +/// total) that this reduces to ~max(15s, 13s) by running both searches +/// at once — and the effect compounds with every additional root. +/// +/// # Errors +/// Propagates the first error from any root's [`CandidateSource::enumerate`] +/// call, in `roots` order (matching the sequential loop this replaces). +#[expect( + clippy::needless_collect, + reason = "the intermediate `handles` collect is the whole point: every root's \ + scope.spawn must happen before any handle is joined, or this degenerates \ + back into spawn-then-immediately-join-one-at-a-time -- exactly the \ + sequential behavior this function exists to replace" +)] +fn enumerate_all_roots_concurrently( + candidate_source: &dyn CandidateSource, + roots: &[std::path::PathBuf], +) -> io::Result> { + let results: Vec>> = std::thread::scope(|scope| { + let handles: Vec<_> = roots + .iter() + .map(|root| scope.spawn(move || candidate_source.enumerate(root))) + .collect(); + handles + .into_iter() + .map(|handle| { + handle.join().unwrap_or_else(|panic_payload| { + Err(io::Error::other(format!( + "candidate enumeration thread panicked: {panic_payload:?}" + ))) + }) + }) + .collect() + }); + + let mut entries = Vec::new(); + for result in results { + entries.extend(result?); + } + Ok(entries) +} + /// Wrap `payload` in a `FrameEnvelope` for `job_id`, assigning and /// advancing the next `frame_sequence`. fn encode_frame( From 2249955136af4df5cf8c12348690d9591e2b7e1b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:34:26 -0700 Subject: [PATCH 97/98] docs(architecture): add UFFS filtering reference for Docenta integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the full filter surface (SearchFilters/SearchFilterParams, CLI flags, the narrower content-export JobRequest subset), the two independent extension/type-group taxonomies and how they combine, and the lack of magic-byte classification — the concrete questions raised while scoping Docenta's consumer-side extension handling. --- docs/architecture/filtering-reference.md | 304 +++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 docs/architecture/filtering-reference.md diff --git a/docs/architecture/filtering-reference.md b/docs/architecture/filtering-reference.md new file mode 100644 index 000000000..0d004d2fa --- /dev/null +++ b/docs/architecture/filtering-reference.md @@ -0,0 +1,304 @@ + +# UFFS Filtering Reference — extensions, type groups, and every other filter axis + +**Status:** Reference doc, audited against the code (2026-07-19). Answers one +question precisely: *what can I filter by, and how do I combine multiple +file types in one query?* + +--- + +## 0. The short answer + +UFFS filters by **raw, comma-separated, multi-extension lists** — OR-combined, +case-insensitive, no leading dot: + +``` +uffs "*" --ext pdf,docx,xlsx +``` + +On top of that there's a **curated named-family layer**, in two independent +forms, both usable as drop-in `--ext`/`--type` values instead of spelling out +every extension: + +``` +uffs "*" --ext documents,rs # a collection alias + a literal, mixed +uffs "*" --type document # a semantic category +``` + +There is **no magic-byte / content-sniffing classification anywhere in UFFS** +— everything below is extension + MFT-metadata based. If you're coming from a +tool that re-classifies a file after reading its header (magic bytes +overriding a wrong extension), UFFS has no equivalent; a misnamed file is +filtered by whatever its extension says, full stop. See §6. + +UFFS also has **two separate filter surfaces**, not one — the interactive +search engine (what the `uffs` CLI and `uffsd` daemon serve) is far richer +than the content-export `JobRequest` (what `uffs-content` accepts for a +streaming export job). §4 has the exact narrower subset the export path +forwards. + +--- + +## 1. The two filter surfaces + +| | Interactive search (CLI / `uffsd`) | Content-export (`uffs-content::JobRequest`) | +|---|---|---| +| Struct | `SearchFilterParams` → `SearchFilters` (`crates/uffs-core/src/search/filters/mod.rs`) | `JobRequest` (`crates/uffs-content/src/job/intake.rs`) | +| Purpose | "find/list/aggregate rows" | "stream file *content*, not just metadata, for a set of candidates" | +| Filter breadth | Everything in §2 | A narrow, deliberately curated subset — §4 | + +If you only ever use the `uffs` CLI, you want §2 and §3. If you're building a +content-export job (Docenta-style bulk read), you want §4, and you should +assume anything not listed there **is not available** on that path. + +--- + +## 2. Extensions & type groups — the full combination story + +### 2.1 Raw extension list (`--ext`) + +- Flag: `--ext ` — **one flag, comma-separated value.** Not repeatable — + a second `--ext` overwrites the first, it does not add to it. Use commas: + `--ext pdf,docx,rs`, not `--ext pdf --ext docx`. +- Wire field: `SearchParams::ext: Option` — same comma-separated + string travels over IPC and into `JobRequest::ext` unchanged. +- Parsing: split on `,`, each token trimmed, leading `.` stripped, lowercased. + Empty tokens skipped. +- Matching: **OR across every value** — a file matches if its extension + equals *any* entry in the list. Case-insensitive. No leading dot in either + the filter or the match. Dotless files, dotfiles (`.gitignore`), and + trailing-dot names (`foo.`) all have "no extension" and never match a + non-empty `--ext` list. +- Multiple extensions per query is fully supported — this is the primary + answer to "how do I combine types": list them, comma-separated. + +### 2.2 Built-in collection aliases (inline in `--ext`) + +Seven names expand to a hardcoded extension list *at `--ext` parse time* — +you can mix an alias with literal extensions in the same comma list: + +| Alias(es) | Expands to | +|---|---| +| `pictures`, `images` | jpg, jpeg, png, gif, bmp, tiff, tif, webp, svg, ico, raw, heic | +| `documents`, `docs` | doc, docx, pdf, txt, rtf, odt, xls, xlsx, ppt, pptx, csv, md | +| `videos`, `video` | mp4, avi, mkv, mov, wmv, flv, webm, mpeg, mpg, m4v, 3gp | +| `music`, `audio` | mp3, wav, flac, aac, ogg, wma, m4a, opus, aiff | +| `archives`, `compressed` | zip, rar, 7z, tar, gz, bz2, xz, iso | +| `code`, `source` | rs, py, js, ts, java, c, cpp, h, hpp, go, rb, php, swift, kt | +| `executables`, `exec` | exe, msi, bat, cmd, ps1, com, scr, vbs, wsf, dll, sys | + +Source: `crates/uffs-core/src/extensions/mod.rs` (`expand_collection`). + +``` +uffs "*" --ext documents,rs # every document extension, plus .rs files +``` + +### 2.3 `--type` semantic categories (a second, larger taxonomy) + +A separate system: **22 semantic categories** (`ALL_TYPE_CATEGORIES` in +`crates/uffs-core/src/search/derived.rs`), each mapping to its own curated +extension list — `document`, `code`, `executable`, `script`, `web`, `font`, +`database`, `config`, `log`, `backup`, `disk_image`, `data`, `cad`, +`shortcut`, `system`, `cert`, `ebook`, plus the five reused from §2.2 +(picture/video/music/archive), and `directory`/`file`/`other` (structural, +not extension-mapped). + +``` +uffs "*" --type document +``` + +**`--type` + `--ext` combine by intersection, not union** — if both are set, +a file must satisfy *both* (a mappable `--type` is folded into the extension +set and ANDed against whatever `--ext` already listed). If you want a union +of two categories, list their extensions together under `--ext` instead: + +``` +uffs "*" --ext documents,rs # union: documents OR rs +uffs "*" --type document --ext txt # intersection: (documents) AND (txt) == just txt, probably not what you want +``` + +### 2.4 Two taxonomies, not one — a real gap to know about + +`extensions::collections` (§2.2) and `search::derived` (§2.3) are +**independently maintained and disagree with each other** — e.g. +`collections::EXECUTABLES` has 11 entries including `dll`/`sys`/`vbs`/`wsf`; +`derived::EXECUTABLES` has 7, and `dll`/`sys` live under `SYSTEM` there +instead. There is no single canonical "what extensions count as an +executable" answer in UFFS the way docenta-core's `family.rs` is canonical +for its families. If precision matters for a given job, list the exact +extensions explicitly rather than relying on either alias system to match +your expectation. + +There is also **no ~150-extension "Text" super-family** the way docenta-core +has one. The nearest equivalents are the separate `documents`/`code`/`config`/ +`data`/`log` categories — you'd union several of them yourself via `--ext` if +you want docenta's "Text" breadth: + +``` +uffs "*" --ext txt,md,csv,json,xml,yaml,toml,ini,log,srt,rs,py,js,ts,c,cpp,go,java +``` + +(spell out whatever subset you actually need — there's no single flag for +"all text-like files" today). + +--- + +## 3. Every other filter axis (interactive search only) + +All of these are **AND-combined** with each other and with §2's extension +filter (only §2's multi-value list and the month filter below are internally +OR'd across their own values). + +| Axis | Flag(s) | Notes | +|---|---|---| +| Size | `--min-size` / `--max-size` / `--exact-size` | bytes; accepts unit suffixes (`10MB`) | +| Size on disk | `--min-size-on-disk` / `--max-size-on-disk` / `--exact-size-on-disk` | allocated bytes | +| Modified time | `--newer` / `--older` | `"7d"`, `"24h"`, `"2026-01-15"` | +| Created time | `--newer-created` / `--older-created` | same spec syntax | +| Accessed time | `--newer-accessed` / `--older-accessed` | same spec syntax | +| Date range | `--between START,END` | shorthand for newer+older together | +| Month | `--month ` | set of calendar months, OR-combined | +| Attributes | `--attr ` | e.g. `hidden,compressed,!system` — `!` prefix excludes | +| Hide NTFS metafiles | `--hide-system` | `$MFT`, `$LogFile`, etc. — not ordinary `$`-prefixed user files | +| Hide ADS | `--hide-ads` | Alternate Data Streams (names containing `:`) | +| Path scope | `--in-path ` / `--not-in-path ` | directory-path glob(s), matched against the dir portion only | +| Name exclude | `--exclude ` | glob against the leaf name | +| Descendants | `--min-descendants` / `--max-descendants` / `--exact-descendants` | directory child count | +| Tree metrics | `--min-treesize` / `--max-treesize` / `--min-tree-allocated` / `--max-tree-allocated` | recursive subtree totals | +| Name/path length | `--min-name-length` / `--max-name-length` / `--min-path-length` / `--max-path-length` | in characters | +| Bulkiness | `--min-bulkiness` / `--max-bulkiness` | allocated/logical ratio, as a percentage | +| Malformed names | `--malformed` / `--well-formed` / `--malformed-path` | see §7 below | +| Drive scope | `--drive ` / `--drives ` | volume scoping | +| Files/dirs only | `--files-only` / `--dirs-only` | structural filter | + +### Malformed-name filter (§7 detail) + +`--malformed` / `--well-formed` filter on whether the record's **leaf name +bytes are not valid UTF-8** — checked against the lossless raw name bytes, +never the lossy display string (which is always valid UTF-8 by construction +and would match nothing). `--malformed-path` is the path-derived superset. +This is a distinct axis from extension filtering entirely — it exists for +forensic/corruption-hunting use cases, not content typing. + +--- + +## 3.1 Pattern matching (separate from `--ext`) + +The main search pattern is independent of `--ext`, with three +auto-detected modes: + +- **Glob** (default) — auto-detected on `*`, `?`, `[`. Supports `**` too. +- **Regex** — pattern starts with `>`, e.g. `uffs ">.*\.(rs|toml)$"`. +- **Literal substring** — no wildcards at all; matched against the full path + (Everything/WizFile-style bare-text search). + +Path-vs-name is also auto-detected: a pattern containing `\` or `/` matches +the full path; otherwise just the filename. + +**A bare `*.ext`-style glob pattern is silently promoted into `--ext`** for +speed — `uffs "*.txt"` is exactly equivalent to `uffs "*" --ext txt` (it +rewrites to pattern `*` + `ext=txt` internally, so it hits the fast +extension-index path). Compound patterns like `*.tar.gz` or character classes +like `*.[ch]` are **not** promoted and stay on the general glob path. The +equivalent regex form (`>.*\.(jpg|png|heic)$`, note the required trailing +`$`) is promoted the same way into `ext=jpg,png,heic`. + +Practical upshot: for a single extension, `"*.pdf"` and `--ext pdf` are +interchangeable. For multiple extensions, `--ext a,b,c` is the clean form — +there's no single-glob equivalent for "match any of these N extensions." + +--- + +## 4. Content-export (`uffs-content::JobRequest`) — the narrower subset + +`JobRequest` (`crates/uffs-content/src/job/intake.rs`) forwards **only**: + +- `query` — the pattern (glob/regex/literal, same rules as §3.1) +- `ext` — same comma-separated extension list as §2.1 (aliases from §2.2/§2.3 + are **not** re-expanded on this path — only the raw `--ext`-style + collection-alias expansion applies if the string is passed through + unchanged; `type_filter` itself is not forwarded at all, see below) +- `min_size` / `max_size` +- `newer` / `older` — **modified-time only**; created/accessed bounds are not + available on this path +- `exclude` +- `attr` +- `roots` — scopes to specific directories/drives (empty = every local NTFS + drive) + +Plus one export-specific field with no search analog: + +- `max_content_delivery_bytes` — a candidate over this size is still + enumerated (appears in the manifest as metadata), but its body is never + streamed. This does not affect *which* files match — only whether their + content gets sent. + +**Not available on the content-export path at all:** `--type` semantic +categories, created/accessed-time bounds, size-on-disk, tree metrics, +descendants, name/path length, bulkiness, month, malformed-name filtering, +`--hide-system`/`--hide-ads`, sort, aggregation. If a job needs any of those, +today the only option is to pre-filter with the interactive search CLI to +confirm the candidate set, then scope the export job's `roots`/`ext`/ +`min_size`/`max_size`/`newer`/`older`/`exclude`/`attr` as tightly as those +seven fields allow. + +The cross-platform test/dev candidate source (`DirWalkCandidateSource`) +ignores every filter field and always matches every regular file under a +root — it exists for testing the Coordinator's own logic, not for real jobs. + +--- + +## 5. Aggregation (`--agg`) — grouping, not filtering + +`--agg ` (repeatable; `--facet`/`--stats`/`--histogram`/`--count` are +shorthand expansions of it) runs **on top of** the already-filtered row set — +it groups/summarizes, it does not add or remove match criteria. Dimensions +include `type`, `extension`/`ext`, `drive`, `size`, and most other indexed +fields; kinds are `Terms`, `Stats`, `Histogram`, `DateHistogram`. Example +used earlier in this session: + +``` +uffs "*.txt" --drive E --agg size +``` + +buckets the already-`--ext txt`-filtered (via glob promotion), already- +`--drive E`-scoped rows by size range — it has no bearing on which files +were included in the first place. + +--- + +## 6. No magic-byte / content-sniffing classification + +Confirmed by direct code search: UFFS has no dependency on any content-type +sniffing library, and no code path reads a file's header bytes to determine +or correct its type. Every "magic" reference in the codebase is UFFS's own +internal binary-format signature (manifest frames, compact-cache headers, the +NTFS `FILE` record signature used by an MFT diagnostic) — none of it +classifies a *searched* or *exported* file's content type. + +Practically: if a file has a `.txt` extension but is actually a renamed ZIP, +UFFS will treat it as a text file for every filter above, forever — there is +no fallback classification step. This is the one capability docenta-core has +that UFFS does not; if content-type correctness (as opposed to extension +correctness) matters for a given job, that check has to happen downstream of +UFFS, after the file is read. + +--- + +## Appendix: source files + +| Concept | File | +|---|---| +| Interactive filter struct + parsing | `crates/uffs-core/src/search/filters/mod.rs` | +| Extension-match semantics | `crates/uffs-core/src/search/filters/ext_match.rs` | +| Collection aliases (§2.2) | `crates/uffs-core/src/extensions/mod.rs` | +| Semantic type categories (§2.3) | `crates/uffs-core/src/search/derived.rs` | +| Pattern mode detection (§3.1) | `crates/uffs-core/src/pattern.rs`, `crates/uffs-core/src/pattern/parse.rs` | +| CLI flag parsing | `crates/uffs-client/src/protocol/cli_args.rs` | +| Wire params | `crates/uffs-client/src/protocol/mod.rs` (`SearchParams`) | +| Content-export job intake | `crates/uffs-content/src/job/intake.rs` (`JobRequest`) | +| Content-export filter forwarding | `crates/uffs-content/src/job/candidate_source.rs` (`VssCandidateSource`) | +| Aggregation | `crates/uffs-core/src/aggregate/`, `crates/uffs-daemon/src/index/aggregation.rs` | From 5631ff79b74ade58b5a8108625c39de255694921 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:15:55 -0700 Subject: [PATCH 98/98] fix(broker): stop uffs-content-reader.exe passing as the Coordinator image is_uffs_content_image's `starts_with("uffs-content")` fallback also matched uffs-content-reader.exe, so verify_coordinator_identity would wrongly accept the Reader process as a legitimate Coordinator. Drop the starts_with fallback on both this and is_uffs_content_reader_image (same risk class) in favor of exact-name matching; the two literal forms already cover every real binary/exe pair. Caught by the merge-queue run of PR #563 failing recognizes_coordinator_image_names on Windows. --- .../src/broker/snapshot_manager/mod.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs index c10d111a7..37af40ac1 100644 --- a/crates/uffs-broker/src/broker/snapshot_manager/mod.rs +++ b/crates/uffs-broker/src/broker/snapshot_manager/mod.rs @@ -392,23 +392,31 @@ fn lease_error_response(err: &LeaseError) -> SnapshotManagerResponse { } /// Whether `exe_path`'s file name matches the Content Coordinator binary. +/// +/// Exact-name match only — no `starts_with` fallback. `uffs-content` is +/// itself a prefix of `uffs-content-reader`, whose own identity is +/// verified separately by [`is_uffs_content_reader_image`]; a prefix +/// match here would let the Reader binary also pass as the Coordinator. fn is_uffs_content_image(exe_path: &std::ffi::OsStr) -> bool { let name = std::path::Path::new(exe_path) .file_name() .and_then(|file_name| file_name.to_str()) .unwrap_or(""); - name == "uffs-content" || name == "uffs-content.exe" || name.starts_with("uffs-content") + name == "uffs-content" || name == "uffs-content.exe" } /// Whether `exe_path`'s file name matches the Snapshot Reader binary. +/// +/// Exact-name match only, for the same reason as +/// [`is_uffs_content_image`]: a `starts_with` fallback on an identity +/// check is a standing invitation for a future binary sharing this +/// prefix to pass unintentionally. fn is_uffs_content_reader_image(exe_path: &std::ffi::OsStr) -> bool { let name = std::path::Path::new(exe_path) .file_name() .and_then(|file_name| file_name.to_str()) .unwrap_or(""); - name == "uffs-content-reader" - || name == "uffs-content-reader.exe" - || name.starts_with("uffs-content-reader") + name == "uffs-content-reader" || name == "uffs-content-reader.exe" } /// Verify the connected pipe client is a legitimate `uffs-content`