From 0e279590748d7576cfe8e4e81cbc0c8716548f24 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:45:20 +0100 Subject: [PATCH 1/3] Wire native WSL state and GC lifecycle --- README.md | 26 +- docs/architecture.md | 16 +- docs/roadmap-decisions.md | 6 +- docs/roadmap-implementation-requirements.md | 20 +- docs/security-model.md | 10 +- docs/wsl-process-contract.md | 6 +- docs/wsl.md | 39 +- internal/hostenv/hostenv.go | 9 +- internal/wslinstall/install.go | 2 +- internal/wslinstall/install_test.go | 2 +- internal/wslrun/plan.go | 45 +- internal/wslrun/plan_test.go | 18 +- internal/wslstate/command.go | 470 ++++++++++++++++++++ internal/wslstate/command_test.go | 253 +++++++++++ internal/wslvolume/python.go | 63 +++ internal/wslvolume/python_test.go | 75 ++++ internal/wslvolume/wslvolume.go | 2 +- main.go | 27 +- main_test.go | 40 ++ 19 files changed, 1046 insertions(+), 83 deletions(-) create mode 100644 internal/wslstate/command.go create mode 100644 internal/wslstate/command_test.go create mode 100644 internal/wslvolume/python.go create mode 100644 internal/wslvolume/python_test.go diff --git a/README.md b/README.md index f5d3451..35420db 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ real Linux CLI/runtime in an ephemeral container |---|---| | Windows 10/11 x64 + Docker Desktop (Linux containers) + PowerShell | **Supported** — this is the validated configuration | | cmd.exe invocation of shims | Works for the common cases; less battle-tested than PowerShell | -| WSL2 | **v2 runtime wired; activation gated.** The native Linux runtime uses the fixed private WSL layout and Docker Desktop's WSL integration directly, including proof-bound retained-container orphan recovery, but production dispatch remains fail-closed until native state commands, integration coverage and real WSL2 qualification land. See [docs/wsl.md](docs/wsl.md) | +| WSL2 | **v2 runtime and state lifecycle wired; activation gated.** The native Linux runtime uses the fixed private WSL layout and Docker Desktop's WSL integration directly, including proof-bound retained-container and volume cleanup, but managed-tool dispatch remains fail-closed until integration coverage and real WSL2 qualification land. See [docs/wsl.md](docs/wsl.md) | | Windows 11 ARM64 | **CI/release-artifact/update-path qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, the release workflow produces a reproducible ARM64 archive, and self-update selects and verifies that archive by `GOARCH`; real Docker Desktop ARM64 E2E qualification remains | | Linux / macOS hosts | **Not supported.** The program is Go and cross-compiles, but shim installation, path mapping and doctor checks are Windows-specific | | Windows containers | Not supported; images are Linux images | @@ -904,6 +904,9 @@ cb wsl prepare --check # native WSL2 only; read-only fixed-layout va cb wsl prepare --apply # create missing fixed-layout directories, then revalidate cb wsl install --check # read-only native install/config/shim plan cb wsl install --apply # install this native binary, registry and fixed symlinks +cb state # native WSL: list only exactly proven namespace volumes +cb gc [TOOL|STATE_GROUP] # native WSL: dry-run current project cleanup +cb gc [TOOL|STATE_GROUP] --orphans # native WSL: dry-run missing-project cleanup ``` `cb bugreport` assembles `cb version`, the Windows and PowerShell versions @@ -949,8 +952,8 @@ creation/upgrades and requires an already provisioned authenticated registry. The bootstrap executable must itself be a bounded, current-user-owned regular non-symlink file with safe executable permissions. This command still does not contact Docker itself. Install reports the wired-but-gated runtime state; -managed tool dispatch remains fail-closed until native state commands, -integration coverage and real WSL2 qualification land. See +managed tool dispatch remains fail-closed until integration coverage and real +WSL2 qualification land. See [docs/wsl.md](docs/wsl.md). `cb wsl cleanup --check` reports exact namespace-owned retained runtime @@ -962,6 +965,17 @@ publication so cleanup cannot guess across that race. Unlocked lease evidence whose exact namespace no longer contains a matching retained container is reported and reaped by `--apply`; locked leases are always preserved. +`cb state` and `cb gc` are also available natively in WSL before general tool +activation. They validate the fixed installation and authenticated registry, +discover only the current distribution/machine/user namespace, then reconstruct +and exactly prove every candidate before producing output or mutation. `cb gc` +is a dry run unless `--apply` is explicit, never selects shared volumes, and +uses non-force deletion with an absence check. `--orphans` requires the recorded +canonical Linux project path to be missing; symlinks and non-directory objects +are reported as unsafe state rather than guessed to be orphans. Native state +backup/restore remains separate future work and is not part of this v2 runtime +activation gate. + ### Self-update release selection `cb self-update --check` compares a release-qualified Windows/amd64 or @@ -1123,9 +1137,9 @@ benchmark methodology and the disposable-container tradeoff are in - Windows x64 + Docker Desktop (Linux containers) is the currently qualified release target. The native WSL2 runtime, fixed-layout installation, project mapping, managed volumes, Engine lifecycle, stdio/TTY, resize, signal and exit - propagation plus retained-container orphan reconciliation are wired, but - activation still requires native state commands, integration coverage and - real WSL2 + Docker Desktop qualification. Windows + propagation plus retained-container and volume cleanup are wired, but + activation still requires integration coverage and real WSL2 + Docker + Desktop qualification. Windows ARM64 has native non-Docker CI coverage and published release artifacts, but no Docker support claim. - First invocation of a tool after `cb lock` may still need images present diff --git a/docs/architecture.md b/docs/architecture.md index a3f349c..45cfe97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -352,6 +352,7 @@ internal/wsldocker native WSL Docker Desktop integration proof internal/wslvolume native WSL namespaced volume identity/lifecycle and stateful-tool binding planning internal/wslrun native WSL fixed-layout tool runtime orchestration +internal/wslstate native WSL proof-bound state inventory and cleanup internal/selfupdate release selection, staging, verification and replacement ``` @@ -359,7 +360,7 @@ The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, project-internal imports only: ``` -main -> cli, diag, dockerrun, hostenv, mutationlock, policy, projectconfig, registry, selfupdate, state, wslfs, wslinstall, wslrun +main -> cli, diag, dockerrun, hostenv, mutationlock, policy, projectconfig, registry, selfupdate, state, wslfs, wslinstall, wslrun, wslstate cli -> atomicio, diag, dockerrun, dockervol, lockfile, pathmap, policy, registry, statearchive, toml projectconfig -> atomicio, pathmap, policy, registry, toml diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry @@ -378,6 +379,7 @@ wslpathmap -> registry, wslproject wsldocker -> hostenv wslvolume -> hostenv, registry, wsldocker, wslproject wslrun -> hostenv, lockfile, policy, registry, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume +wslstate -> hostenv, policy, registry, wslfs, wslproject, wslvolume selfupdate -> mutationlock, registry atomicio, dockervol, hostenv, mutationlock, terminal, toml -> (leaves) ``` @@ -491,8 +493,9 @@ identity constructed by this package and its complete labels plus local driver/scope. The package also preflights an entire stateful profile's project/shared binding set, re-proves the exact project root before deriving project identities, and ensures each distinct identity only after the complete -plan validates. Tool-time composition is wired but the host boundary keeps it -activation-gated while native state commands and qualification remain. +plan validates. Tool-time composition and the v2 state/GC lifecycle are wired, +but the host boundary keeps managed tools activation-gated while integration +coverage and real qualification remain. `internal/wslrun` is the native WSL vertical orchestrator. It requires the fixed layout, private registry, managed binary and exact invoked shim; resolves @@ -514,8 +517,11 @@ all orphan removal uses the same proof-bound non-force lifecycle. Explicit coordinator-held pass enumerates managed lease names and reaps an unlocked lease only when complete namespace discovery contains no matching run; locked lease-only records remain untouched. The production host -boundary still does not dispatch into this orchestrator until native state -commands, integration coverage and real WSL qualification complete. +boundary still does not dispatch into this orchestrator until integration +coverage and real WSL qualification complete. `internal/wslstate` is dispatched +separately before that gate; it plans current project/shared identities from +the fixed registry, consumes only exactly proven namespace volumes, and offers +dry-run-by-default non-force project cleanup without selecting shared state. After the host runtime boundary is enforced, `cb self-update` is dispatched before machine policy and registry loading. Release selection therefore remains diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index e18af8c..347aae3 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -16,7 +16,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | RM-24 Python / uv | **Keep both** | Decision complete. Built-in `python`/`pip` keep the dedicated Python provider; `uv`/`uvx` remain separate opt-in stateful profiles. | | RM-26 Python global CLI exposure | **pipx yes; plain pip expose no** | Completed in PR #74. The separate stateful pipx profile and managed store shipped; project/compat `/venv/bin` remains intentionally unexposed. | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all and explicit binary selection are sufficient. Reopen only for a concrete unmet use case. | -| WSL2 | **Native WSL frontend** | Fixed-layout install, managed-tool runtime/Docker composition and proof-bound retained-container orphan reconciliation are wired behind the host gate. Native state commands, integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | +| WSL2 | **Native WSL frontend** | Fixed-layout install, managed-tool runtime/Docker composition, proof-bound retained-container reconciliation, and the v2-required state/GC lifecycle are wired. Integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | | Enterprise policy | **Machine-owned constraint layer** | Foundation shipped in PR #75. Authenticated registry and image-trust follow-ups must extend this boundary and cannot be weakened by lower layers. | | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | @@ -349,7 +349,7 @@ is not completion. - explicit read-only/apply Linux ownership, permission and symlink layout preparation plus the fixed-path native install/config lifecycle are implemented; ordinary managed tool dispatch is composed but remains - activation-gated pending state-command integration and qualification; + activation-gated pending integration coverage and real qualification; - Docker Desktop WSL integration proof, proof-bound bounded control requests, the separately constrained attach transport, strict raw-stream decoder, exact container inspection, wait, TTY-resize, signal, start, creation and @@ -357,7 +357,7 @@ is not completion. host-signal forwarding, retained-container cleanup and exit propagation; - namespace-prefixed/labeled WSL volume identity plus proof-bound exact inspect/create/remove and namespace discovery are implemented; tool-time - creation is wired while state/GC/backup/restore command integration remains; + creation and v2 state/GC are wired while backup/restore remains deferred; - profile-aware nearest/outermost/trusted project-root selection, canonical project and descendant storage classification (including symlink and nested-mount rejection), and proof-consuming argument mapping are wired; diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 21b90d1..7fbed3a 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Native tool runtime wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup, orphan reconciliation and exit propagation are composed behind the fail-closed host gate. Native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | +| WSL2 | **Native runtime/state wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup, proof-bound state/GC, orphan reconciliation and exit propagation are composed. Integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | @@ -617,8 +617,11 @@ until wait records the exit status and are then removed through the proof-bound cleanup path, avoiding an auto-remove race for fast tools. Process-held run leases and a namespace coordinator close the create-before-lease race; automatic startup and explicit `cb wsl cleanup --check|--apply` re-prove and recover only -exact unlocked or lease-less retained runs. Native state-command integration, -project/cross-boundary integration coverage and real WSL qualification remain. +exact unlocked or lease-less retained runs. Native `cb state`/`cb gc` now +reconcile only completely proven namespace volumes, never delete shared state, +and require an absent recorded Linux path before orphan deletion. Native state +backup/restore remains deferred; project/cross-boundary integration coverage +and real WSL qualification remain for v2 activation. Implementation must define native config/shim location, Docker endpoint, project identity, named-volume behavior, file permissions, case sensitivity, @@ -647,6 +650,15 @@ domain. Later Docker lifecycle wiring may use the exact prefix and namespace label for discovery, but adoption, backup, restore, GC or deletion must match the complete constructed name and label identity. +The v2-required native state subset is now composed. `cb state` and `cb gc` +consume only the fixed layout and authenticated registry, prove the complete +discovered namespace before output or mutation, never select shared volumes for +deletion, and classify an orphan only when the recorded canonical Linux project +path is missing. Existing symlinks and non-directory objects are unsafe rather +than orphan evidence. Apply uses proof-bound non-force removal and verifies +absence. Native state backup/restore remains deferred and is not an activation +prerequisite for the supported runtime contract. + Qualification must include both Windows-filesystem and WSL-filesystem projects plus mixed invocation rejection cases. @@ -729,7 +741,7 @@ in PR #91. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. 4. Remaining RM-31 real published-release/self-test E2E qualification. -5. Native WSL remaining state commands, integration corpus and real E2E. +5. Native WSL integration corpus and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 real Windows-on-Arm + Docker Desktop qualification last; do not delay higher-value work for it. diff --git a/docs/security-model.md b/docs/security-model.md index b4d4237..64b584b 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -103,7 +103,7 @@ readable, and dangerous to let others edit. authenticates signed registries when required, and reconciles only the fixed managed binary and provenance-checked symlinks. The ordinary managed-tool runtime is composed and tested but remains behind this host gate until - native state commands, integration coverage and real qualification land; + integration coverage and real qualification land; unsupported native management commands remain rejected. - **Native WSL installation does not adopt ambient files.** The bootstrap executable is the exact OS-reported running image and must be a bounded, @@ -151,6 +151,14 @@ readable, and dangerous to let others edit. requires that same proof immediately before deletion and verifies absence afterward. Prefix/label filters are discovery-only and cannot authorize adoption or mutation. Windows and other WSL scopes remain foreign state. +- **Native WSL state cleanup proves before it classifies or mutates.** `cb state` + and `cb gc` require the fixed layout and authenticated registry, reconstruct + every namespace discovery result into an exact immutable volume identity, + and complete the whole plan before the first deletion. Shared volumes are + never GC candidates. A project volume is orphaned only when its recorded + canonical Linux path is absent; symlinks, non-directories and inspection + errors are preserved and fail closed. Apply deletes without force and proves + absence afterward. - **Native WSL run containers are transaction-bound.** The create primitive accepts no raw Engine body, endpoint, privilege or Docker-socket mount controls. It admits at most one existing, symlink-free canonical project diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 8b720ec..5a6b23e 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -2,9 +2,9 @@ This document defines the process semantics implemented by ContainerBin's native-Linux frontend inside WSL2. The runtime is composed and covered in this -tree, but production dispatch remains activation-gated until the required -native state commands, integration corpus and real WSL2 + Docker Desktop -qualification land. +tree, but managed-tool dispatch remains activation-gated until the integration +corpus and real WSL2 + Docker Desktop qualification land. The v2-required +native state inventory and cleanup surface is wired independently of that gate. The corresponding Windows behavior is documented separately in [the Windows shell/process contract](shell-contract.md). diff --git a/docs/wsl.md b/docs/wsl.md index 7b8c6f3..f281868 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -7,9 +7,10 @@ WSL frontend, and standalone Linux remains a separate, demand-gated product. The implementation establishes the runtime boundary, fixed native-WSL layout, explicit install/config lifecycle and the complete managed-tool composition -through Docker Desktop's Engine socket. Production dispatch remains fail-closed -until the remaining native management state lifecycle, integration corpus and -real WSL2 + Docker Desktop qualification land. +through Docker Desktop's Engine socket. Managed-tool dispatch remains +fail-closed until the integration corpus and real WSL2 + Docker Desktop +qualification land. The v2-required native state inventory and cleanup +lifecycle is wired and available independently of that gate. ## Runtime classification @@ -29,8 +30,9 @@ real WSL2 + Docker Desktop qualification land. became consistent. Other Linux kernels are standalone Linux and rejected. - `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; they perform no Docker or registry mutation and return before host enforcement. -- `cb wsl prepare --check|--apply`, `cb wsl install --check|--apply` and - `cb wsl cleanup --check|--apply` are the native-WSL management surface. +- `cb wsl prepare --check|--apply`, `cb wsl install --check|--apply`, + `cb wsl cleanup --check|--apply`, `cb state` and `cb gc` are the native-WSL + management surface. Managed tool shims are installed, but their runtime dispatch remains gated; other `cb` management commands remain explicitly unavailable rather than falling through to Windows-oriented Docker CLI, path or state behavior. @@ -115,7 +117,7 @@ created only when missing and fully revalidated. Foreign files, owners, targets or unsafe modes stop the transaction instead of being repaired or replaced. The install command itself performs no Docker request. After a successful apply and revalidation, its managed tool shims remain activation-gated by the -host boundary until state-command integration and qualification land. +host boundary until integration coverage and real qualification land. An interruption before the final binary rename can leave a current-user-owned `.cb-install-.tmp` regular file in the private binary directory. ContainerBin does not sweep filename lookalikes without stronger provenance; @@ -348,19 +350,32 @@ collision. The wired tool path plans the complete state set before mutation, ensures each exact volume, and passes its complete labels into container creation. Stateful project/shared profiles and the Python provider's per-project venv, namespace-shared compatibility venv and pip cache are wired. -`cb state`/`cb gc` and backup/restore remain separate native management work. +`cb state` validates the fixed installation and authenticated registry, derives +the exact distribution/machine/user namespace, constructs the current project +and configured shared identities, and proves every discovered Docker volume +before writing its report. It classifies exact current, shared and Python +compatibility state separately from stale managed state. A project volume is +`ORPHAN` only when its exact recorded canonical Linux path is absent; an +existing symlink or non-directory object is `UNSAFE` and never deletion input. + +`cb gc [TOOL|STATE_GROUP] [--orphans] [--apply]` uses the same complete proof +set. The default is a dry run. Current-project mode selects only identities +constructed from the current registry and freshly selected project. Orphan +mode selects only exactly proven project volumes whose recorded path is missing. +Shared volumes are never candidates. Apply mode removes without force through +the proof-bound lifecycle and verifies exact absence after deletion. All +discovery, ownership proof, path classification and planning finish before the +first mutation. Native backup/restore remains separate future management work; +it is not required for v2 runtime activation. ## Remaining before the v2 WSL support claim The ordinary managed tool path is implemented behind the host gate. Activation and release qualification still require all of the following: -1. complete the native state-management subset required for safe supported - cleanup and diagnostics; every consumer must construct and match the complete - distribution/machine/user identity; -2. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation +1. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation rejection; and -3. real WSL2 + Docker Desktop end-to-end qualification before any support claim. +2. real WSL2 + Docker Desktop end-to-end qualification before any support claim. The WSL runtime deliberately rejects `host_mounts`: that registry field uses a Windows drive-path grammar and silently reinterpreting it as Linux would violate diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 06895ba..2ff04a1 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -50,9 +50,10 @@ func Current() (Runtime, error) { } // RequireFrontend enforces the supported host boundary. Native Windows is -// enabled. Native WSL2 is classified precisely but remains activation-gated -// until state-command integration, the integration corpus and real -// qualification land. +// enabled. Native WSL2 is classified precisely but managed-tool dispatch +// remains activation-gated until the integration corpus and real qualification +// land. Its bootstrap/install and state-management commands are dispatched +// separately before this general gate. func RequireFrontend() error { return requireFrontend(Current()) } @@ -77,7 +78,7 @@ func requireFrontend(info Runtime, probeErr error) error { if err := validateDistroIdentity(info.Distro); err != nil { return fmt.Errorf("native WSL2 distribution identity cannot be proven: %w", err) } - return fmt.Errorf("native WSL2 distribution %q was detected; the tool runtime is wired but activation is gated until native state commands, integration coverage and real Docker Desktop qualification land", info.Distro) + return fmt.Errorf("native WSL2 distribution %q was detected; the tool runtime and state lifecycle are wired but activation is gated until integration coverage and real Docker Desktop qualification land", info.Distro) case WSL1Native: return errors.New("WSL1 is unsupported; the native frontend requires WSL2 and Docker Desktop WSL integration") case WSLUnrecognized: diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index 8884c93..aba3c09 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -303,7 +303,7 @@ func printPlan(out io.Writer, plan Plan, applied bool) error { } if plan.ready() { fmt.Fprintln(&report, "status: INSTALLATION READY") - fmt.Fprintln(&report, "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED (state commands, integration coverage and real WSL qualification remain)") + fmt.Fprintln(&report, "frontend: INSTALLED; RUNTIME/STATE WIRED; ACTIVATION GATED (integration coverage and real WSL qualification remain)") } else { fmt.Fprintln(&report, "status: APPLY REQUIRED") fmt.Fprintln(&report, "apply: cb wsl install --apply") diff --git a/internal/wslinstall/install_test.go b/internal/wslinstall/install_test.go index 0154a6a..677937e 100644 --- a/internal/wslinstall/install_test.go +++ b/internal/wslinstall/install_test.go @@ -246,7 +246,7 @@ func TestApplyComposesRegistryBinaryAndShimLifecycle(t *testing.T) { if !prepared || !locked || !registryExists || !binaryReady || !managementReady || !shimsReady { t.Fatalf("incomplete lifecycle: prepared=%t locked=%t registry=%t binary=%t management=%t shims=%t", prepared, locked, registryExists, binaryReady, managementReady, shimsReady) } - for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED"} { + for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: INSTALLED; RUNTIME/STATE WIRED; ACTIVATION GATED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } diff --git a/internal/wslrun/plan.go b/internal/wslrun/plan.go index bc73729..357f0f8 100644 --- a/internal/wslrun/plan.go +++ b/internal/wslrun/plan.go @@ -24,7 +24,6 @@ import ( const ( projectWorkspace = "/workspace/project" isolatedWorkspace = "/root" - pythonStateGroup = "python313" pythonBootstrap = `if [ ! -x /venv/bin/python ]; then python -m venv /venv || exit $?; fi; if [ "$1" = "__CB_PIP__" ]; then shift; exec /venv/bin/python -m pip "$@"; else exec /venv/bin/python "$@"; fi` ) @@ -35,15 +34,15 @@ type toolPlan struct { } type planDependencies struct { - resolveImage func(registry.Tool, policy.Policy, string) (string, error) - selectProject func(string, registry.Tool) (wslproject.Project, bool, error) - classifyDescendant func(wslproject.Project, string) (wslproject.Descendant, error) - mapArgs func(registry.Tool, wslproject.Project, string, string, []string) ([]string, string, error) - planVolumes func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Binding, error) + resolveImage func(registry.Tool, policy.Policy, string) (string, error) + selectProject func(string, registry.Tool) (wslproject.Project, bool, error) + mapArgs func(registry.Tool, wslproject.Project, string, string, []string) ([]string, string, error) + planVolumes func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Binding, error) + planPython func(wslvolume.Scope, wslproject.Project, bool) (wslvolume.PythonState, error) } func buildToolPlan(tool registry.Tool, userArgs []string, machinePolicy policy.Policy, layout hostenv.WSLLayout, cwd string, tty bool, environ []string, deps planDependencies) (toolPlan, error) { - if deps.resolveImage == nil || deps.selectProject == nil || deps.classifyDescendant == nil || deps.mapArgs == nil || deps.planVolumes == nil { + if deps.resolveImage == nil || deps.selectProject == nil || deps.mapArgs == nil || deps.planVolumes == nil || deps.planPython == nil { return toolPlan{}, errors.New("native WSL runtime planning dependencies are incomplete") } if len(tool.HostMounts) != 0 { @@ -120,30 +119,14 @@ func buildToolPlan(tool registry.Tool, userArgs []string, machinePolicy policy.P if tool.CwdMode == "isolated" { return toolPlan{}, errors.New("native WSL Python provider cannot use isolated cwd mode") } - var venv wslvolume.Volume - if found { - root, proofErr := deps.classifyDescendant(project, project.Root) - if proofErr != nil { - return toolPlan{}, fmt.Errorf("prove native WSL Python project root: %w", proofErr) - } - if !root.Exists || root.Path != project.Root || root.Relative != "." || root.NearestExisting != project.Root { - return toolPlan{}, errors.New("native WSL Python project proof did not identify the exact existing root") - } - venv, err = scope.Project(pythonStateGroup, "venv", project.Root) - } else { - venv, err = scope.Shared(pythonStateGroup, "compat-venv") - } - if err != nil { - return toolPlan{}, err - } - pipCache, err := scope.Shared(pythonStateGroup, "pip-cache") + pythonState, err := deps.planPython(scope, project, found) if err != nil { return toolPlan{}, err } - if err := appendVolume(venv, "/venv"); err != nil { + if err := appendVolume(pythonState.Venv, "/venv"); err != nil { return toolPlan{}, err } - if err := appendVolume(pipCache, "/root/.cache/pip"); err != nil { + if err := appendVolume(pythonState.PipCache, "/root/.cache/pip"); err != nil { return toolPlan{}, err } plan.spec.Environment, err = mergeLiteralEnvironment(plan.spec.Environment, @@ -263,10 +246,10 @@ func validEnvironmentName(name string) bool { func productionPlanDependencies() planDependencies { return planDependencies{ - resolveImage: lockfile.RuntimeImageForToolAt, - selectProject: wslproject.SelectForTool, - classifyDescendant: wslproject.ClassifyDescendant, - mapArgs: wslpathmap.MapToolArgs, - planVolumes: wslvolume.PlanStatefulToolVolumes, + resolveImage: lockfile.RuntimeImageForToolAt, + selectProject: wslproject.SelectForTool, + mapArgs: wslpathmap.MapToolArgs, + planVolumes: wslvolume.PlanStatefulToolVolumes, + planPython: wslvolume.PlanPythonVolumes, } } diff --git a/internal/wslrun/plan_test.go b/internal/wslrun/plan_test.go index 31f617f..6a78fc0 100644 --- a/internal/wslrun/plan_test.go +++ b/internal/wslrun/plan_test.go @@ -128,13 +128,6 @@ func testPlanDependencies(found bool) planDependencies { return planDependencies{ resolveImage: func(registry.Tool, policy.Policy, string) (string, error) { return "demo@sha256:locked", nil }, selectProject: func(string, registry.Tool) (wslproject.Project, bool, error) { return project, found, nil }, - classifyDescendant: func(_ wslproject.Project, candidate string) (wslproject.Descendant, error) { - relative := "." - if candidate != "/project" { - relative = strings.TrimPrefix(candidate, "/project/") - } - return wslproject.Descendant{Path: candidate, Relative: relative, Exists: true, NearestExisting: candidate}, nil - }, mapArgs: func(_ registry.Tool, _ wslproject.Project, _ string, workspace string, args []string) ([]string, string, error) { mapped := append([]string(nil), args...) for index, argument := range mapped { @@ -147,6 +140,17 @@ func testPlanDependencies(found bool) planDependencies { planVolumes: func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Binding, error) { return nil, nil }, + planPython: func(scope wslvolume.Scope, project wslproject.Project, found bool) (wslvolume.PythonState, error) { + venv, err := scope.Shared(wslvolume.PythonStateGroup, "compat-venv") + if found { + venv, err = scope.Project(wslvolume.PythonStateGroup, "venv", project.Root) + } + if err != nil { + return wslvolume.PythonState{}, err + } + cache, err := scope.Shared(wslvolume.PythonStateGroup, "pip-cache") + return wslvolume.PythonState{Venv: venv, PipCache: cache}, err + }, } } diff --git a/internal/wslstate/command.go b/internal/wslstate/command.go new file mode 100644 index 0000000..7f89633 --- /dev/null +++ b/internal/wslstate/command.go @@ -0,0 +1,470 @@ +// Package wslstate implements the native-WSL state inventory and explicit +// cleanup surface. Discovery never establishes ownership: every candidate is +// reconstructed and exactly proven before it is reported or removed. +package wslstate + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "sort" + "strings" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslproject" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +const projectWorkspace = "/workspace/project" + +type dependencies struct { + currentLayout func() (hostenv.WSLLayout, error) + checkLayout func(hostenv.WSLLayout) (wslfs.Plan, error) + checkRegistryRecovery func(hostenv.WSLLayout) error + loadPolicy func() (policy.Policy, error) + loadRegistry func(string, registry.Authenticator) (registry.Registry, string, error) + lstat func(string) (os.FileInfo, error) + getwd func() (string, error) + selectProject func(string, registry.Tool) (wslproject.Project, bool, error) + planStateful func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Volume, error) + planPython func(wslvolume.Scope, wslproject.Project, bool) (wslvolume.PythonState, error) + discover func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) + remove func(context.Context, wslvolume.Volume) error +} + +type classification struct { + status string + volume wslvolume.Volume + path string +} + +// Run executes cb state or cb gc against the fixed native-WSL installation. +func Run(ctx context.Context, args []string, out io.Writer) error { + return run(ctx, args, out, productionDependencies()) +} + +func productionDependencies() dependencies { + return dependencies{ + currentLayout: wslfs.CurrentLayout, + checkLayout: wslfs.Check, + checkRegistryRecovery: wslfs.CheckRegistryRecovery, + loadPolicy: policy.Load, + loadRegistry: registry.LoadAtReadOnly, + lstat: os.Lstat, + getwd: os.Getwd, + selectProject: wslproject.SelectForTool, + planStateful: func(scope wslvolume.Scope, tool registry.Tool, project wslproject.Project, workspace string) ([]wslvolume.Volume, error) { + bindings, err := wslvolume.PlanStatefulToolVolumes(scope, tool, project, workspace) + if err != nil { + return nil, err + } + volumes := make([]wslvolume.Volume, 0, len(bindings)) + for _, binding := range bindings { + volumes = append(volumes, binding.Volume()) + } + return volumes, nil + }, + planPython: wslvolume.PlanPythonVolumes, + discover: discoverProven, + remove: wslvolume.Remove, + } +} + +func discoverProven(ctx context.Context, scope wslvolume.Scope) ([]wslvolume.Volume, error) { + candidates, err := wslvolume.Discover(ctx, scope) + if err != nil { + return nil, err + } + volumes := make([]wslvolume.Volume, 0, len(candidates)) + seen := make(map[string]bool, len(candidates)) + for _, candidate := range candidates { + volume, err := wslvolume.ProveCandidate(scope, candidate) + if err != nil { + return nil, err + } + if seen[volume.Name()] { + return nil, fmt.Errorf("duplicate proven native WSL volume %q", volume.Name()) + } + seen[volume.Name()] = true + volumes = append(volumes, volume) + } + sort.Slice(volumes, func(i, j int) bool { return volumes[i].Name() < volumes[j].Name() }) + return volumes, nil +} + +func run(ctx context.Context, args []string, out io.Writer, deps dependencies) error { + if ctx == nil { + return errors.New("native WSL state command requires a context") + } + if out == nil { + return errors.New("native WSL state command requires an output writer") + } + if len(args) == 0 || (args[0] != "state" && args[0] != "gc") { + return errors.New("usage: cb state | cb gc [TOOL|STATE_GROUP] [--orphans] [--apply]") + } + if args[0] == "state" && len(args) != 1 { + return errors.New("usage: cb state") + } + var options gcOptions + if args[0] == "gc" { + var err error + options, err = parseGC(args[1:]) + if err != nil { + return err + } + } + if deps.currentLayout == nil || deps.checkLayout == nil || deps.checkRegistryRecovery == nil || deps.loadPolicy == nil || deps.loadRegistry == nil || deps.lstat == nil || deps.getwd == nil || deps.selectProject == nil || deps.planStateful == nil || deps.planPython == nil || deps.discover == nil || deps.remove == nil { + return errors.New("native WSL state command dependencies are incomplete") + } + + layout, err := deps.currentLayout() + if err != nil { + return fmt.Errorf("derive native WSL layout: %w", err) + } + checked, err := deps.checkLayout(layout) + if err != nil { + return fmt.Errorf("validate native WSL layout: %w", err) + } + if checked.Layout != layout || len(checked.MissingDirectories) != 0 { + return errors.New("native WSL installation layout is incomplete; run `cb wsl install --apply`") + } + if err := deps.checkRegistryRecovery(layout); err != nil { + return fmt.Errorf("validate native WSL registry recovery state: %w", err) + } + info, err := deps.lstat(layout.RegistryPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return errors.New("native WSL registry is not installed; run `cb wsl install --apply`") + } + return fmt.Errorf("inspect native WSL registry: %w", err) + } + if !info.Mode().IsRegular() { + return errors.New("native WSL registry is not a regular non-symlink file") + } + machinePolicy, err := deps.loadPolicy() + if err != nil { + return fmt.Errorf("load native WSL machine policy: %w", err) + } + reg, loadedPath, err := deps.loadRegistry(layout.RegistryPath, machinePolicy.AuthenticateRegistry) + if err != nil { + return fmt.Errorf("load native WSL registry: %w", err) + } + if loadedPath != layout.RegistryPath { + return fmt.Errorf("native WSL registry loader returned path %q, expected %q", loadedPath, layout.RegistryPath) + } + scope, err := wslvolume.New(layout) + if err != nil { + return err + } + cwd := "" + if args[0] == "state" || !options.orphans { + cwd, err = deps.getwd() + if err != nil { + return fmt.Errorf("determine native WSL working directory: %w", err) + } + } + + if args[0] == "state" { + return show(ctx, out, layout, scope, reg, cwd, deps) + } + return gc(ctx, out, scope, reg, cwd, options, deps) +} + +type gcOptions struct { + apply bool + orphans bool + filter string +} + +func parseGC(args []string) (gcOptions, error) { + var result gcOptions + for _, arg := range args { + switch arg { + case "--apply": + result.apply = true + case "--dry-run": + case "--orphans": + result.orphans = true + default: + if strings.HasPrefix(arg, "-") { + return gcOptions{}, fmt.Errorf("unknown option %q", arg) + } + if result.filter != "" { + return gcOptions{}, errors.New("usage: cb gc [TOOL|STATE_GROUP] [--orphans] [--apply]") + } + result.filter = strings.ToLower(arg) + } + } + return result, nil +} + +func show(ctx context.Context, out io.Writer, layout hostenv.WSLLayout, scope wslvolume.Scope, reg registry.Registry, cwd string, deps dependencies) error { + current, shared, err := expectedVolumes(scope, reg, cwd, "", deps) + if err != nil { + return err + } + actual, err := deps.discover(ctx, scope) + if err != nil { + return fmt.Errorf("discover and prove native WSL volumes: %w", err) + } + classified, err := classify(actual, current, shared, deps.lstat) + if err != nil { + return err + } + var report strings.Builder + fmt.Fprintf(&report, "distribution: %s\nnamespace: %s\n", layout.Distro, layout.StateNamespace) + fmt.Fprintf(&report, "%-9s %-64s %s\n", "STATUS", "VOLUME", "OWNER") + fmt.Fprintf(&report, "%-9s %-64s %s\n", "---------", "----------------------------------------------------------------", "-----") + for _, item := range classified { + owner := item.volume.Labels()["cb.owner"] + if item.path != "" { + owner += " (" + item.path + ")" + } + fmt.Fprintf(&report, "%-9s %-64s %s\n", item.status, item.volume.Name(), owner) + } + if len(classified) == 0 { + fmt.Fprintln(&report, "(no proven ContainerBin volumes found in this native WSL namespace)") + } + fmt.Fprintln(&report, "\nORPHAN requires a proven project volume whose recorded Linux project path no longer exists. Unsafe paths and shared volumes are never auto-deleted.") + if _, err := io.WriteString(out, report.String()); err != nil { + return fmt.Errorf("write native WSL state report: %w", err) + } + return nil +} + +func classify(actual []wslvolume.Volume, current, shared map[string]wslvolume.Volume, lstat func(string) (os.FileInfo, error)) ([]classification, error) { + result := make([]classification, 0, len(actual)) + for _, volume := range actual { + labels := volume.Labels() + item := classification{volume: volume} + if exact, ok := current[volume.Name()]; ok && exact.Matches(volume.Name(), labels) { + item.status = "CURRENT" + } else if exact, ok := shared[volume.Name()]; ok && exact.Matches(volume.Name(), labels) { + item.status = "SHARED" + if labels["cb.owner"] == wslvolume.PythonStateGroup+"/compat-venv" { + item.status = "COMPAT" + } + } else if labels["cb.kind"] == "shared" { + item.status = "MANAGED" + } else { + item.path = labels["cb.project_path"] + status, err := inspectProjectPath(item.path, lstat) + if err != nil { + return nil, err + } + item.status = status + } + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { return result[i].volume.Name() < result[j].volume.Name() }) + return result, nil +} + +func gc(ctx context.Context, out io.Writer, scope wslvolume.Scope, reg registry.Registry, cwd string, options gcOptions, deps dependencies) error { + actual, err := deps.discover(ctx, scope) + if err != nil { + return fmt.Errorf("discover and prove native WSL volumes: %w", err) + } + byName := make(map[string]wslvolume.Volume, len(actual)) + for _, volume := range actual { + byName[volume.Name()] = volume + } + var candidates []wslvolume.Volume + if options.orphans { + _, stateGroup := resolveFilter(reg, options.filter) + for _, volume := range actual { + labels := volume.Labels() + if labels["cb.kind"] != "project" || !ownerMatches(labels["cb.owner"], options.filter, stateGroup) { + continue + } + projectPath := labels["cb.project_path"] + status, statErr := inspectProjectPath(projectPath, deps.lstat) + if statErr != nil { + return statErr + } + if status == "ORPHAN" { + candidates = append(candidates, volume) + } + } + } else { + current, _, err := expectedVolumes(scope, reg, cwd, options.filter, deps) + if err != nil { + return err + } + if len(current) == 0 { + return errors.New("no project-scoped state matches the current directory/filter") + } + for name, expected := range current { + if observed, ok := byName[name]; ok && expected.Matches(observed.Name(), observed.Labels()) { + candidates = append(candidates, observed) + } + } + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name() < candidates[j].Name() }) + if len(candidates) == 0 { + message := "No matching project volumes currently exist.\n" + if options.orphans { + message = "No proven orphan project volumes found.\n" + } + _, err := io.WriteString(out, message) + return err + } + if !options.apply { + var report strings.Builder + if options.orphans { + fmt.Fprintln(&report, "Dry run: proven project volumes whose recorded Linux project path no longer exists:") + } else { + fmt.Fprintln(&report, "Dry run: the following CURRENT project volumes would be removed:") + } + for _, volume := range candidates { + labels := volume.Labels() + detail := labels["cb.owner"] + if options.orphans { + detail += " " + labels["cb.project_path"] + } + fmt.Fprintf(&report, " %-64s %s\n", volume.Name(), detail) + } + fmt.Fprintln(&report, "\nShared volumes are never included. Re-run with --apply to delete these exact proven volumes.") + _, err := io.WriteString(out, report.String()) + return err + } + for _, volume := range candidates { + if err := deps.remove(ctx, volume); err != nil { + return fmt.Errorf("remove native WSL volume %s: %w", volume.Name(), err) + } + } + var report strings.Builder + for _, volume := range candidates { + fmt.Fprintf(&report, "Removed %s\n", volume.Name()) + } + _, err = io.WriteString(out, report.String()) + return err +} + +func inspectProjectPath(recorded string, lstat func(string) (os.FileInfo, error)) (string, error) { + candidate := recorded + for { + info, err := lstat(candidate) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "UNSAFE", nil + } + if candidate == recorded { + return "MANAGED", nil + } + return "ORPHAN", nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("inspect recorded native WSL project path %s: %w", candidate, err) + } + parent := path.Dir(candidate) + if parent == candidate { + return "", fmt.Errorf("inspect recorded native WSL project path %s: no existing directory ancestor", recorded) + } + candidate = parent + } +} + +func expectedVolumes(scope wslvolume.Scope, reg registry.Registry, cwd, filter string, deps dependencies) (map[string]wslvolume.Volume, map[string]wslvolume.Volume, error) { + current := make(map[string]wslvolume.Volume) + shared := make(map[string]wslvolume.Volume) + resolved, _ := resolveFilter(reg, filter) + names := make([]string, 0, len(reg.Tools)) + for name := range reg.Tools { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + tool := reg.Tools[name] + if filter != "" && filter != tool.Name && resolved != tool.Name && filter != tool.StateGroup && !(filter == "python" && tool.Provider == "python") { + continue + } + switch tool.Provider { + case "stateful": + var project wslproject.Project + if len(tool.ProjectVolumes) > 0 { + var err error + project, _, err = deps.selectProject(cwd, tool) + if err != nil { + return nil, nil, fmt.Errorf("select native WSL project for %s: %w", tool.Name, err) + } + } + volumes, err := deps.planStateful(scope, tool, project, projectWorkspace) + if err != nil { + return nil, nil, fmt.Errorf("plan native WSL state for %s: %w", tool.Name, err) + } + for _, volume := range volumes { + target := shared + if volume.Labels()["cb.kind"] == "project" { + target = current + } + if err := addExpected(target, volume); err != nil { + return nil, nil, err + } + } + case "python": + project, found, err := deps.selectProject(cwd, tool) + if err != nil { + return nil, nil, fmt.Errorf("select native WSL project for %s: %w", tool.Name, err) + } + state, err := deps.planPython(scope, project, found) + if err != nil { + return nil, nil, fmt.Errorf("plan native WSL Python state for %s: %w", tool.Name, err) + } + if state.Venv.Labels()["cb.kind"] == "project" { + if err := addExpected(current, state.Venv); err != nil { + return nil, nil, err + } + } else if err := addExpected(shared, state.Venv); err != nil { + return nil, nil, err + } + if err := addExpected(shared, state.PipCache); err != nil { + return nil, nil, err + } + } + } + return current, shared, nil +} + +func addExpected(target map[string]wslvolume.Volume, volume wslvolume.Volume) error { + if prior, ok := target[volume.Name()]; ok && !prior.Matches(volume.Name(), volume.Labels()) { + return fmt.Errorf("native WSL state planning produced conflicting identity %q", volume.Name()) + } + target[volume.Name()] = volume + return nil +} + +func resolveFilter(reg registry.Registry, filter string) (resolved, stateGroup string) { + if filter == "" { + return "", "" + } + tool, resolved, ok := reg.Resolve(filter) + if !ok { + return "", "" + } + if tool.Provider == "python" { + return resolved, wslvolume.PythonStateGroup + } + return resolved, tool.StateGroup +} + +func ownerMatches(owner, filter, stateGroup string) bool { + if filter == "" { + return true + } + owner = strings.ToLower(owner) + for _, candidate := range []string{filter, stateGroup} { + candidate = strings.ToLower(candidate) + if candidate != "" && (owner == candidate || strings.HasPrefix(owner, candidate+"/")) { + return true + } + } + return false +} diff --git a/internal/wslstate/command_test.go b/internal/wslstate/command_test.go new file mode 100644 index 0000000..7ee982e --- /dev/null +++ b/internal/wslstate/command_test.go @@ -0,0 +1,253 @@ +package wslstate + +import ( + "bytes" + "context" + "errors" + "io/fs" + "os" + "strings" + "testing" + "time" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslproject" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +const testNamespace = "wsl2-0123456789abcdef0123456789abcdef" + +func TestStateClassifiesOnlyProvenNamespacedVolumes(t *testing.T) { + deps, scope := testDependencies(t) + project, err := scope.Project(wslvolume.PythonStateGroup, "venv", "/work/current") + if err != nil { + t.Fatal(err) + } + cache, _ := scope.Shared(wslvolume.PythonStateGroup, "pip-cache") + staleShared, _ := scope.Shared("node24", "cache") + orphan, _ := scope.Project("node24", "modules", "/work/gone") + unsafe, _ := scope.Project("go124", "cache", "/work/unsafe") + deps.discover = func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { + return []wslvolume.Volume{unsafe, staleShared, cache, project, orphan}, nil + } + deps.lstat = func(path string) (os.FileInfo, error) { + switch path { + case testLayout().RegistryPath: + return fakeInfo{mode: 0o600}, nil + case "/work/gone": + return nil, fs.ErrNotExist + case "/work/unsafe": + return fakeInfo{mode: os.ModeSymlink}, nil + default: + return fakeInfo{mode: os.ModeDir | 0o700}, nil + } + } + + var out bytes.Buffer + if err := run(context.Background(), []string{"state"}, &out, deps); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "distribution: Ubuntu-24.04", "namespace: " + testNamespace, + "CURRENT", project.Name(), "SHARED", cache.Name(), "MANAGED", staleShared.Name(), + "ORPHAN", orphan.Name(), "/work/gone", "UNSAFE", unsafe.Name(), "/work/unsafe", + } { + if !strings.Contains(out.String(), want) { + t.Fatalf("state output missing %q:\n%s", want, out.String()) + } + } +} + +func TestGCDryRunSelectsCurrentProjectAndNeverSharedState(t *testing.T) { + deps, scope := testDependencies(t) + project, _ := scope.Project(wslvolume.PythonStateGroup, "venv", "/work/current") + cache, _ := scope.Shared(wslvolume.PythonStateGroup, "pip-cache") + deps.discover = func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { + return []wslvolume.Volume{cache, project}, nil + } + removed := false + deps.remove = func(context.Context, wslvolume.Volume) error { removed = true; return nil } + + var out bytes.Buffer + if err := run(context.Background(), []string{"gc", "python"}, &out, deps); err != nil { + t.Fatal(err) + } + if removed || !strings.Contains(out.String(), project.Name()) || strings.Contains(out.String(), cache.Name()) { + t.Fatalf("dry-run removed=%t output=%q", removed, out.String()) + } +} + +func TestGCApplyRemovesOnlyMissingProvenProjectVolumes(t *testing.T) { + deps, scope := testDependencies(t) + missing, _ := scope.Project("node24", "modules", "/work/gone") + unsafe, _ := scope.Project("node24", "cache", "/work/unsafe") + other, _ := scope.Project("go124", "cache", "/work/other-gone") + shared, _ := scope.Shared("node24", "cache") + deps.discover = func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { + return []wslvolume.Volume{unsafe, shared, other, missing}, nil + } + deps.lstat = func(path string) (os.FileInfo, error) { + if path == testLayout().RegistryPath { + return fakeInfo{mode: 0o600}, nil + } + if path == "/work/unsafe" { + return fakeInfo{mode: os.ModeSymlink}, nil + } + if path == "/work" || path == "/" { + return fakeInfo{mode: os.ModeDir | 0o755}, nil + } + return nil, fs.ErrNotExist + } + var removed []string + deps.remove = func(_ context.Context, volume wslvolume.Volume) error { + removed = append(removed, volume.Name()) + return nil + } + reg := registry.Registry{Tools: map[string]registry.Tool{ + "node-v24": {Name: "node-v24", Provider: "stateful", StateGroup: "node24", ProjectVolumes: []string{"modules:/workspace/node_modules"}}, + }} + deps.loadRegistry = func(path string, _ registry.Authenticator) (registry.Registry, string, error) { return reg, path, nil } + + var out bytes.Buffer + if err := run(context.Background(), []string{"gc", "node24", "--orphans", "--apply"}, &out, deps); err != nil { + t.Fatal(err) + } + if len(removed) != 1 || removed[0] != missing.Name() { + t.Fatalf("removed = %#v, want only %s", removed, missing.Name()) + } + if !strings.Contains(out.String(), "Removed "+missing.Name()) { + t.Fatalf("apply output = %q", out.String()) + } +} + +func TestGCProofFailurePrecedesMutationAndOutput(t *testing.T) { + deps, _ := testDependencies(t) + deps.discover = func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { + return nil, errors.New("ownership proof failed") + } + deps.remove = func(context.Context, wslvolume.Volume) error { + t.Fatal("removal ran after proof failure") + return nil + } + var out bytes.Buffer + err := run(context.Background(), []string{"gc", "--orphans", "--apply"}, &out, deps) + if err == nil || !strings.Contains(err.Error(), "ownership proof failed") || out.Len() != 0 { + t.Fatalf("proof failure err=%v output=%q", err, out.String()) + } +} + +func TestStateRequiresInstalledRegularRegistry(t *testing.T) { + deps, _ := testDependencies(t) + deps.lstat = func(string) (os.FileInfo, error) { return nil, fs.ErrNotExist } + if err := run(context.Background(), []string{"state"}, &bytes.Buffer{}, deps); err == nil || !strings.Contains(err.Error(), "registry is not installed") { + t.Fatalf("missing registry error = %v", err) + } + deps.lstat = func(string) (os.FileInfo, error) { return fakeInfo{mode: os.ModeSymlink}, nil } + if err := run(context.Background(), []string{"state"}, &bytes.Buffer{}, deps); err == nil || !strings.Contains(err.Error(), "regular non-symlink") { + t.Fatalf("unsafe registry error = %v", err) + } +} + +func TestInvalidGCSyntaxFailsBeforeFrontendIO(t *testing.T) { + deps := dependencies{ + currentLayout: func() (hostenv.WSLLayout, error) { + t.Fatal("layout derived for invalid syntax") + return hostenv.WSLLayout{}, nil + }, + } + err := run(context.Background(), []string{"gc", "one", "two"}, &bytes.Buffer{}, deps) + if err == nil || !strings.Contains(err.Error(), "usage: cb gc") { + t.Fatalf("invalid syntax error = %v", err) + } +} + +func TestResolveFilterMapsDefaultAliasToStateGroup(t *testing.T) { + reg := registry.Registry{ + Tools: map[string]registry.Tool{ + "node24": { + Name: "node24", Provider: "stateful", StateGroup: "node24", + DefaultFamily: "node", DefaultVersion: "24", DefaultAlias: "node", + }, + }, + Defaults: map[string]string{"node": "24"}, + } + resolved, group := resolveFilter(reg, "node") + if resolved != "node24" || group != "node24" { + t.Fatalf("resolveFilter(node) = (%q, %q)", resolved, group) + } +} + +func TestMissingProjectBelowUnsafeAncestorIsNeverOrphaned(t *testing.T) { + status, err := inspectProjectPath("/work/link/gone", func(candidate string) (os.FileInfo, error) { + if candidate == "/work/link" { + return fakeInfo{mode: os.ModeSymlink}, nil + } + return nil, fs.ErrNotExist + }) + if err != nil || status != "UNSAFE" { + t.Fatalf("unsafe ancestor classification = (%q, %v)", status, err) + } +} + +func testDependencies(t *testing.T) (dependencies, wslvolume.Scope) { + t.Helper() + layout := testLayout() + scope, err := wslvolume.New(layout) + if err != nil { + t.Fatal(err) + } + reg := registry.Registry{Tools: map[string]registry.Tool{ + "python": {Name: "python", Provider: "python"}, + }} + return dependencies{ + currentLayout: func() (hostenv.WSLLayout, error) { return layout, nil }, + checkLayout: func(got hostenv.WSLLayout) (wslfs.Plan, error) { return wslfs.Plan{Layout: got}, nil }, + checkRegistryRecovery: func(hostenv.WSLLayout) error { return nil }, + loadPolicy: func() (policy.Policy, error) { return policy.Policy{}, nil }, + loadRegistry: func(path string, _ registry.Authenticator) (registry.Registry, string, error) { return reg, path, nil }, + lstat: func(string) (os.FileInfo, error) { return fakeInfo{mode: 0o600}, nil }, + getwd: func() (string, error) { return "/work/current", nil }, + selectProject: func(string, registry.Tool) (wslproject.Project, bool, error) { + return wslproject.Project{Root: "/work/current"}, true, nil + }, + planStateful: func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Volume, error) { + return nil, nil + }, + planPython: func(scope wslvolume.Scope, project wslproject.Project, found bool) (wslvolume.PythonState, error) { + venv, err := scope.Project(wslvolume.PythonStateGroup, "venv", project.Root) + if !found { + venv, err = scope.Shared(wslvolume.PythonStateGroup, "compat-venv") + } + if err != nil { + return wslvolume.PythonState{}, err + } + cache, err := scope.Shared(wslvolume.PythonStateGroup, "pip-cache") + return wslvolume.PythonState{Venv: venv, PipCache: cache}, err + }, + discover: func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { return nil, nil }, + remove: func(context.Context, wslvolume.Volume) error { return nil }, + }, scope +} + +func testLayout() hostenv.WSLLayout { + return hostenv.WSLLayout{ + Distro: "Ubuntu-24.04", UID: 1000, Home: "/home/alice", + BinaryPath: "/home/alice/.local/lib/container-bin/cb", ManagementShim: "/home/alice/.local/bin/cb", + ShimDir: "/home/alice/.local/bin", ConfigDir: "/home/alice/.config/container-bin", + RegistryPath: "/home/alice/.config/container-bin/container-bin.toml", + LockPath: "/home/alice/.config/container-bin/container-bin.lock", StateDir: "/home/alice/.local/state/container-bin", + StateNamespace: testNamespace, + } +} + +type fakeInfo struct{ mode os.FileMode } + +func (f fakeInfo) Name() string { return "fake" } +func (f fakeInfo) Size() int64 { return 1 } +func (f fakeInfo) Mode() os.FileMode { return f.mode } +func (f fakeInfo) ModTime() time.Time { return time.Time{} } +func (f fakeInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeInfo) Sys() any { return nil } diff --git a/internal/wslvolume/python.go b/internal/wslvolume/python.go new file mode 100644 index 0000000..36c9083 --- /dev/null +++ b/internal/wslvolume/python.go @@ -0,0 +1,63 @@ +package wslvolume + +import ( + "errors" + "fmt" + + "github.com/AviBackToBlack/container-bin/internal/wslproject" +) + +const PythonStateGroup = "python313" + +// PythonState is the complete native-WSL volume set used by the legacy Python +// provider. The compatibility environment and pip cache are namespace-shared; +// a marker-selected project receives an exact project-scoped environment. +type PythonState struct { + Venv Volume + PipCache Volume +} + +type pythonDependencies struct { + classifyDescendant func(wslproject.Project, string) (wslproject.Descendant, error) +} + +// PlanPythonVolumes constructs the same exact identities consumed by ordinary +// runtime execution without contacting Docker. +func PlanPythonVolumes(scope Scope, project wslproject.Project, found bool) (PythonState, error) { + return planPythonVolumes(scope, project, found, pythonDependencies{ + classifyDescendant: wslproject.ClassifyDescendant, + }) +} + +func planPythonVolumes(scope Scope, project wslproject.Project, found bool, deps pythonDependencies) (PythonState, error) { + if !validNamespace(scope.namespace) || scope.prefix != "cb-"+scope.namespace+"-" { + return PythonState{}, errors.New("native WSL Python-volume planning requires a valid scope") + } + var ( + venv Volume + err error + ) + if found { + if deps.classifyDescendant == nil { + return PythonState{}, errors.New("native WSL Python project-volume proof is unavailable") + } + root, proofErr := deps.classifyDescendant(project, project.Root) + if proofErr != nil { + return PythonState{}, fmt.Errorf("prove native WSL Python project root: %w", proofErr) + } + if !root.Exists || root.Path != project.Root || root.Relative != "." || root.NearestExisting != project.Root { + return PythonState{}, errors.New("native WSL Python project proof did not identify the exact existing root") + } + venv, err = scope.Project(PythonStateGroup, "venv", project.Root) + } else { + venv, err = scope.Shared(PythonStateGroup, "compat-venv") + } + if err != nil { + return PythonState{}, err + } + pipCache, err := scope.Shared(PythonStateGroup, "pip-cache") + if err != nil { + return PythonState{}, err + } + return PythonState{Venv: venv, PipCache: pipCache}, nil +} diff --git a/internal/wslvolume/python_test.go b/internal/wslvolume/python_test.go new file mode 100644 index 0000000..be6931d --- /dev/null +++ b/internal/wslvolume/python_test.go @@ -0,0 +1,75 @@ +package wslvolume + +import ( + "errors" + "strings" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/wslproject" +) + +func TestPlanPythonVolumesUsesProjectOrNamespaceCompatibilityIdentity(t *testing.T) { + project := testClassifiedProject() + projectState, err := planPythonVolumes(testScope(t), project, true, pythonDependencies{ + classifyDescendant: func(got wslproject.Project, candidate string) (wslproject.Descendant, error) { + if got.Root != project.Root || candidate != project.Root { + t.Fatalf("project proof = (%+v, %q)", got, candidate) + } + return exactProjectRoot(project), nil + }, + }) + if err != nil { + t.Fatal(err) + } + if labels := projectState.Venv.Labels(); labels["cb.kind"] != "project" || labels["cb.project_path"] != project.Root || labels["cb.owner"] != PythonStateGroup+"/venv" { + t.Fatalf("project venv labels = %#v", labels) + } + if labels := projectState.PipCache.Labels(); labels["cb.kind"] != "shared" || labels["cb.owner"] != PythonStateGroup+"/pip-cache" { + t.Fatalf("project pip-cache labels = %#v", labels) + } + + called := false + compat, err := planPythonVolumes(testScope(t), project, false, pythonDependencies{ + classifyDescendant: func(wslproject.Project, string) (wslproject.Descendant, error) { + called = true + return wslproject.Descendant{}, errors.New("unexpected proof") + }, + }) + if err != nil { + t.Fatal(err) + } + if called { + t.Fatal("compatibility planning attempted project proof") + } + if labels := compat.Venv.Labels(); labels["cb.kind"] != "shared" || labels["cb.owner"] != PythonStateGroup+"/compat-venv" { + t.Fatalf("compatibility venv labels = %#v", labels) + } +} + +func TestPlanPythonVolumesRejectsInvalidProjectProofBeforeReturningState(t *testing.T) { + project := testClassifiedProject() + for name, descendant := range map[string]wslproject.Descendant{ + "missing": {Path: project.Root, Relative: ".", NearestExisting: "/home/alice"}, + "wrong path": {Path: project.Root + "/child", Relative: ".", Exists: true, NearestExisting: project.Root}, + "wrong relative": {Path: project.Root, Relative: "child", Exists: true, NearestExisting: project.Root}, + } { + t.Run(name, func(t *testing.T) { + _, err := planPythonVolumes(testScope(t), project, true, pythonDependencies{ + classifyDescendant: func(wslproject.Project, string) (wslproject.Descendant, error) { return descendant, nil }, + }) + if err == nil || !strings.Contains(err.Error(), "exact existing root") { + t.Fatalf("inexact proof error = %v", err) + } + }) + } + if _, err := planPythonVolumes(testScope(t), project, true, pythonDependencies{ + classifyDescendant: func(wslproject.Project, string) (wslproject.Descendant, error) { + return wslproject.Descendant{}, errors.New("mount changed") + }, + }); err == nil || !strings.Contains(err.Error(), "mount changed") { + t.Fatalf("proof failure = %v", err) + } + if _, err := planPythonVolumes(Scope{}, project, false, pythonDependencies{}); err == nil { + t.Fatal("zero scope was accepted") + } +} diff --git a/internal/wslvolume/wslvolume.go b/internal/wslvolume/wslvolume.go index daf9f0f..784aef5 100644 --- a/internal/wslvolume/wslvolume.go +++ b/internal/wslvolume/wslvolume.go @@ -2,7 +2,7 @@ // native WSL2 distribution, machine and user namespace and binds exact // inspect/create/remove/discovery operations to the proven Docker Desktop WSL // control transport. It can preflight and ensure a stateful profile's complete -// binding set; container creation and state-command wiring remain separate. +// binding set; container creation and state commands compose this boundary. package wslvolume import ( diff --git a/main.go b/main.go index d083bd9..050094e 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/wslinstall" "github.com/AviBackToBlack/container-bin/internal/wslreconcile" "github.com/AviBackToBlack/container-bin/internal/wslrun" + "github.com/AviBackToBlack/container-bin/internal/wslstate" ) // version is injected at release time via: @@ -48,6 +49,11 @@ var currentHostRuntime = hostenv.Current // owns fixed-layout, registry, project, Docker and process validation. var runWSLTool = wslrun.Run +// runWSLState owns the fixed-layout, proof-bound native-WSL state inventory +// and cleanup surface. It is dispatched before the intentionally closed +// general WSL frontend gate, just like cb wsl bootstrap/install commands. +var runWSLState = wslstate.Run + // runSelfUpdate is a test seam for proving the complete explicit self-update // command remains available before policy or registry I/O. Production always // uses selfupdate.Run. @@ -79,6 +85,19 @@ func main() { } return } + if isManagementInvocation(invoked) && len(os.Args) > 1 && (os.Args[1] == "state" || os.Args[1] == "gc") { + hostRuntime, err := currentHostRuntime() + if err != nil { + fatalf("host runtime: %v", err) + return + } + if hostRuntime.Kind == hostenv.WSL2Native { + if err := runWSLState(context.Background(), os.Args[1:], os.Stdout); err != nil { + fatalf("%s: %v", os.Args[1], err) + } + return + } + } if err := requireHostFrontend(); err != nil { fatalf("host runtime: %v", err) return @@ -90,7 +109,7 @@ func main() { } if hostRuntime.Kind == hostenv.WSL2Native { if isManagementInvocation(invoked) { - fatalf("native WSL management command %q is unavailable; use `cb wsl install --check|--apply`, `cb wsl cleanup --check|--apply`, `cb version`, `cb help`, or a managed tool shim", strings.Join(os.Args[1:], " ")) + fatalf("native WSL management command %q is unavailable; use `cb state`, `cb gc`, `cb wsl install --check|--apply`, `cb wsl cleanup --check|--apply`, `cb version`, `cb help`, or a managed tool shim", strings.Join(os.Args[1:], " ")) return } code, err := runWSLTool(context.Background(), invoked, os.Args[1:]) @@ -485,9 +504,9 @@ Commands: cb help print this help without loading the registry Native WSL2: - Bootstrap and cb wsl ... are enabled. The managed-tool runtime is wired but - activation awaits native state commands, integration coverage and real - Docker Desktop qualification. + Bootstrap, cb wsl ..., cb state and cb gc are enabled. The managed-tool + runtime is wired but activation awaits integration coverage and real Docker + Desktop qualification. Registry: %s diff --git a/main_test.go b/main_test.go index 27f150d..7cfe006 100644 --- a/main_test.go +++ b/main_test.go @@ -200,6 +200,46 @@ func TestNativeWSLToolDispatchSkipsWindowsRegistryAndRuntime(t *testing.T) { main() } +func TestNativeWSLStateDispatchSkipsGeneralGateAndWindowsRegistry(t *testing.T) { + oldArgs := os.Args + oldRunWSLState := runWSLState + oldCurrentHostRuntime := currentHostRuntime + oldRequireHostFrontend := requireHostFrontend + oldLoadRegistry := loadRegistry + oldLoadPolicy := loadPolicy + defer func() { + os.Args = oldArgs + runWSLState = oldRunWSLState + currentHostRuntime = oldCurrentHostRuntime + requireHostFrontend = oldRequireHostFrontend + loadRegistry = oldLoadRegistry + loadPolicy = oldLoadPolicy + }() + + currentHostRuntime = func() (hostenv.Runtime, error) { + return hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: "Ubuntu-24.04"}, nil + } + requireHostFrontend = func() error { panic("native WSL state command attempted general host enforcement") } + loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { + panic("native WSL state command attempted the Windows registry loader") + } + loadPolicy = func() (policy.Policy, error) { + panic("native WSL state command attempted the Windows policy loader") + } + var got []string + runWSLState = func(_ context.Context, args []string, out io.Writer) error { + got = append([]string(nil), args...) + _, err := io.WriteString(out, "native WSL state reached\n") + return err + } + os.Args = []string{"cb", "gc", "node24", "--orphans"} + + out := captureMainStdout(t, main) + if strings.Join(got, " ") != "gc node24 --orphans" || out != "native WSL state reached\n" { + t.Fatalf("state args=%q output=%q", got, out) + } +} + func TestHostBoundaryPrecedesPolicyAndRegistryLoad(t *testing.T) { oldArgs := os.Args oldLoadRegistry := loadRegistry From 0189a316c407f9f704b7d87c5f8532c4e4318299 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:56:31 +0100 Subject: [PATCH 2/3] Harden WSL orphan deletion proofs --- README.md | 6 +- docs/roadmap-implementation-requirements.md | 10 +- docs/security-model.md | 7 +- docs/wsl.md | 12 ++- internal/wslproject/project.go | 110 ++++++++++++++++++++ internal/wslproject/project_linux.go | 10 ++ internal/wslproject/project_other.go | 6 ++ internal/wslproject/project_test.go | 79 ++++++++++++++ internal/wslstate/command.go | 62 +++++------ internal/wslstate/command_test.go | 42 ++++++-- 10 files changed, 295 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 35420db..986df45 100644 --- a/README.md +++ b/README.md @@ -971,8 +971,10 @@ discover only the current distribution/machine/user namespace, then reconstruct and exactly prove every candidate before producing output or mutation. `cb gc` is a dry run unless `--apply` is explicit, never selects shared volumes, and uses non-force deletion with an absence check. `--orphans` requires the recorded -canonical Linux project path to be missing; symlinks and non-directory objects -are reported as unsafe state rather than guessed to be orphans. Native state +canonical Linux project path to be missing below a currently proven supported +storage boundary; a vanished `/mnt/` mount, symlinked ancestry and +non-directory objects are unsafe rather than guessed to be orphans. Apply +re-proves absence immediately before each removal. Native state backup/restore remains separate future work and is not part of this v2 runtime activation gate. diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 7fbed3a..33f62c8 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -654,10 +654,12 @@ The v2-required native state subset is now composed. `cb state` and `cb gc` consume only the fixed layout and authenticated registry, prove the complete discovered namespace before output or mutation, never select shared volumes for deletion, and classify an orphan only when the recorded canonical Linux project -path is missing. Existing symlinks and non-directory objects are unsafe rather -than orphan evidence. Apply uses proof-bound non-force removal and verifies -absence. Native state backup/restore remains deferred and is not an activation -prerequisite for the supported runtime contract. +path is missing below a currently live supported storage boundary. Vanished +default drive mounts, symlinked ancestry and non-directory objects are unsafe +rather than orphan evidence. Apply re-proves orphan status immediately before +proof-bound non-force removal and verifies absence. Native state backup/restore +remains deferred and is not an activation prerequisite for the supported +runtime contract. Qualification must include both Windows-filesystem and WSL-filesystem projects plus mixed invocation rejection cases. diff --git a/docs/security-model.md b/docs/security-model.md index 64b584b..dbdb334 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -156,9 +156,10 @@ readable, and dangerous to let others edit. every namespace discovery result into an exact immutable volume identity, and complete the whole plan before the first deletion. Shared volumes are never GC candidates. A project volume is orphaned only when its recorded - canonical Linux path is absent; symlinks, non-directories and inspection - errors are preserved and fail closed. Apply deletes without force and proves - absence afterward. + canonical Linux path is absent below a currently live supported storage + boundary; vanished default drive mounts, symlinked ancestry, non-directories + and inspection errors are preserved and fail closed. Apply re-proves orphan + status immediately before each non-force deletion and proves absence afterward. - **Native WSL run containers are transaction-bound.** The create primitive accepts no raw Engine body, endpoint, privilege or Docker-socket mount controls. It admits at most one existing, symlink-free canonical project diff --git a/docs/wsl.md b/docs/wsl.md index f281868..14f1da7 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -355,8 +355,10 @@ the exact distribution/machine/user namespace, constructs the current project and configured shared identities, and proves every discovered Docker volume before writing its report. It classifies exact current, shared and Python compatibility state separately from stale managed state. A project volume is -`ORPHAN` only when its exact recorded canonical Linux path is absent; an -existing symlink or non-directory object is `UNSAFE` and never deletion input. +`ORPHAN` only when its exact recorded canonical Linux path is absent below a +currently proven supported storage boundary. A vanished default Windows-drive +mount, symlinked ancestry or non-directory object is `UNSAFE` and never +deletion input. `cb gc [TOOL|STATE_GROUP] [--orphans] [--apply]` uses the same complete proof set. The default is a dry run. Current-project mode selects only identities @@ -365,8 +367,10 @@ mode selects only exactly proven project volumes whose recorded path is missing. Shared volumes are never candidates. Apply mode removes without force through the proof-bound lifecycle and verifies exact absence after deletion. All discovery, ownership proof, path classification and planning finish before the -first mutation. Native backup/restore remains separate future management work; -it is not required for v2 runtime activation. +first mutation. Apply also re-proves orphan status immediately before each +removal, so a restored project invalidates the stale plan. Native backup/restore +remains separate future management work; it is not required for v2 runtime +activation. ## Remaining before the v2 WSL support claim diff --git a/internal/wslproject/project.go b/internal/wslproject/project.go index 667b369..454640d 100644 --- a/internal/wslproject/project.go +++ b/internal/wslproject/project.go @@ -67,6 +67,12 @@ type mountInfo struct { superOptions string } +type missingBoundary struct { + nearest string + missing string + info pathInfo +} + func classify(root string, d dependencies) (Project, error) { if err := validateRoot(root); err != nil { return Project{}, err @@ -209,6 +215,110 @@ func resolveDescendant(project Project, candidate string, d dependencies) (Desce return Descendant{Path: candidate, Relative: relative, Exists: exists, NearestExisting: nearest}, nil } +// proveMissingProject proves that root is absent below an unchanged supported +// native-WSL storage boundary. It checks every existing lexical ancestor with +// Lstat, so a symlink cannot be hidden above the nearest existing directory, +// and repeats the ancestry proof after reading mountinfo to bound replacement +// races. A vanished default /mnt/ mount is never orphan evidence. +func proveMissingProject(root string, d dependencies) error { + if err := validateRoot(root); err != nil { + return err + } + if d.currentRuntime == nil || d.lstat == nil || d.readMountInfo == nil { + return errors.New("missing WSL project proof dependencies are incomplete") + } + runtime, err := d.currentRuntime() + if err != nil { + return fmt.Errorf("classify native WSL runtime: %w", err) + } + if runtime.Kind != hostenv.WSL2Native { + return fmt.Errorf("missing WSL project proof requires runtime kind %q, got %q", hostenv.WSL2Native, runtime.Kind) + } + before, err := inspectMissingBoundary(root, d.lstat) + if err != nil { + return err + } + rawMounts, err := d.readMountInfo() + if err != nil { + return fmt.Errorf("read WSL mount table for missing project %s: %w", root, err) + } + mounts, err := parseMountInfo(rawMounts) + if err != nil { + return fmt.Errorf("parse WSL mount table for missing project %s: %w", root, err) + } + + if drive, underDrive := defaultDriveRoot(root); underDrive { + mountPoint := "/mnt/" + drive + if before.nearest != mountPoint && !strings.HasPrefix(before.nearest, mountPoint+"/") { + return fmt.Errorf("Windows drive mount %s is unavailable; missing project %s is unsafe to orphan", mountPoint, root) + } + mount, err := containingMount(root, mounts) + if err != nil { + return fmt.Errorf("select mount for missing WSL project %s: %w", root, err) + } + if mount.point != mountPoint || !isDrvFS(mount, drive) || mount.device != before.info.Dev { + return fmt.Errorf("Windows drive mount %s is not the expected live DrvFs boundary", mountPoint) + } + } else { + distroRoot, err := d.lstat("/") + if err != nil { + return fmt.Errorf("inspect native WSL distribution root: %w", err) + } + mount, err := containingMount(before.nearest, mounts) + if err != nil { + return fmt.Errorf("select mount for missing WSL project %s: %w", root, err) + } + if before.info.Dev != distroRoot.Dev || mount.device != before.info.Dev { + return fmt.Errorf("missing WSL project %s is below an unqualified filesystem boundary", root) + } + } + + after, err := inspectMissingBoundary(root, d.lstat) + if err != nil { + return err + } + if before.nearest != after.nearest || before.missing != after.missing || before.info != after.info { + return fmt.Errorf("missing WSL project %s ancestry changed during proof", root) + } + return nil +} + +func inspectMissingBoundary(root string, lstat func(string) (pathInfo, error)) (missingBoundary, error) { + nearest := missingBoundary{} + current := "" + for _, element := range strings.Split(strings.TrimPrefix(root, "/"), "/") { + current += "/" + element + info, err := lstat(current) + if err == nil { + if info.Mode&os.ModeSymlink != 0 || !info.Mode.IsDir() { + return missingBoundary{}, fmt.Errorf("WSL project ancestor %s must be a non-symlink directory", current) + } + nearest = missingBoundary{nearest: current, info: info} + continue + } + if !errors.Is(err, fs.ErrNotExist) { + return missingBoundary{}, fmt.Errorf("inspect WSL project ancestor %s: %w", current, err) + } + nearest.missing = current + break + } + if nearest.missing == "" { + return missingBoundary{}, fmt.Errorf("WSL project path %s exists and is not orphaned", root) + } + if nearest.nearest == "" { + rootInfo, err := lstat("/") + if err != nil { + return missingBoundary{}, fmt.Errorf("inspect native WSL distribution root: %w", err) + } + if rootInfo.Mode&os.ModeSymlink != 0 || !rootInfo.Mode.IsDir() { + return missingBoundary{}, errors.New("native WSL distribution root must be a non-symlink directory") + } + nearest.nearest = "/" + nearest.info = rootInfo + } + return nearest, nil +} + func sameProjectIdentity(first, second Project) bool { return first.Root == second.Root && first.Storage == second.Storage && first.MountPoint == second.MountPoint && first.WindowsDrive == second.WindowsDrive && first.mountID == second.mountID && first.device == second.device diff --git a/internal/wslproject/project_linux.go b/internal/wslproject/project_linux.go index 4b332d1..d5c4780 100644 --- a/internal/wslproject/project_linux.go +++ b/internal/wslproject/project_linux.go @@ -40,6 +40,16 @@ func ClassifyDescendant(project Project, candidate string) (Descendant, error) { }) } +// ProveMissingProject proves that an absent recorded project path still lies +// below the same supported distribution or default Windows-drive boundary. +func ProveMissingProject(root string) error { + return proveMissingProject(root, dependencies{ + currentRuntime: hostenv.Current, + lstat: statPath, + readMountInfo: readMountInfo, + }) +} + // SelectForTool applies a profile's marker policy and classifies the selected // native-WSL project root. The boolean reports whether a marker or trusted // overlay root was found; otherwise the proven working directory is the root. diff --git a/internal/wslproject/project_other.go b/internal/wslproject/project_other.go index 2d0bc60..ff9e044 100644 --- a/internal/wslproject/project_other.go +++ b/internal/wslproject/project_other.go @@ -20,6 +20,12 @@ func ClassifyDescendant(Project, string) (Descendant, error) { return Descendant{}, errors.New("native WSL project path classification requires Linux") } +// ProveMissingProject is unavailable outside Linux because mount identity and +// symlink semantics are part of the missing-project proof. +func ProveMissingProject(string) error { + return errors.New("native WSL missing-project proof requires Linux") +} + // SelectForTool is unavailable outside Linux because project selection must // immediately prove the selected root and working directory mount identity. func SelectForTool(string, registry.Tool) (Project, bool, error) { diff --git a/internal/wslproject/project_test.go b/internal/wslproject/project_test.go index 2b24993..ae3136e 100644 --- a/internal/wslproject/project_test.go +++ b/internal/wslproject/project_test.go @@ -203,6 +203,85 @@ func TestResolveDescendantRejectsInvalidInputsAndChangedProjectIdentity(t *testi } } +func TestProveMissingProjectRequiresLiveSupportedStorageBoundary(t *testing.T) { + driveMounts := rootMount + "25 24 0:45 / /mnt/c rw - 9p drvfsa rw,aname=drvfs;path=C:\\134;uid=1000\n" + tests := map[string]struct { + root string + mounts string + infos map[string]pathInfo + want string + }{ + "distribution": { + root: "/home/alice/gone", mounts: rootMount, + infos: map[string]pathInfo{ + "/": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/home": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/home/alice": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, + }, + }, + "live default drive": { + root: "/mnt/c/Work/gone", mounts: driveMounts, + infos: map[string]pathInfo{ + "/": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/mnt": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/mnt/c": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(0, 45)}, "/mnt/c/Work": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(0, 45)}, + }, + }, + "vanished default drive": { + root: "/mnt/c/Work/gone", mounts: rootMount, + infos: map[string]pathInfo{ + "/": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/mnt": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, + }, + want: "mount /mnt/c is unavailable", + }, + "symlinked ancestor": { + root: "/work/link/gone", mounts: rootMount, + infos: map[string]pathInfo{ + "/": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/work": {Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, "/work/link": {Mode: os.ModeSymlink | 0o777, Dev: linuxDevice(8, 1)}, + }, + want: "non-symlink directory", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + deps := dependencies{ + currentRuntime: func() (hostenv.Runtime, error) { return hostenv.Runtime{Kind: hostenv.WSL2Native}, nil }, + lstat: func(candidate string) (pathInfo, error) { + if info, ok := tc.infos[candidate]; ok { + return info, nil + } + return pathInfo{}, fs.ErrNotExist + }, + readMountInfo: func() ([]byte, error) { return []byte(tc.mounts), nil }, + } + err := proveMissingProject(tc.root, deps) + if tc.want == "" && err != nil { + t.Fatal(err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("proveMissingProject() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestProveMissingProjectRejectsRecreatedPathDuringProof(t *testing.T) { + root := "/home/alice/gone" + rootChecks := 0 + deps := dependencies{ + currentRuntime: func() (hostenv.Runtime, error) { return hostenv.Runtime{Kind: hostenv.WSL2Native}, nil }, + lstat: func(candidate string) (pathInfo, error) { + if candidate == root { + rootChecks++ + if rootChecks == 1 { + return pathInfo{}, fs.ErrNotExist + } + } + return pathInfo{Mode: os.ModeDir | 0o755, Dev: linuxDevice(8, 1)}, nil + }, + readMountInfo: func() ([]byte, error) { return []byte(rootMount), nil }, + } + if err := proveMissingProject(root, deps); err == nil || !strings.Contains(err.Error(), "exists and is not orphaned") { + t.Fatalf("recreated path error = %v", err) + } +} + func TestClassifyRejectsMountHiddenAtAncestor(t *testing.T) { root := "/mnt/c/Users/Alice/Project" mounts := rootMount + diff --git a/internal/wslstate/command.go b/internal/wslstate/command.go index 7f89633..353e1e4 100644 --- a/internal/wslstate/command.go +++ b/internal/wslstate/command.go @@ -10,7 +10,6 @@ import ( "io" "io/fs" "os" - "path" "sort" "strings" @@ -35,6 +34,7 @@ type dependencies struct { selectProject func(string, registry.Tool) (wslproject.Project, bool, error) planStateful func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Volume, error) planPython func(wslvolume.Scope, wslproject.Project, bool) (wslvolume.PythonState, error) + proveMissing func(string) error discover func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) remove func(context.Context, wslvolume.Volume) error } @@ -71,9 +71,10 @@ func productionDependencies() dependencies { } return volumes, nil }, - planPython: wslvolume.PlanPythonVolumes, - discover: discoverProven, - remove: wslvolume.Remove, + planPython: wslvolume.PlanPythonVolumes, + proveMissing: wslproject.ProveMissingProject, + discover: discoverProven, + remove: wslvolume.Remove, } } @@ -120,7 +121,7 @@ func run(ctx context.Context, args []string, out io.Writer, deps dependencies) e return err } } - if deps.currentLayout == nil || deps.checkLayout == nil || deps.checkRegistryRecovery == nil || deps.loadPolicy == nil || deps.loadRegistry == nil || deps.lstat == nil || deps.getwd == nil || deps.selectProject == nil || deps.planStateful == nil || deps.planPython == nil || deps.discover == nil || deps.remove == nil { + if deps.currentLayout == nil || deps.checkLayout == nil || deps.checkRegistryRecovery == nil || deps.loadPolicy == nil || deps.loadRegistry == nil || deps.lstat == nil || deps.getwd == nil || deps.selectProject == nil || deps.planStateful == nil || deps.planPython == nil || deps.proveMissing == nil || deps.discover == nil || deps.remove == nil { return errors.New("native WSL state command dependencies are incomplete") } @@ -214,7 +215,7 @@ func show(ctx context.Context, out io.Writer, layout hostenv.WSLLayout, scope ws if err != nil { return fmt.Errorf("discover and prove native WSL volumes: %w", err) } - classified, err := classify(actual, current, shared, deps.lstat) + classified, err := classify(actual, current, shared, deps.lstat, deps.proveMissing) if err != nil { return err } @@ -239,7 +240,7 @@ func show(ctx context.Context, out io.Writer, layout hostenv.WSLLayout, scope ws return nil } -func classify(actual []wslvolume.Volume, current, shared map[string]wslvolume.Volume, lstat func(string) (os.FileInfo, error)) ([]classification, error) { +func classify(actual []wslvolume.Volume, current, shared map[string]wslvolume.Volume, lstat func(string) (os.FileInfo, error), proveMissing func(string) error) ([]classification, error) { result := make([]classification, 0, len(actual)) for _, volume := range actual { labels := volume.Labels() @@ -255,7 +256,7 @@ func classify(actual []wslvolume.Volume, current, shared map[string]wslvolume.Vo item.status = "MANAGED" } else { item.path = labels["cb.project_path"] - status, err := inspectProjectPath(item.path, lstat) + status, err := inspectProjectPath(item.path, lstat, proveMissing) if err != nil { return nil, err } @@ -285,7 +286,7 @@ func gc(ctx context.Context, out io.Writer, scope wslvolume.Scope, reg registry. continue } projectPath := labels["cb.project_path"] - status, statErr := inspectProjectPath(projectPath, deps.lstat) + status, statErr := inspectProjectPath(projectPath, deps.lstat, deps.proveMissing) if statErr != nil { return statErr } @@ -336,6 +337,16 @@ func gc(ctx context.Context, out io.Writer, scope wslvolume.Scope, reg registry. return err } for _, volume := range candidates { + if options.orphans { + projectPath := volume.Labels()["cb.project_path"] + status, err := inspectProjectPath(projectPath, deps.lstat, deps.proveMissing) + if err != nil { + return err + } + if status != "ORPHAN" { + return fmt.Errorf("native WSL project path %s changed before removal; refusing stale orphan plan", projectPath) + } + } if err := deps.remove(ctx, volume); err != nil { return fmt.Errorf("remove native WSL volume %s: %w", volume.Name(), err) } @@ -348,28 +359,21 @@ func gc(ctx context.Context, out io.Writer, scope wslvolume.Scope, reg registry. return err } -func inspectProjectPath(recorded string, lstat func(string) (os.FileInfo, error)) (string, error) { - candidate := recorded - for { - info, err := lstat(candidate) - if err == nil { - if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { - return "UNSAFE", nil - } - if candidate == recorded { - return "MANAGED", nil - } - return "ORPHAN", nil - } - if !errors.Is(err, fs.ErrNotExist) { - return "", fmt.Errorf("inspect recorded native WSL project path %s: %w", candidate, err) - } - parent := path.Dir(candidate) - if parent == candidate { - return "", fmt.Errorf("inspect recorded native WSL project path %s: no existing directory ancestor", recorded) +func inspectProjectPath(recorded string, lstat func(string) (os.FileInfo, error), proveMissing func(string) error) (string, error) { + info, err := lstat(recorded) + if err == nil { + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return "UNSAFE", nil } - candidate = parent + return "MANAGED", nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", fmt.Errorf("inspect recorded native WSL project path %s: %w", recorded, err) + } + if err := proveMissing(recorded); err != nil { + return "UNSAFE", nil } + return "ORPHAN", nil } func expectedVolumes(scope wslvolume.Scope, reg registry.Registry, cwd, filter string, deps dependencies) (map[string]wslvolume.Volume, map[string]wslvolume.Volume, error) { diff --git a/internal/wslstate/command_test.go b/internal/wslstate/command_test.go index 7ee982e..7639160 100644 --- a/internal/wslstate/command_test.go +++ b/internal/wslstate/command_test.go @@ -180,18 +180,45 @@ func TestResolveFilterMapsDefaultAliasToStateGroup(t *testing.T) { } } -func TestMissingProjectBelowUnsafeAncestorIsNeverOrphaned(t *testing.T) { - status, err := inspectProjectPath("/work/link/gone", func(candidate string) (os.FileInfo, error) { - if candidate == "/work/link" { - return fakeInfo{mode: os.ModeSymlink}, nil - } +func TestMissingProjectWithUnsafeBoundaryProofIsNeverOrphaned(t *testing.T) { + status, err := inspectProjectPath("/work/link/gone", func(string) (os.FileInfo, error) { return nil, fs.ErrNotExist + }, func(string) error { + return errors.New("symlinked ancestor") }) if err != nil || status != "UNSAFE" { t.Fatalf("unsafe ancestor classification = (%q, %v)", status, err) } } +func TestGCRechecksOrphanImmediatelyBeforeRemoval(t *testing.T) { + deps, scope := testDependencies(t) + orphan, _ := scope.Project("node24", "modules", "/work/restored") + deps.discover = func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { + return []wslvolume.Volume{orphan}, nil + } + checks := 0 + deps.lstat = func(path string) (os.FileInfo, error) { + if path == testLayout().RegistryPath { + return fakeInfo{mode: 0o600}, nil + } + checks++ + if checks == 1 { + return nil, fs.ErrNotExist + } + return fakeInfo{mode: os.ModeDir | 0o755}, nil + } + deps.remove = func(context.Context, wslvolume.Volume) error { + t.Fatal("restored project volume was removed") + return nil + } + var out bytes.Buffer + err := run(context.Background(), []string{"gc", "--orphans", "--apply"}, &out, deps) + if err == nil || !strings.Contains(err.Error(), "refusing stale orphan plan") || out.Len() != 0 { + t.Fatalf("stale-plan result err=%v output=%q", err, out.String()) + } +} + func testDependencies(t *testing.T) (dependencies, wslvolume.Scope) { t.Helper() layout := testLayout() @@ -227,8 +254,9 @@ func testDependencies(t *testing.T) (dependencies, wslvolume.Scope) { cache, err := scope.Shared(wslvolume.PythonStateGroup, "pip-cache") return wslvolume.PythonState{Venv: venv, PipCache: cache}, err }, - discover: func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { return nil, nil }, - remove: func(context.Context, wslvolume.Volume) error { return nil }, + proveMissing: func(string) error { return nil }, + discover: func(context.Context, wslvolume.Scope) ([]wslvolume.Volume, error) { return nil, nil }, + remove: func(context.Context, wslvolume.Volume) error { return nil }, }, scope } From a683c169d362e6bdd326d0f91a59c8c55d1b92a2 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:02:51 +0100 Subject: [PATCH 3/3] Honor Python state-group cleanup filter --- internal/wslstate/command.go | 9 ++++++++- internal/wslstate/command_test.go | 16 ++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/wslstate/command.go b/internal/wslstate/command.go index 353e1e4..6cdcf7d 100644 --- a/internal/wslstate/command.go +++ b/internal/wslstate/command.go @@ -387,7 +387,7 @@ func expectedVolumes(scope wslvolume.Scope, reg registry.Registry, cwd, filter s sort.Strings(names) for _, name := range names { tool := reg.Tools[name] - if filter != "" && filter != tool.Name && resolved != tool.Name && filter != tool.StateGroup && !(filter == "python" && tool.Provider == "python") { + if !toolMatchesFilter(tool, filter, resolved) { continue } switch tool.Provider { @@ -437,6 +437,13 @@ func expectedVolumes(scope wslvolume.Scope, reg registry.Registry, cwd, filter s return current, shared, nil } +func toolMatchesFilter(tool registry.Tool, filter, resolved string) bool { + if filter == "" || filter == tool.Name || resolved == tool.Name || filter == tool.StateGroup { + return true + } + return tool.Provider == "python" && (filter == "python" || filter == wslvolume.PythonStateGroup) +} + func addExpected(target map[string]wslvolume.Volume, volume wslvolume.Volume) error { if prior, ok := target[volume.Name()]; ok && !prior.Matches(volume.Name(), volume.Labels()) { return fmt.Errorf("native WSL state planning produced conflicting identity %q", volume.Name()) diff --git a/internal/wslstate/command_test.go b/internal/wslstate/command_test.go index 7639160..8343947 100644 --- a/internal/wslstate/command_test.go +++ b/internal/wslstate/command_test.go @@ -71,12 +71,16 @@ func TestGCDryRunSelectsCurrentProjectAndNeverSharedState(t *testing.T) { removed := false deps.remove = func(context.Context, wslvolume.Volume) error { removed = true; return nil } - var out bytes.Buffer - if err := run(context.Background(), []string{"gc", "python"}, &out, deps); err != nil { - t.Fatal(err) - } - if removed || !strings.Contains(out.String(), project.Name()) || strings.Contains(out.String(), cache.Name()) { - t.Fatalf("dry-run removed=%t output=%q", removed, out.String()) + for _, filter := range []string{"python", wslvolume.PythonStateGroup} { + t.Run(filter, func(t *testing.T) { + var out bytes.Buffer + if err := run(context.Background(), []string{"gc", filter}, &out, deps); err != nil { + t.Fatal(err) + } + if removed || !strings.Contains(out.String(), project.Name()) || strings.Contains(out.String(), cache.Name()) { + t.Fatalf("dry-run removed=%t output=%q", removed, out.String()) + } + }) } }