diff --git a/docs/architecture.md b/docs/architecture.md index 9528d66..ad108ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -294,6 +294,7 @@ 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 host classification and gated WSL layout (leaf) +internal/wslfs native WSL filesystem ownership/mode preflight internal/selfupdate canonical release selection and read-only plan (leaf) ``` @@ -312,6 +313,7 @@ lockfile -> atomicio, policy, registry, toml pathmap -> registry registry -> atomicio, toml policy -> toml +wslfs -> hostenv atomicio, dockervol, hostenv, mutationlock, selfupdate, toml -> (leaves) ``` @@ -338,6 +340,14 @@ release workflow inject it with `-ldflags "-X main.version=..."`, so that symbol path is part of the release contract. Packages that need it take it as a parameter. +`internal/wslfs` is an unexposed Linux-only preflight over the fixed layout +derived by `internal/hostenv`. It accepts only a real current-user-owned home on +the distribution root filesystem device, creates missing ContainerBin layout +directories without repairing existing objects, and validates strict modes for +managed registry, lock, binary and management-shim endpoints. The non-Linux +build-tagged implementation always rejects the operation. Frontend wiring, +registry-derived tool shims and Docker integration remain later WSL slices. + After the host runtime boundary is enforced, `cb self-update --check` is dispatched before machine policy and registry loading. Release selection therefore remains available when either local configuration source is missing diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 25ae242..344358f 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -293,47 +293,37 @@ exception, expiry and outage policy. This is priority/order guidance, not permission to merge. Merged foundations are removed from the remaining queue: RM-26 shipped in PR -#74, enterprise-policy foundation in PR #75, RM-31 selection/check in PR #76, -the WSL host boundary in PR #77, native Windows ARM64 CI in PR #78, and -reproducible ARM64 release packaging in PR #86. Unmerged pull-request coverage -is not completion. - -1. **Per-project overlay trust foundation** - - project overlay parsing independent of global registry; - - add-only collision rules; - - external trust store bound to canonical root + overlay digest; - - `cb trust` / `cb untrust` / inspect/doctor; - - initial restricted capability set. - -2. **Registry-signature enterprise policy** - - detached Ed25519 signature envelope over exact registry bytes; - - trusted-key rotation/revocation policy; - - verify before parsing/acting on registry content. - -3. **Image trust** +#74, enterprise policy and signed registries in PRs #75 and #84, per-project +overlay trust in PR #80, RM-31 selection/staging/verification in PRs #76, #81 +and #82, the WSL host boundary and native layout identity in PRs #77 and #83, +native Windows ARM64 CI in PR #78, and reproducible ARM64 release packaging in +PR #86. Unmerged pull-request coverage is not completion. + +1. **Image trust** - cosign verifier configuration and verifier hash validation; - per-repository trust policy; - lock schema/evidence migration; - online/offline verification and stale-evidence behavior. -4. **Remaining RM-31 self-update** - - selection/check/dry-run API is merged in PR #76; - - bounded canonical GitHub release download/staging; - - `gh attestation verify` policy integration; +2. **Remaining RM-31 self-update** + - selection/check, bounded staging and `gh attestation verify` are merged in + PRs #76, #81 and #82; - Windows helper transaction, managed-shim reconciliation and rollback; - release/self-test E2E. -5. **Remaining WSL2** - - narrow reusable Linux host interfaces and fail-closed boundary are merged in PR #77; - - native WSL config/shim/state layout; +3. **Remaining WSL2** + - narrow reusable Linux host interfaces, fail-closed boundary and native + layout/state identity are merged in PRs #77 and #83; + - Linux ownership, permission and symlink preflight is implemented but not + yet wired into an enabled frontend; - Docker Desktop WSL integration; - project identity and cross-boundary rejection tests; - real WSL Docker E2E. -6. **RM-30 Authenticode** +4. **RM-30 Authenticode** - only after certificate/protected signing prerequisites exist. -7. **RM-29 Windows ARM64** +5. **RM-29 Windows ARM64** - **lowest priority**; - native hosted ARM64 CI is merged in PR #78; - architecture-specific release packaging is merged in PR #86; diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 87831d6..d66658c 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -15,8 +15,9 @@ decision. Status snapshot: **2026-09-25**. The earlier 2026-09-17 snapshot counted every unchecked roadmap line as unfinished work; that is no longer an accurate model. -RM-26 pipx, the enterprise-policy foundation, the RM-31 selection/check slice, -the WSL host boundary, native Windows ARM64 CI, and reproducible ARM64 release +RM-26 pipx, per-project overlay trust, signed-registry policy, the RM-31 +selection/check/staging/verification slices, the WSL host boundary and native +layout identity, native Windows ARM64 CI, and reproducible ARM64 release packaging have since shipped. The maintainer has also explicitly accepted product/security dispositions for the remaining design gates. Use the readiness table below plus @@ -72,14 +73,14 @@ The minimum delivery gate for a code change is: | RM-26 Python global CLI exposure | **Completed in PR #74** | Stateful pipx + `cb expose pipx` shipped; plain pip `/venv/bin` remains intentionally unexposed | | RM-29 Windows ARM64 | **Native CI and release packaging shipped / update and hardware work remain** | PR #78 added native hosted ARM64 CI and PR #86 added reproducible release packaging; ARM64 self-update selection and real Windows-on-Arm + Docker Desktop E2E remain | | RM-30 Authenticode | **Design complete / externally blocked** | Provision real code-signing certificate and protected signing mechanism | -| RM-31 self-update | **Selection/check foundation shipped** | PR #76 shipped selection/check behavior; staging, verification, transactional apply and E2E remain | +| RM-31 self-update | **Selection, staging and verification shipped** | PRs #76, #81 and #82 shipped the read-only plan, fail-closed staging and provenance verification; transactional apply and E2E remain | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all/explicit selection are sufficient; reopen only for concrete unmet use case | | Linux/macOS hosts | **Demand-gated** | WSL may factor reusable Linux host code; standalone support needs its own demand and qualification | -| Enterprise policy | **Foundation shipped / signed registry remains** | PR #75 shipped the machine-owned constraint layer; authenticated registry and image-trust slices remain | +| 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 | **Design complete / sequenced** | Implement after signed-registry policy using policy-driven Sigstore/cosign verification | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Host boundary shipped / implementation remaining** | PR #77 shipped the fail-closed host boundary; native layout, Docker Desktop integration and real WSL qualification remain | -| Per-project overlays | **Design complete / implementation-ready** | Implement add-only digest-bound trust model on the merged enterprise-policy foundation | +| WSL2 | **Host boundary and layout identity shipped / implementation remaining** | PRs #77 and #83 shipped the fail-closed host boundary and fixed native layout/state identity; filesystem preparation is implemented but unexposed, while frontend wiring, Docker Desktop integration and real WSL qualification remain | +| 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 | | Issue #69 | **Completed** | Superseded by merged implementation; no remaining roadmap dependency | diff --git a/docs/wsl.md b/docs/wsl.md index db24299..7c7c6c9 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -5,10 +5,11 @@ 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. -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. +The implemented foundation establishes the runtime boundary, fixed native-WSL +layout contract and an unexposed filesystem-preparation step. It does not +publish a Linux artifact or enable WSL execution yet. Until the remaining +frontend wiring, Docker and qualification slices land, non-bootstrap commands +fail closed on every host except native Windows. ## Runtime classification @@ -47,18 +48,33 @@ root: | 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 +The home directory must be a canonical absolute Linux path on the same +filesystem device as the distribution root. A lexical `/mnt` check rejects the +default Windows-drive layout early; filesystem preparation then rejects a +symlinked home, a +[custom DrvFs automount root](https://learn.microsoft.com/windows/wsl/wsl-config#automount-settings), +a bind-mounted Windows home, and any other separate filesystem rather than +guessing its trust semantics. This is deliberately narrower than accepting +every possible Linux `/home` mount: support for a separate native filesystem +needs its own filesystem-type and ownership qualification. 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. +The filesystem checks are a point-in-time preflight, not a durable path handle. +The later wiring slice must revalidate managed paths at each mutation boundary +or use descriptor-relative, no-follow traversal so a path swap after preflight +cannot redirect a registry, lockfile, binary, or shim operation. + +The unexposed `internal/wslfs` preparation step creates only missing fixed +layout directories. Config, state and managed-binary directories must be +private and current-user-owned; existing registry and lock files must be +regular non-symlink files with mode `0600`; and an existing managed binary must +be a regular non-symlink file with mode `0755`. The shim directory must be +current-user-owned, owner-accessible and not group- or world-writable. Existing +permissions and ownership are never repaired by guessing intent. The management +shim, when present, must be a current-user-owned symlink to the fixed managed +binary; unrelated files or links fail closed. Tool-shim enumeration remains a +later registry/install wiring concern. Every ContainerBin-managed Docker object in WSL is scoped to one exact tuple: @@ -78,8 +94,9 @@ distribution therefore cannot silently adopt existing state. Later reviewable slices must still implement and qualify all of the following: -1. Linux ownership, permission and symlink enforcement for the accepted native - layout; +1. wire the implemented Linux ownership, permission and symlink preflight into + the native installer/config lifecycle and extend it to registry-derived tool + shims; 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; diff --git a/internal/wslfs/wslfs_linux.go b/internal/wslfs/wslfs_linux.go new file mode 100644 index 0000000..e7b5b2a --- /dev/null +++ b/internal/wslfs/wslfs_linux.go @@ -0,0 +1,313 @@ +//go:build linux + +// Package wslfs enforces the native WSL filesystem layout before the frontend +// is enabled. It deliberately does not infer or repair ownership. +package wslfs + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +const ( + privateDirMode = 0o700 + shimDirMode = 0o755 + privateFileMode = 0o600 + binaryFileMode = 0o755 +) + +// Prepare validates the fixed native-WSL layout and creates only missing +// ContainerBin-owned directories. Existing paths are never chmodded, chowned or +// replaced: ambiguous ownership, symlinks and unsafe permissions fail closed. +// The WSL frontend remains gated; later registry/install wiring will call this +// before creating files or shims. +func Prepare(layout hostenv.WSLLayout) error { + runtime, err := hostenv.Current() + if err != nil { + return fmt.Errorf("classify native WSL runtime: %w", err) + } + machineID, err := os.ReadFile("/etc/machine-id") + if err != nil { + return fmt.Errorf("read native WSL machine identity: %w", err) + } + return prepare(layout, runtime, strings.TrimSpace(string(machineID))) +} + +func prepare(layout hostenv.WSLLayout, runtime hostenv.Runtime, machineID string) (err error) { + if err := validateLayout(layout); err != nil { + return err + } + uid := uint32(os.Getuid()) + if layout.UID != uid { + return fmt.Errorf("native WSL layout UID %d does not match current user UID %d", layout.UID, uid) + } + expected, err := runtime.NativeWSLLayout(layout.Home, layout.UID, machineID) + if err != nil { + return fmt.Errorf("validate native WSL state identity: %w", err) + } + if layout.Distro != expected.Distro || layout.StateNamespace != expected.StateNamespace { + return fmt.Errorf("native WSL state namespace %q does not match current distribution, machine and user identity", layout.StateNamespace) + } + + homeInfo, err := inspectDirectory(layout.Home, uid, false, nil) + if err != nil { + return fmt.Errorf("validate native WSL home: %w", err) + } + resolvedHome, err := filepath.EvalSymlinks(layout.Home) + if err != nil { + return fmt.Errorf("resolve native WSL home: %w", err) + } + if resolvedHome != layout.Home { + return fmt.Errorf("native WSL home %q resolves to %q; symlinked homes are not accepted", layout.Home, resolvedHome) + } + rootInfo, err := os.Stat(string(filepath.Separator)) + if err != nil { + return fmt.Errorf("inspect distribution root filesystem: %w", err) + } + homeDevice, err := filesystemDevice(homeInfo) + if err != nil { + return fmt.Errorf("inspect native WSL home filesystem: %w", err) + } + rootDevice, err := filesystemDevice(rootInfo) + if err != nil { + return fmt.Errorf("inspect distribution root filesystem: %w", err) + } + if homeDevice != rootDevice { + return fmt.Errorf("native WSL home %q is on filesystem device %d, not distribution root device %d", layout.Home, homeDevice, rootDevice) + } + + created := make([]string, 0, 8) + defer func() { + if err == nil { + return + } + for i := len(created) - 1; i >= 0; i-- { + if removeErr := os.Remove(created[i]); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + err = errors.Join(err, fmt.Errorf("remove newly created directory %s after failure: %w", created[i], removeErr)) + } + } + }() + + directories := []struct { + path string + createMode os.FileMode + private bool + }{ + {filepath.Join(layout.Home, ".config"), privateDirMode, false}, + {layout.ConfigDir, privateDirMode, true}, + {filepath.Join(layout.Home, ".local"), privateDirMode, false}, + {filepath.Join(layout.Home, ".local", "state"), privateDirMode, false}, + {layout.StateDir, privateDirMode, true}, + {filepath.Join(layout.Home, ".local", "lib"), privateDirMode, false}, + {filepath.Dir(layout.BinaryPath), privateDirMode, true}, + {layout.ShimDir, shimDirMode, false}, + } + for _, directory := range directories { + made, makeErr := ensureDirectory(directory.path, uid, rootDevice, directory.createMode, directory.private) + if made { + created = append(created, directory.path) + } + if makeErr != nil { + return makeErr + } + } + + for _, file := range []struct { + path string + mode os.FileMode + name string + }{ + {layout.RegistryPath, privateFileMode, "registry"}, + {layout.LockPath, privateFileMode, "lockfile"}, + {layout.BinaryPath, binaryFileMode, "managed binary"}, + } { + if err := validateManagedFile(file.path, uid, rootDevice, file.mode, file.name); err != nil { + return err + } + } + if err := validateManagementShim(layout, uid, rootDevice); err != nil { + return err + } + return nil +} + +func validateLayout(layout hostenv.WSLLayout) error { + home := layout.Home + if home == "" || !filepath.IsAbs(home) || filepath.Clean(home) != home || home == string(filepath.Separator) || strings.ContainsRune(home, '\\') { + return errors.New("native WSL filesystem layout has an invalid home path") + } + expected := map[string]string{ + "managed binary": layout.BinaryPath, + "management shim": layout.ManagementShim, + "shim directory": layout.ShimDir, + "config directory": layout.ConfigDir, + "registry": layout.RegistryPath, + "lockfile": layout.LockPath, + "state directory": layout.StateDir, + } + want := map[string]string{ + "managed binary": filepath.Join(home, ".local", "lib", "container-bin", "cb"), + "management shim": filepath.Join(home, ".local", "bin", "cb"), + "shim directory": filepath.Join(home, ".local", "bin"), + "config directory": filepath.Join(home, ".config", "container-bin"), + "registry": filepath.Join(home, ".config", "container-bin", "container-bin.toml"), + "lockfile": filepath.Join(home, ".config", "container-bin", "container-bin.lock"), + "state directory": filepath.Join(home, ".local", "state", "container-bin"), + } + for name, value := range expected { + if value != want[name] { + return fmt.Errorf("native WSL %s path %q does not match fixed layout %q", name, value, want[name]) + } + } + if layout.Distro == "" || layout.StateNamespace == "" { + return errors.New("native WSL filesystem layout is missing its state identity") + } + return nil +} + +func ensureDirectory(path string, uid uint32, rootDevice uint64, createMode os.FileMode, private bool) (bool, error) { + created := false + if err := os.Mkdir(path, createMode); err == nil { + created = true + if err := os.Chmod(path, createMode); err != nil { + return true, fmt.Errorf("set native WSL directory mode on %s: %w", path, err) + } + } else if !errors.Is(err, os.ErrExist) { + return false, fmt.Errorf("create native WSL directory %s: %w", path, err) + } + if _, err := inspectDirectory(path, uid, private, &rootDevice); err != nil { + return created, fmt.Errorf("validate native WSL directory %s: %w", path, err) + } + return created, nil +} + +func inspectDirectory(path string, uid uint32, private bool, rootDevice *uint64) (os.FileInfo, error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return nil, errors.New("must be a real directory, not a symlink or other object") + } + if err := requireOwner(info, uid); err != nil { + return nil, err + } + if rootDevice != nil { + if err := requireDevice(info, *rootDevice); err != nil { + return nil, err + } + } + perm, err := exactMode(info) + if err != nil { + return nil, err + } + if private { + if perm != privateDirMode { + return nil, fmt.Errorf("must have mode 0700, got %04o", perm) + } + } else if perm&0o700 != 0o700 || perm&0o7022 != 0 { + return nil, fmt.Errorf("must be owner-accessible and not writable by group or other, got %04o", perm) + } + return info, nil +} + +func validateManagedFile(path string, uid uint32, rootDevice uint64, wantMode os.FileMode, name string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect native WSL %s %s: %w", name, path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("native WSL %s %s must be a regular non-symlink file", name, path) + } + if err := requireOwner(info, uid); err != nil { + return fmt.Errorf("native WSL %s %s: %w", name, path, err) + } + if err := requireDevice(info, rootDevice); err != nil { + return fmt.Errorf("native WSL %s %s: %w", name, path, err) + } + mode, err := exactMode(info) + if err != nil { + return fmt.Errorf("native WSL %s %s: %w", name, path, err) + } + if mode != wantMode { + return fmt.Errorf("native WSL %s %s must have mode %04o, got %04o", name, path, wantMode, mode) + } + return nil +} + +func validateManagementShim(layout hostenv.WSLLayout, uid uint32, rootDevice uint64) error { + info, err := os.Lstat(layout.ManagementShim) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect native WSL management shim %s: %w", layout.ManagementShim, err) + } + if info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("native WSL management shim %s collides with a non-symlink object", layout.ManagementShim) + } + if err := requireOwner(info, uid); err != nil { + return fmt.Errorf("native WSL management shim %s: %w", layout.ManagementShim, err) + } + if err := requireDevice(info, rootDevice); err != nil { + return fmt.Errorf("native WSL management shim %s: %w", layout.ManagementShim, err) + } + target, err := os.Readlink(layout.ManagementShim) + if err != nil { + return fmt.Errorf("read native WSL management shim %s: %w", layout.ManagementShim, err) + } + if !filepath.IsAbs(target) { + target = filepath.Join(filepath.Dir(layout.ManagementShim), target) + } + if filepath.Clean(target) != layout.BinaryPath { + return fmt.Errorf("native WSL management shim %s targets %q instead of managed binary %q", layout.ManagementShim, target, layout.BinaryPath) + } + return nil +} + +func requireOwner(info os.FileInfo, uid uint32) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("owner could not be determined") + } + if stat.Uid != uid { + return fmt.Errorf("is owned by UID %d, expected current user UID %d", stat.Uid, uid) + } + return nil +} + +func filesystemDevice(info os.FileInfo) (uint64, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("filesystem device could not be determined") + } + return uint64(stat.Dev), nil +} + +func requireDevice(info os.FileInfo, rootDevice uint64) error { + device, err := filesystemDevice(info) + if err != nil { + return err + } + if device != rootDevice { + return fmt.Errorf("is on filesystem device %d, not distribution root device %d", device, rootDevice) + } + return nil +} + +func exactMode(info os.FileInfo) (os.FileMode, error) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("file mode could not be determined") + } + return os.FileMode(stat.Mode & 0o7777), nil +} diff --git a/internal/wslfs/wslfs_linux_test.go b/internal/wslfs/wslfs_linux_test.go new file mode 100644 index 0000000..8ad731b --- /dev/null +++ b/internal/wslfs/wslfs_linux_test.go @@ -0,0 +1,372 @@ +//go:build linux + +package wslfs + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +const testMachineID = "0123456789abcdef0123456789abcdef" + +func testLayout(t *testing.T) hostenv.WSLLayout { + t.Helper() + home := t.TempDir() + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } + homeInfo, err := os.Stat(home) + if err != nil { + t.Fatal(err) + } + rootInfo, err := os.Stat(string(filepath.Separator)) + if err != nil { + t.Fatal(err) + } + homeDevice, err := filesystemDevice(homeInfo) + if err != nil { + t.Fatal(err) + } + rootDevice, err := filesystemDevice(rootInfo) + if err != nil { + t.Fatal(err) + } + if homeDevice != rootDevice { + t.Skipf("temporary directory device %d differs from distribution root device %d", homeDevice, rootDevice) + } + layout, err := (hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: "Ubuntu-24.04"}).NativeWSLLayout(home, uint32(os.Getuid()), testMachineID) + if err != nil { + t.Fatal(err) + } + return layout +} + +func prepareTest(layout hostenv.WSLLayout) error { + return prepare(layout, hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: layout.Distro}, testMachineID) +} + +func TestPrepareCreatesFixedDirectoriesAndIsIdempotent(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + for path, wantMode := range map[string]os.FileMode{ + layout.ConfigDir: 0o700, + layout.StateDir: 0o700, + filepath.Dir(layout.BinaryPath): 0o700, + layout.ShimDir: 0o755, + } { + info, err := os.Lstat(path) + if err != nil { + t.Fatal(err) + } + if !info.IsDir() || info.Mode().Perm() != wantMode { + t.Errorf("directory %s mode = %v, want %04o", path, info.Mode(), wantMode) + } + } + if err := prepareTest(layout); err != nil { + t.Fatalf("idempotent Prepare() error = %v", err) + } +} + +func TestPrepareCreatesExactModesDespiteRestrictiveUmask(t *testing.T) { + layout := testLayout(t) + oldUmask := syscall.Umask(0o277) + t.Cleanup(func() { syscall.Umask(oldUmask) }) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + for path, wantMode := range map[string]os.FileMode{ + layout.ConfigDir: 0o700, + layout.ShimDir: 0o755, + } { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + mode, err := exactMode(info) + if err != nil { + t.Fatal(err) + } + if mode != wantMode { + t.Errorf("directory %s mode = %04o, want %04o", path, mode, wantMode) + } + } +} + +func TestPrepareValidatesManagedFilesAndManagementShim(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + for path, mode := range map[string]os.FileMode{ + layout.RegistryPath: 0o600, + layout.LockPath: 0o600, + layout.BinaryPath: 0o755, + } { + if err := os.WriteFile(path, []byte("fixture"), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join("..", "lib", "container-bin", "cb"), layout.ManagementShim); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err != nil { + t.Fatalf("Prepare() rejected valid managed endpoints: %v", err) + } + if err := os.Remove(layout.ManagementShim); err != nil { + t.Fatal(err) + } + if err := os.Symlink("unrelated", layout.ManagementShim); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "targets") { + t.Fatalf("wrong-target shim Prepare() error = %v", err) + } +} + +func TestPrepareRejectsUnsafeObjectsWithoutRepairingThem(t *testing.T) { + t.Run("symlinked home", func(t *testing.T) { + parent := t.TempDir() + realHome := filepath.Join(parent, "real-home") + if err := os.Mkdir(realHome, 0o700); err != nil { + t.Fatal(err) + } + linkedHome := filepath.Join(parent, "linked-home") + if err := os.Symlink(realHome, linkedHome); err != nil { + t.Fatal(err) + } + layout, err := (hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: "Ubuntu-24.04"}).NativeWSLLayout(linkedHome, uint32(os.Getuid()), testMachineID) + if err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "not a symlink") { + t.Fatalf("symlinked-home Prepare() error = %v", err) + } + }) + + t.Run("private directory mode", func(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + if err := os.Chmod(layout.ConfigDir, 0o755); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "mode 0700") { + t.Fatalf("insecure config Prepare() error = %v", err) + } + info, err := os.Stat(layout.ConfigDir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Fatalf("Prepare() repaired unrelated permissions: mode=%v", info.Mode()) + } + }) + + t.Run("private directory special mode", func(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + if err := os.Chmod(layout.ConfigDir, os.ModeSticky|0o700); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "mode 0700") { + t.Fatalf("special-mode config Prepare() error = %v", err) + } + }) + + t.Run("writable shim directory", func(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + if err := os.Chmod(layout.ShimDir, 0o777); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "writable by group or other") { + t.Fatalf("writable shim directory Prepare() error = %v", err) + } + info, err := os.Stat(layout.ShimDir) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o777 { + t.Fatalf("Prepare() repaired shim permissions: mode=%v", info.Mode()) + } + }) + + t.Run("symlinked config directory", func(t *testing.T) { + layout := testLayout(t) + if err := os.Mkdir(filepath.Join(layout.Home, ".config"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), layout.ConfigDir); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "not a symlink") { + t.Fatalf("symlinked config Prepare() error = %v", err) + } + }) + + t.Run("wrong current user", func(t *testing.T) { + layout := testLayout(t) + layout.UID++ + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "does not match current user") { + t.Fatalf("wrong-UID Prepare() error = %v", err) + } + if _, err := os.Stat(layout.ConfigDir); !os.IsNotExist(err) { + t.Fatalf("wrong-UID Prepare() mutated layout: %v", err) + } + }) +} + +func TestPrepareRejectsHomeOnDifferentFilesystem(t *testing.T) { + const sharedMemoryRoot = "/dev/shm" + if _, err := os.Stat(sharedMemoryRoot); err != nil { + t.Skipf("shared-memory filesystem unavailable: %v", err) + } + home, err := os.MkdirTemp(sharedMemoryRoot, "container-bin-wslfs-") + if err != nil { + t.Skipf("cannot create cross-filesystem fixture: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(home) }) + if err := os.Chmod(home, 0o700); err != nil { + t.Fatal(err) + } + + homeInfo, err := os.Stat(home) + if err != nil { + t.Fatal(err) + } + rootInfo, err := os.Stat(string(filepath.Separator)) + if err != nil { + t.Fatal(err) + } + homeDevice, err := filesystemDevice(homeInfo) + if err != nil { + t.Fatal(err) + } + rootDevice, err := filesystemDevice(rootInfo) + if err != nil { + t.Fatal(err) + } + if homeDevice == rootDevice { + t.Skip("fixture shares the distribution root filesystem device") + } + + layout, err := (hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: "Ubuntu-24.04"}).NativeWSLLayout(home, uint32(os.Getuid()), testMachineID) + if err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "not distribution root device") { + t.Fatalf("cross-filesystem Prepare() error = %v", err) + } +} + +func TestInspectDirectoryRejectsNestedDifferentFilesystem(t *testing.T) { + const sharedMemoryRoot = "/dev/shm" + if _, err := os.Stat(sharedMemoryRoot); err != nil { + t.Skipf("shared-memory filesystem unavailable: %v", err) + } + path, err := os.MkdirTemp(sharedMemoryRoot, "container-bin-wslfs-nested-") + if err != nil { + t.Skipf("cannot create cross-filesystem directory fixture: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(path) }) + if err := os.Chmod(path, 0o700); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + rootInfo, err := os.Stat(string(filepath.Separator)) + if err != nil { + t.Fatal(err) + } + rootDevice, err := filesystemDevice(rootInfo) + if err != nil { + t.Fatal(err) + } + device, err := filesystemDevice(info) + if err != nil { + t.Fatal(err) + } + if device == rootDevice { + t.Skip("fixture shares the distribution root filesystem device") + } + if _, err := inspectDirectory(path, uint32(os.Getuid()), false, &rootDevice); err == nil || !strings.Contains(err.Error(), "not distribution root device") { + t.Fatalf("nested cross-filesystem directory error = %v", err) + } +} + +func TestPrepareCleansOnlyDirectoriesCreatedByFailedAttempt(t *testing.T) { + layout := testLayout(t) + for _, path := range []string{filepath.Join(layout.Home, ".local"), layout.ShimDir} { + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(layout.ManagementShim, []byte("unrelated"), 0o755); err != nil { + t.Fatal(err) + } + + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("collision Prepare() error = %v", err) + } + for _, path := range []string{layout.ConfigDir, layout.StateDir, filepath.Dir(layout.BinaryPath)} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("failed Prepare() left newly created directory %s: %v", path, err) + } + } + if _, err := os.Stat(layout.ManagementShim); err != nil { + t.Fatalf("failed Prepare() removed pre-existing collision: %v", err) + } +} + +func TestPrepareRejectsForeignStateNamespaceBeforeMutation(t *testing.T) { + layout := testLayout(t) + layout.StateNamespace = "wsl2-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "does not match current distribution") { + t.Fatalf("foreign namespace Prepare() error = %v", err) + } + if _, err := os.Stat(layout.ConfigDir); !os.IsNotExist(err) { + t.Fatalf("foreign namespace Prepare() mutated layout: %v", err) + } +} + +func TestPrepareRejectsExistingManagedFileModeAndCollision(t *testing.T) { + layout := testLayout(t) + if err := prepareTest(layout); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.RegistryPath, []byte("fixture"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(layout.RegistryPath, 0o644); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "mode 0600") { + t.Fatalf("insecure registry Prepare() error = %v", err) + } + if err := os.Remove(layout.RegistryPath); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.ManagementShim, []byte("unrelated"), 0o755); err != nil { + t.Fatal(err) + } + if err := prepareTest(layout); err == nil || !strings.Contains(err.Error(), "collides") { + t.Fatalf("management-shim collision Prepare() error = %v", err) + } +} diff --git a/internal/wslfs/wslfs_other.go b/internal/wslfs/wslfs_other.go new file mode 100644 index 0000000..41c1b76 --- /dev/null +++ b/internal/wslfs/wslfs_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package wslfs + +import ( + "errors" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" +) + +// Prepare is unavailable outside native Linux because Unix ownership and mode +// checks are part of the WSL trust boundary. +func Prepare(hostenv.WSLLayout) error { + return errors.New("native WSL filesystem preparation requires Linux") +}