diff --git a/docs/architecture.md b/docs/architecture.md index 6fb4f7d..6ecaf63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -259,7 +259,7 @@ internal/registry Tool/Registry, TOML parser, defaults, registry file internal/toml the shared TOML subset lexer (leaf) internal/atomicio crash-safe write + .bak recovery (leaf) internal/mutationlock the registry mutation lock primitive (leaf) -internal/hostenv Windows/WSL/Linux runtime classification (leaf) +internal/hostenv host classification and gated WSL layout (leaf) internal/selfupdate canonical release selection and read-only plan (leaf) ``` diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 5fa514c..40596db 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -82,6 +82,20 @@ project identities and ContainerBin state namespaces. ContainerBin does not infer identity equivalence between `C:\x`, `/mnt/c/x` or `\\wsl$\...`. Native Linux path, permission, symlink, case, TTY and signal semantics apply. +The accepted native layout is fixed rather than XDG-configurable: the managed +binary is `~/.local/lib/container-bin/cb`; management and tool shims are under +`~/.local/bin`; the registry and lockfile are under +`~/.config/container-bin`; and private state is under +`~/.local/state/container-bin`. The home must be a canonical distribution-local +Linux path, never `/mnt/*`. + +Docker state identity is the exact case-sensitive WSL distribution name, +canonical `/etc/machine-id` and numeric Linux UID, hashed under a versioned +domain into an opaque namespace. Every managed WSL volume must carry that +namespace in both its name and ownership labels, and all lifecycle operations +must filter by exact namespace. ContainerBin does not normalize identities or +silently adopt state across distributions, reinstalls or users. + ### Enterprise policy — machine constraint layer Enterprise policy is not another registry merge layer. User registry, future diff --git a/docs/wsl.md b/docs/wsl.md index 63e2bac..db24299 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -5,10 +5,10 @@ Linux shims inside one WSL2 distribution, using Docker Desktop's supported WSL integration. A Windows `cb.exe` launched through WSL interoperability is not the WSL frontend, and standalone Linux remains a separate, demand-gated product. -This first implementation slice establishes the runtime boundary only. It does -not publish a Linux artifact or enable WSL execution yet. Until the remaining -layout, namespace and Docker qualification slices land, non-bootstrap commands -fail closed on every host except native Windows. +The implemented foundation establishes the runtime boundary and the fixed +native-WSL layout contract. It does not publish a Linux artifact or enable WSL +execution yet. Until the remaining filesystem, Docker and qualification slices +land, non-bootstrap commands fail closed on every host except native Windows. ## Runtime classification @@ -32,14 +32,56 @@ Environment variables alone never promote an ordinary Linux kernel to WSL2. Custom kernels that remove the Microsoft WSL2 identity markers fail closed; Docker Desktop also documents custom WSL kernels as unsupported. +## Native layout and state identity + +The WSL frontend uses fixed, distribution-local locations. `XDG_*`, `PATH` and +project configuration cannot redirect the binary, registry, lockfile or state +root: + +| Purpose | Location | +|---|---| +| managed binary | `~/.local/lib/container-bin/cb` | +| management shim | `~/.local/bin/cb` | +| tool shim directory | `~/.local/bin` | +| user registry | `~/.config/container-bin/container-bin.toml` | +| lockfile | `~/.config/container-bin/container-bin.lock` | +| private state | `~/.local/state/container-bin` | + +The home directory must be a canonical absolute Linux path in the distribution +filesystem. A home under `/mnt` is rejected rather than placing trust or state +files on a Windows filesystem. The machine policy location remains the separate +administrator-owned `/etc/container-bin/policy.toml` contract. + +Later filesystem wiring must create config and state directories as private, +current-user-owned directories; create registry and lock files with mode `0600`; +install the managed binary with mode `0755`; and reject an existing shim +directory that is group- or world-writable. It must never repair permissions on +an unrelated shared directory by guessing ownership intent. Tool shims are +native Linux symlinks to the managed binary, and collisions with unrelated +files or links fail closed. + +Every ContainerBin-managed Docker object in WSL is scoped to one exact tuple: + +- the case-sensitive `WSL_DISTRO_NAME` value; +- the canonical lowercase `/etc/machine-id` value; and +- the numeric Linux user ID. + +ContainerBin derives an opaque, versioned namespace from that tuple. It does +not lowercase, Unicode-normalize or expose the raw identity: two spellings +produce separate namespaces instead of being guessed equivalent. Volume +creation must prefix and label every managed object with the namespace, and +state listing, garbage collection, backup and restore must filter on the exact +namespace. Reinstalling a distribution, changing users or selecting another +distribution therefore cannot silently adopt existing state. + ## Required before WSL execution can be enabled Later reviewable slices must still implement and qualify all of the following: -1. native config, lock, binary and symlink locations with Linux ownership and - permission checks; -2. distribution-scoped shared/project volume identities, with no implicit - equivalence to Windows paths or state; +1. Linux ownership, permission and symlink enforcement for the accepted native + layout; +2. wiring the accepted distribution/machine/user namespace into shared and + project volume creation and lifecycle commands; 3. native Linux path, symlink, case, stdin/TTY and signal semantics; 4. Docker Desktop WSL-integration detection without accepting a separate local Docker Engine by accident; diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 01aa08e..370d446 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -70,7 +70,7 @@ func requireFrontend(info Runtime, probeErr error) error { } return fmt.Errorf("Windows ContainerBin process inherited WSL interoperability marker(s): %s; this invocation is unsupported; run cb from a native Windows process, or use the native WSL frontend after it is released", markers) case WSL2Native: - if info.Distro == "" { + if strings.TrimSpace(info.Distro) == "" { return errors.New("native WSL2 was detected but WSL_DISTRO_NAME is unavailable, so distribution identity cannot be proven") } return fmt.Errorf("native WSL2 distribution %q was detected, but the WSL frontend is not enabled in this release", info.Distro) @@ -89,15 +89,14 @@ func classify(goos, kernelRelease, distro, interop string) Runtime { info := Runtime{ GOOS: strings.TrimSpace(goos), KernelRelease: strings.TrimSpace(kernelRelease), - Distro: strings.TrimSpace(distro), + Distro: distro, } - interop = strings.TrimSpace(interop) switch info.GOOS { case "windows": if interop != "" { info.InteropMarkers = append(info.InteropMarkers, "WSL_INTEROP") } - if info.Distro != "" { + if distro != "" { info.InteropMarkers = append(info.InteropMarkers, "WSL_DISTRO_NAME") } if len(info.InteropMarkers) != 0 { diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go index afa84a8..79023d1 100644 --- a/internal/hostenv/hostenv_test.go +++ b/internal/hostenv/hostenv_test.go @@ -15,6 +15,7 @@ func TestClassify(t *testing.T) { }{ {name: "windows", goos: "windows", want: WindowsNative}, {name: "windows distro marker", goos: "windows", distro: "Ubuntu", want: WindowsWSLInterop}, + {name: "windows whitespace distro marker", goos: "windows", distro: " ", want: WindowsWSLInterop}, {name: "windows interop marker", goos: "windows", interop: "/run/WSL/1_interop", want: WindowsWSLInterop}, {name: "wsl2", goos: "linux", kernel: "6.6.87.2-microsoft-standard-WSL2", distro: "Ubuntu", want: WSL2Native}, {name: "wsl2 case insensitive", goos: "linux", kernel: "5.15.167.4-MICROSOFT-standard-wsl2", want: WSL2Native}, @@ -47,6 +48,7 @@ func TestRequireFrontend(t *testing.T) { {name: "windows native", info: Runtime{Kind: WindowsNative}}, {name: "windows interop", info: Runtime{Kind: WindowsWSLInterop, InteropMarkers: []string{"WSL_INTEROP"}}, want: "WSL_INTEROP"}, {name: "wsl2 missing distro", info: Runtime{Kind: WSL2Native}, want: "distribution identity cannot be proven"}, + {name: "wsl2 whitespace distro", info: Runtime{Kind: WSL2Native, Distro: " \t"}, want: "distribution identity cannot be proven"}, {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "WSL frontend is not enabled"}, {name: "wsl1", info: Runtime{Kind: WSL1Native}, want: "WSL1 is unsupported"}, {name: "unrecognized Microsoft kernel", info: Runtime{Kind: WSLUnrecognized, KernelRelease: "4.19.128-microsoft-standard"}, want: "generation cannot be proven"}, diff --git a/internal/hostenv/wsl_layout.go b/internal/hostenv/wsl_layout.go new file mode 100644 index 0000000..5fe388f --- /dev/null +++ b/internal/hostenv/wsl_layout.go @@ -0,0 +1,131 @@ +package hostenv + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +const wslNamespaceDomain = "container-bin/wsl2-state/v1\x00" + +// WSLLayout is the fixed user-local filesystem and Docker-state contract for +// one native WSL2 distribution. It is computed without consulting XDG or PATH +// environment variables so a launcher or project cannot redirect trust state. +// The frontend remains gated until the later filesystem, Docker integration, +// path and end-to-end qualification slices land. +type WSLLayout struct { + Distro string + UID uint32 + Home string + BinaryPath string + ManagementShim string + ShimDir string + ConfigDir string + RegistryPath string + LockPath string + StateDir string + StateNamespace string +} + +// NativeWSLLayout returns the WSL2 layout for a classified runtime. The +// machine ID must be the canonical /etc/machine-id value; combining it with the +// exact WSL distribution name and numeric Linux UID prevents distributions or +// users sharing Docker Desktop's daemon from silently sharing ContainerBin +// volumes. +func (r Runtime) NativeWSLLayout(home string, uid uint32, machineID string) (WSLLayout, error) { + if r.Kind != WSL2Native { + return WSLLayout{}, fmt.Errorf("native WSL layout requires runtime kind %q, got %q", WSL2Native, r.Kind) + } + if err := validateDistroIdentity(r.Distro); err != nil { + return WSLLayout{}, err + } + home, err := validateWSLHome(home) + if err != nil { + return WSLLayout{}, err + } + machineID, err = validateMachineID(machineID) + if err != nil { + return WSLLayout{}, err + } + + configDir := path.Join(home, ".config/container-bin") + stateDir := path.Join(home, ".local/state/container-bin") + shimDir := path.Join(home, ".local/bin") + binaryPath := path.Join(home, ".local/lib/container-bin/cb") + identity := wslNamespaceDomain + r.Distro + "\x00" + machineID + "\x00" + strconv.FormatUint(uint64(uid), 10) + sum := sha256.Sum256([]byte(identity)) + return WSLLayout{ + Distro: r.Distro, + UID: uid, + Home: home, + BinaryPath: binaryPath, + ManagementShim: path.Join(shimDir, "cb"), + ShimDir: shimDir, + ConfigDir: configDir, + RegistryPath: path.Join(configDir, "container-bin.toml"), + LockPath: path.Join(configDir, "container-bin.lock"), + StateDir: stateDir, + StateNamespace: "wsl2-" + hex.EncodeToString(sum[:16]), + }, nil +} + +func validateDistroIdentity(distro string) error { + if distro == "" { + return errors.New("native WSL layout requires a distribution identity") + } + if strings.TrimSpace(distro) != distro || !utf8.ValidString(distro) { + return errors.New("WSL distribution identity is not canonical UTF-8") + } + for _, r := range distro { + if unicode.IsControl(r) { + return errors.New("WSL distribution identity contains a control character") + } + } + return nil +} + +func validateWSLHome(home string) (string, error) { + if home == "" || !utf8.ValidString(home) || strings.ContainsRune(home, '\x00') || strings.ContainsRune(home, '\\') { + return "", errors.New("native WSL home must be a canonical absolute Linux path") + } + for _, r := range home { + if unicode.IsControl(r) { + return "", errors.New("native WSL home contains a control character") + } + } + clean := path.Clean(home) + if !path.IsAbs(home) || clean != home || clean == "/" { + return "", errors.New("native WSL home must be a canonical absolute non-root Linux path") + } + if clean == "/mnt" || strings.HasPrefix(clean, "/mnt/") { + return "", errors.New("native WSL home must be distribution-local, not under /mnt") + } + return clean, nil +} + +func validateMachineID(machineID string) (string, error) { + if len(machineID) != 32 || machineID != strings.ToLower(machineID) { + return "", errors.New("WSL machine ID must be 32 canonical lowercase hexadecimal characters") + } + decoded, err := hex.DecodeString(machineID) + if err != nil { + return "", errors.New("WSL machine ID must be 32 canonical lowercase hexadecimal characters") + } + allZero := true + for _, b := range decoded { + if b != 0 { + allZero = false + break + } + } + if allZero { + return "", errors.New("WSL machine ID must not be all zeroes") + } + return machineID, nil +} diff --git a/internal/hostenv/wsl_layout_test.go b/internal/hostenv/wsl_layout_test.go new file mode 100644 index 0000000..c79db13 --- /dev/null +++ b/internal/hostenv/wsl_layout_test.go @@ -0,0 +1,110 @@ +package hostenv + +import ( + "strings" + "testing" +) + +const testMachineID = "0123456789abcdef0123456789abcdef" + +func TestNativeWSLLayoutIsFixedAndDistributionScoped(t *testing.T) { + runtime := Runtime{Kind: WSL2Native, Distro: "Ubuntu-24.04"} + layout, err := runtime.NativeWSLLayout("/home/alice", 1000, testMachineID) + if err != nil { + t.Fatal(err) + } + want := 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: "wsl2-98d6411d95851ca46df32643077388c2", + } + if layout != want { + t.Fatalf("NativeWSLLayout =\n%+v\nwant\n%+v", layout, want) + } + if strings.Contains(layout.StateNamespace, runtime.Distro) || strings.Contains(layout.StateNamespace, testMachineID) { + t.Fatalf("state namespace exposes raw identity: %q", layout.StateNamespace) + } + + again, err := runtime.NativeWSLLayout("/home/alice", 1000, testMachineID) + if err != nil || again.StateNamespace != layout.StateNamespace { + t.Fatalf("state namespace is not stable: (%q, %v)", again.StateNamespace, err) + } + otherDistro, err := (Runtime{Kind: WSL2Native, Distro: "Debian"}).NativeWSLLayout("/home/alice", 1000, testMachineID) + if err != nil { + t.Fatal(err) + } + otherMachine, err := runtime.NativeWSLLayout("/home/alice", 1000, "fedcba9876543210fedcba9876543210") + if err != nil { + t.Fatal(err) + } + otherUser, err := runtime.NativeWSLLayout("/home/alice", 1001, testMachineID) + if err != nil { + t.Fatal(err) + } + caseVariant, err := (Runtime{Kind: WSL2Native, Distro: "ubuntu-24.04"}).NativeWSLLayout("/home/alice", 1000, testMachineID) + if err != nil { + t.Fatal(err) + } + for name, namespace := range map[string]string{ + "distro": otherDistro.StateNamespace, + "machine": otherMachine.StateNamespace, + "user": otherUser.StateNamespace, + "distro casing": caseVariant.StateNamespace, + } { + if namespace == layout.StateNamespace { + t.Errorf("%s identity collapsed to %q", name, namespace) + } + } +} + +func TestNativeWSLLayoutRejectsAmbiguousInputs(t *testing.T) { + cases := []struct { + name string + runtime Runtime + home string + machineID string + want string + }{ + {name: "wrong runtime", runtime: Runtime{Kind: WindowsNative}, home: "/home/alice", machineID: testMachineID, want: "requires runtime kind"}, + {name: "missing distro", runtime: Runtime{Kind: WSL2Native}, home: "/home/alice", machineID: testMachineID, want: "distribution identity"}, + {name: "padded distro", runtime: Runtime{Kind: WSL2Native, Distro: " Ubuntu"}, home: "/home/alice", machineID: testMachineID, want: "canonical UTF-8"}, + {name: "distro control", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu\n"}, home: "/home/alice", machineID: testMachineID, want: "canonical UTF-8"}, + {name: "relative home", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "home/alice", machineID: testMachineID, want: "canonical absolute"}, + {name: "root home", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/", machineID: testMachineID, want: "non-root"}, + {name: "unclean home", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/../alice", machineID: testMachineID, want: "canonical absolute"}, + {name: "trailing slash", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice/", machineID: testMachineID, want: "canonical absolute"}, + {name: "Windows filesystem home", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/mnt/c/Users/alice", machineID: testMachineID, want: "distribution-local"}, + {name: "backslash home", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice\\x", machineID: testMachineID, want: "canonical absolute"}, + {name: "short machine ID", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice", machineID: "abcd", want: "32 canonical"}, + {name: "uppercase machine ID", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice", machineID: strings.ToUpper(testMachineID), want: "32 canonical"}, + {name: "non-hex machine ID", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice", machineID: strings.Repeat("g", 32), want: "32 canonical"}, + {name: "zero machine ID", runtime: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, home: "/home/alice", machineID: strings.Repeat("0", 32), want: "all zeroes"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := tc.runtime.NativeWSLLayout(tc.home, 1000, tc.machineID) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("NativeWSLLayout error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestClassifiedPaddedDistroDoesNotCollapseStateIdentity(t *testing.T) { + runtime := classify("linux", "6.6.87.2-microsoft-standard-WSL2", " Ubuntu-24.04", "") + if runtime.Distro != " Ubuntu-24.04" { + t.Fatalf("classify() distro = %q, want raw identity", runtime.Distro) + } + _, err := runtime.NativeWSLLayout("/home/alice", 1000, testMachineID) + if err == nil || !strings.Contains(err.Error(), "canonical UTF-8") { + t.Fatalf("NativeWSLLayout error = %v, want padded identity rejection", err) + } +}