Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down
14 changes: 14 additions & 0 deletions docs/roadmap-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 50 additions & 8 deletions docs/wsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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;
Expand Down
7 changes: 3 additions & 4 deletions internal/hostenv/hostenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/hostenv/hostenv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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"},
Expand Down
131 changes: 131 additions & 0 deletions internal/hostenv/wsl_layout.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
AviBackToBlack marked this conversation as resolved.
}
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
}
110 changes: 110 additions & 0 deletions internal/hostenv/wsl_layout_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading