From 3fb7bb6e53a5f41c99a839594e101dc32007f582 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 19 Sep 2026 02:27:44 +0100 Subject: [PATCH 01/10] feat: add managed pipx application profile --- README.md | 69 ++++++++++++++++++++++++++---- docs/architecture.md | 4 +- docs/release-matrix.md | 5 +++ docs/security-model.md | 18 ++++++-- internal/cli/cli.go | 4 +- internal/cli/cli_test.go | 63 +++++++++++++++++++++++++++ internal/registry/file_test.go | 4 +- internal/registry/registry.go | 55 +++++++++++++++++++++++- internal/registry/registry_test.go | 68 +++++++++++++++++++++++++---- 9 files changed, 263 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9550c6d..7314505 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ **Run CLI tools on Windows through Docker-backed executable shims — without installing the runtimes on the host.** -ContainerBin makes commands such as `python`, `pip`, `node`, `npm`, `npx`, +ContainerBin makes commands such as `python`, `pip`, `pipx`, `node`, `npm`, `npx`, `uv`, `uvx`, `go`, `cargo`, `rustc`, `dotnet`, `ruby`, `gem`, `bundle`, `jq`, `yq`, `terraform` and `ffmpeg` look like ordinary Windows executables while their real implementations run inside disposable Linux containers on Docker Desktop. Your Windows installation stays clean: no -Python, Node, Go, Rust, uv, .NET SDK or Ruby on the host — just one small Go binary, +Python, pipx, Node, Go, Rust, uv, .NET SDK or Ruby on the host — just one small Go binary, `cb.exe`. ```powershell @@ -125,6 +125,7 @@ cb lock | `rustc` | `rust:1.98.1-slim-bookworm` | stateless | | `cargo` | `rust:1.98.1-slim-bookworm` | stateful (`rust198` state group) | | `uv`, `uvx` | `ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim` | stateful (`uv012-py313` state group) | +| `pipx` | `ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim` | stateful (`pipx117-py313` state group; pinned `pipx==1.17.4`) | | `dotnet` | `mcr.microsoft.com/dotnet/sdk:10.0` | stateful (`dotnet10` state group) | | `ruby`, `gem`, `bundle` | `ruby:4.0-trixie` | stateful (`ruby40` state group) | | `jq` | `ghcr.io/jqlang/jq:latest` | stateless | @@ -405,6 +406,50 @@ Existing installations gain `uv` and `uvx` on `cb install`. An older lockfile does not include their image, so run `cb update uv` (or regenerate the lock with `cb lock`) before first use in locked mode. +## pipx global application state + +`pipx` is the classic-Python global application workflow. It is a separate +stateful profile: installed application environments live under +`/cb/pipx/home`, their executables live under `/cb/pipx/bin`, and the pinned +pipx launcher cache lives under `/cb/pipx/launcher-cache`. These directories +share one managed state volume so their relative links and cached launcher +remain portable together. That volume is not the Python provider's project +`/venv` or pip cache, and it is not shared with uv's tool store. + +The locked uv/Python image launches the exact `pipx==1.17.4` release with +`uvx`. First use therefore needs package-index access to populate the dedicated +launcher cache; later invocations can use that cache offline. The image lock +pins the launcher image, while package-index trust and the pipx package download +remain governed by the profile's narrowly forwarded uv/pip index, TLS and proxy +settings. Automatic Python downloads are disabled and pipx uses the Python 3.13 +interpreter already in the locked image. After a successful pipx command, a +fail-closed wrapper changes pipx-owned absolute links to relative links within +the state volume and copies only the known image interpreter into its launcher +cache and application venvs; this keeps `cb-pipx117-py313-state` portable +through selected-volume backup/restore without relaxing archive link validation. + +```powershell +pipx install cowsay==6.1 +cb expose pipx cowsay # explicit deterministic selection +cowsay "hello from pipx" + +cb expose pipx # expose every other eligible app in the store +cb unexpose cowsay +``` + +The generated application profiles preserve the pipx image, state group, +volumes and environment policy. Exposure discovery reads only the managed bin +directory within the state volume through the selected locked profile; it does +not search a project venv, the host `PATH`, uv's store, or other container +directories. +Plain `pip` remains for project dependencies, so scripts in `/venv/bin` are +deliberately not eligible for global exposure. + +Existing installations gain `pipx` on `cb install`. Because it shares the same +image reference as uv, a lock that already contains that reference can resolve +it; otherwise run `cb update pipx` (or regenerate the lock with `cb lock`) before +first use in locked mode. + ## .NET SDK state `dotnet` uses Microsoft's .NET 10 LTS SDK image. NuGet packages, user-level @@ -487,6 +532,10 @@ uv tool install ruff cb expose uvx ruff ruff --version +pipx install cowsay==6.1 +cb expose pipx cowsay +cowsay "hello from pipx" + dotnet tool install --global dotnet-ef cb expose dotnet dotnet-ef dotnet-ef --version @@ -503,8 +552,9 @@ acme-lint --version `cb expose` takes a stateful source profile with one supported global binary store: the npm prefix (`npm`, `npm22`, ...), Go's shared `/go/bin` (`go`), -Cargo's install root (`cargo`), uv's tool-bin directory (`uv`, `uvx`), .NET's -global tool home (`dotnet`), or the RubyGems home (`ruby`, `gem`, `bundle`). It +Cargo's install root (`cargo`), uv's tool-bin directory (`uv`, `uvx`), pipx's +managed bin directory (`pipx`), .NET's global tool home (`dotnet`), or the +RubyGems home (`ruby`, `gem`, `bundle`). It adds registry profiles that inherit the source image, `state_group`, project-root markers and mode, shared volumes, and environment policy, then creates Windows shims — `cowsay.exe`, `stringer.exe`, `just.exe`, `ruff.exe`, @@ -514,7 +564,8 @@ expose a binary installed under the Node 22 runtime, use `cb expose npm22 `; for `go install` output, use `cb expose go `; for `cargo install`, use `cb expose cargo `; for `uv tool install`, use -`cb expose uvx `; for a global .NET tool, use +`cb expose uvx `; for a pipx application, use +`cb expose pipx `; for a global .NET tool, use `cb expose dotnet `; for a Ruby gem executable, use `cb expose ruby `. @@ -851,10 +902,10 @@ benchmark methodology and the disposable-container tradeoff are in executable, e.g. `$env:GOOS="windows"; go build`. `go test` must remain native to the container because a Windows test binary cannot run inside it. - `cb expose` supports the npm global prefix, Go's shared `/go/bin`, Cargo's - managed install root, uv's pipx-style tool bin, .NET's global tool home, and - RubyGems executables, plus an explicitly named executable beneath any - declared shared volume; direct pip/pipx environments outside uv's managed - tool store are not supported. + managed install root, uv's tool bin, pipx's managed application bin, .NET's + global tool home, and RubyGems executables, plus an explicitly named + executable beneath any declared shared volume; plain pip project environments + and unmanaged pipx stores are not supported. ## Roadmap diff --git a/docs/architecture.md b/docs/architecture.md index 2080610..f650c5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -49,8 +49,8 @@ Three providers own lifecycle semantics: `python -m pip` inside the same environment. - **stateful** — generic declarative provider: `state_group` namespacing, `project_volumes` (scoped per project root), `shared_volumes`. Node/npm/npx, - go/gofmt, cargo, uv/uvx, dotnet, ruby/gem/bundle and everything `cb expose` - creates use this. + go/gofmt, cargo, uv/uvx, pipx, dotnet, ruby/gem/bundle and everything + `cb expose` creates use this. ## Project roots and volume naming diff --git a/docs/release-matrix.md b/docs/release-matrix.md index bf47cc3..fb55aa2 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -105,6 +105,11 @@ mode: - `pip install requests` - `terraform -chdir=.\tf validate` + When qualifying a release that changes managed application exposure, also + exercise the pipx path end to end: install a disposable app, run both + `cb expose pipx APP` and store-wide `cb expose pipx`, invoke the generated + shim, then remove it with `cb unexpose APP`. + `cb self-test` alone does not fully replace this, because it uses its own temp project volumes and already-local locked images rather than a first-run user project. diff --git a/docs/security-model.md b/docs/security-model.md index 6e7074c..6a60265 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -84,10 +84,20 @@ readable, and dangerous to let others edit. project (and any explicitly referenced external paths) read-write, plus the allowlisted environment variables. `cb lock` gives you *reproducibility* — the same digest every time — not *safety* of that digest's contents. -- **Malicious packages.** `pip install`, `npm install -g`, and `cargo install` - execute inside containers, but the packages can read/write the mounted project - and persist in state volumes; an exposed global binary runs whenever you - invoke its shim. +- **Malicious packages.** `pip install`, `pipx install`, `npm install -g`, and + `cargo install` execute inside containers, but the packages can read/write the + mounted project and persist in state volumes; an exposed global binary runs + whenever you invoke its shim. +- **pipx launcher bootstrap.** The pipx profile's image digest is locked, but + its exact-version `pipx==1.17.4` launcher is populated from the configured + Python package index into a dedicated cache on first use. Index overrides, + TLS settings and proxies therefore remain part of that bootstrap's trust + boundary; the image lock does not attest package-index artifacts. +- **Fail-closed pipx link normalization.** After a successful pipx command, the + profile makes pipx-owned absolute links relative within its one state volume + and copies the exact image interpreter only at known venv/cache locations. + Any other absolute link fails the command; archive traversal checks remain + unchanged. - **Secrets you pass through.** `env_prefixes = ["AWS_"]` exists so Terraform can authenticate — which means your AWS credentials enter that container. That is the feature working as designed; scope prefixes deliberately. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3732944..acc14d0 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -304,6 +304,8 @@ func exposeStoreForMountTarget(dst string) (exposeStore, bool) { return exposeStore{kind: "Cargo", mountTarget: dst, binDirectory: dst + "/bin", installHint: "cargo install "}, true case "/cb/uv-bin": return exposeStore{kind: "uv tool", mountTarget: dst, binDirectory: dst, installHint: "uv tool install ", companionTargets: []string{"/cb/uv-tools"}}, true + case "/cb/pipx": + return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install "}, true case "/root/.dotnet": return exposeStore{kind: ".NET tool", mountTarget: dst, binDirectory: dst + "/tools", installHint: "dotnet tool install --global "}, true case "/cb/ruby-gems": @@ -335,7 +337,7 @@ func exposeStoreFor(t registry.Tool) (exposeStore, error) { found = &candidate } if found == nil { - return exposeStore{}, fmt.Errorf("tool %q has no supported global binary store (/cb/npm-global, /go/bin, /cb/cargo-global, /cb/uv-bin, /root/.dotnet or /cb/ruby-gems)", t.Name) + return exposeStore{}, fmt.Errorf("tool %q has no supported global binary store (/cb/npm-global, /go/bin, /cb/cargo-global, /cb/uv-bin, /cb/pipx, /root/.dotnet or /cb/ruby-gems)", t.Name) } for _, target := range found.companionTargets { volumeName, ok := volumesByTarget[target] diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 2e5138f..225357a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -372,6 +372,7 @@ func TestExposeStoreForBuiltins(t *testing.T) { {tool: "cargo", kind: "Cargo", target: "/cb/cargo-global", binDir: "/cb/cargo-global/bin", volumeEnd: "global"}, {tool: "uv", kind: "uv tool", target: "/cb/uv-bin", binDir: "/cb/uv-bin", volumeEnd: "tool-bin", companion: "/cb/uv-tools", compEnd: "tools"}, {tool: "uvx", kind: "uv tool", target: "/cb/uv-bin", binDir: "/cb/uv-bin", volumeEnd: "tool-bin", companion: "/cb/uv-tools", compEnd: "tools"}, + {tool: "pipx", kind: "pipx", target: "/cb/pipx", binDir: "/cb/pipx/bin", volumeEnd: "state"}, {tool: "dotnet", kind: ".NET tool", target: "/root/.dotnet", binDir: "/root/.dotnet/tools", volumeEnd: "dotnet-home"}, {tool: "ruby", kind: "RubyGems", target: "/cb/ruby-gems", binDir: "/cb/ruby-gems/bin", volumeEnd: "gems"}, {tool: "gem", kind: "RubyGems", target: "/cb/ruby-gems", binDir: "/cb/ruby-gems/bin", volumeEnd: "gems"}, @@ -416,6 +417,21 @@ shared_volumes = ["bin:/cb/uv-bin"] } } +func TestExposeStoreRejectsLegacySplitPipxBinVolume(t *testing.T) { + reg, err := registry.ParseTOML(`[tools.demo] +image = "example/demo:1" +provider = "stateful" +state_group = "demo" +shared_volumes = ["bin:/cb/pipx-bin"] +`) + if err != nil { + t.Fatal(err) + } + if _, err := exposeStoreFor(reg.Tools["demo"]); err == nil || !strings.Contains(err.Error(), "no supported global binary store") { + t.Fatalf("legacy split-store error = %v", err) + } +} + func TestExposeStoreCanonicalizesVolumeTargets(t *testing.T) { reg, err := registry.ParseTOML(`[tools.demo] image = "example/demo:1" @@ -460,6 +476,26 @@ func TestUVExposeDiscoveryMountsBinAndToolVolumesReadOnly(t *testing.T) { } } +func TestPipxExposeDiscoveryMountsStateReadOnly(t *testing.T) { + reg := registry.Default() + store, err := exposeStoreFor(reg.Tools["pipx"]) + if err != nil { + t.Fatal(err) + } + args, err := exposeDiscoveryArgs(store, "example/pipx:1", "discover-script") + if err != nil { + t.Fatal(err) + } + want := []string{ + "run", "--rm", "--pull", "never", "--network", "none", "--read-only", + "--mount", "type=volume,src=cb-pipx117-py313-state,dst=/cb/pipx,readonly", + "--entrypoint", "sh", "example/pipx:1", "-c", "discover-script", "cb-expose", "/cb/pipx/bin", + } + if !reflect.DeepEqual(args, want) { + t.Fatalf("discovery args = %#v, want %#v", args, want) + } +} + func TestExposeStoreRejectsAmbiguousProfile(t *testing.T) { reg, err := registry.ParseTOML(`[tools.demo] image = "example/demo:1" @@ -798,6 +834,30 @@ func TestRenderUVToolExposedToolSection(t *testing.T) { } } +func TestRenderPipxExposedToolSection(t *testing.T) { + reg := registry.Default() + source := reg.Tools["pipx"] + const binary = "cowsay" + section := renderExposedToolSection("pipx", source, binary, "/cb/pipx/bin/"+binary) + parsed, err := registry.ParseTOML("schema_version = 1\n" + section) + if err != nil { + t.Fatalf("rendered pipx section invalid: %v", err) + } + got := parsed.Tools[binary] + if got.Image != source.Image || got.StateGroup != source.StateGroup { + t.Fatalf("exposed pipx identity = %#v", got) + } + if !reflect.DeepEqual(got.Command, []string{"/cb/pipx/bin/cowsay"}) { + t.Errorf("command = %v", got.Command) + } + if !reflect.DeepEqual(got.SharedVolumes, source.SharedVolumes) || !reflect.DeepEqual(got.EnvSet, source.EnvSet) || !reflect.DeepEqual(got.EnvNames, source.EnvNames) { + t.Error("exposed pipx profile did not inherit source state/environment") + } + if got.Role != "exposed" { + t.Fatalf("role = %q, want exposed", got.Role) + } +} + func TestRenderDotnetExposedToolSection(t *testing.T) { reg := registry.Default() source := reg.Tools["dotnet"] @@ -860,6 +920,7 @@ func TestManagedExposedToolRecognition(t *testing.T) { {name: "stringer", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/go/bin/Stringer"}, SharedVolumes: []string{"gobin:/go/bin"}}}, {name: "just", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/cargo-global/bin/just"}, SharedVolumes: []string{"global:/cb/cargo-global"}}}, {name: "ruff", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/uv-bin/ruff"}, SharedVolumes: []string{"tools:/cb/uv-tools", "tool-bin:/cb/uv-bin"}}}, + {name: "cowsay", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/pipx/bin/cowsay"}, SharedVolumes: []string{"state:/cb/pipx"}}}, {name: "dotnet-ef", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/root/.dotnet/tools/dotnet-ef"}, SharedVolumes: []string{"dotnet-home:/root/.dotnet"}}}, {name: "rake", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/ruby-gems/bin/rake"}, SharedVolumes: []string{"gems:/cb/ruby-gems"}}}, {name: "acme-lint", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/opt/acme/bin/acme-lint"}, SharedVolumes: []string{"tools:/opt/acme"}}}, @@ -882,6 +943,8 @@ func TestManagedExposedToolRecognition(t *testing.T) { {name: "just", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/cargo-global/bin/just"}, SharedVolumes: []string{"global:/other"}}}, {name: "ruff", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/uv-bin/ruff"}, SharedVolumes: []string{"tool-bin:/cb/uv-bin"}}}, {name: "ruff", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/uv-bin/ruff"}, SharedVolumes: []string{"tool-bin:/other"}}}, + {name: "cowsay", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/pipx/bin/cowsay"}, SharedVolumes: []string{"state:/other"}}}, + {name: "cowsay", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/pipx/cowsay"}, SharedVolumes: []string{"state:/cb/pipx"}}}, {name: "dotnet-ef", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/root/.dotnet/tools/dotnet-ef"}, SharedVolumes: []string{"dotnet-home:/other"}}}, {name: "rake", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/cb/ruby-gems/bin/rake"}, SharedVolumes: []string{"gems:/other"}}}, {name: "acme-lint", tool: registry.Tool{Provider: "stateful", Role: "exposed", Command: []string{"/opt/acme/bin/../acme-lint"}, SharedVolumes: []string{"tools:/opt/acme"}}}, diff --git a/internal/registry/file_test.go b/internal/registry/file_test.go index ec91b6d..3dba167 100644 --- a/internal/registry/file_test.go +++ b/internal/registry/file_test.go @@ -154,7 +154,7 @@ provider = "stateless" if _, ok := reg.Tools["jq2"]; !ok { t.Fatal("custom jq2 was lost") } - for _, name := range []string{"python3", "pip", "pip3", "jq", "yq", "terraform", "ffmpeg", "node24", "npm24", "npx24", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "dotnet", "ruby", "gem", "bundle"} { + for _, name := range []string{"python3", "pip", "pip3", "jq", "yq", "terraform", "ffmpeg", "node24", "npm24", "npx24", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "pipx", "dotnet", "ruby", "gem", "bundle"} { if _, ok := reg.Tools[name]; !ok { t.Fatalf("missing migrated tool %q", name) } @@ -399,7 +399,7 @@ func TestAppendMissingDefaultToolsUpgradesPreRM11(t *testing.T) { if _, ok := reg.Tools["jq2"]; !ok { t.Fatal("custom jq2 was lost") } - for _, name := range []string{"node22", "npm22", "npx22", "rustc", "cargo", "uv", "uvx", "dotnet", "ruby", "gem", "bundle"} { + for _, name := range []string{"node22", "npm22", "npx22", "rustc", "cargo", "uv", "uvx", "pipx", "dotnet", "ruby", "gem", "bundle"} { if _, ok := reg.Tools[name]; !ok { t.Fatalf("missing upgraded tool %q", name) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 1b43da3..7cc15ec 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -55,7 +55,45 @@ type Registry struct { // startup is not subsequently reported as unsupported by cb doctor. const MaxSchemaVersion = 2 -const DefaultTOML = `# container-bin tool registry +const pipxCommand = `uvx --from pipx==1.17.4 pipx "$@" +status=$? +if [ "$status" -eq 0 ]; then + python3 -c ' +import os +import pathlib +import shutil + +root = pathlib.Path("/cb/pipx").resolve() +allowed_python = pathlib.Path("/usr/local/bin/python3.13").resolve(strict=True) +for link in pathlib.Path("/cb/pipx").rglob("*"): + if not link.is_symlink() or not os.path.isabs(os.readlink(link)): + continue + target = link.resolve(strict=True) + try: + target.relative_to(root) + except ValueError: + parts = link.relative_to(root).parts + venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2:] == ("bin", "python") + launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") + if target != allowed_python or not (venv_python or launcher_python): + raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {os.readlink(link)}") + temporary = link.with_name(link.name + ".cb-copy") + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() + shutil.copy2(target, temporary) + os.replace(temporary, link) + continue + relative = os.path.relpath(target, start=link.parent) + temporary = link.with_name(link.name + ".cb-link") + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() + temporary.symlink_to(relative) + os.replace(temporary, link) +' || exit $? +fi +exit "$status"` + +var DefaultTOML = `# container-bin tool registry schema_version = 2 # Generated by cb. Edit this file, then run: cb install # @@ -288,6 +326,21 @@ env_prefixes = ["UV_INDEX_"] env_names = ["UV_INDEX", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_NO_INDEX", "UV_NATIVE_TLS", "UV_OFFLINE", "UV_NO_CACHE", "UV_NO_PROGRESS", "UV_COLOR", "UV_HTTP_TIMEOUT", "UV_HTTP_RETRIES", "UV_CONCURRENT_DOWNLOADS", "UV_CONCURRENT_BUILDS", "UV_CONCURRENT_INSTALLS", "UV_INSECURE_HOST", "UV_KEYRING_PROVIDER", "UV_PRERELEASE", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] path_equals = ["--project", "--directory", "--config-file", "--cache-dir"] +[tools.pipx] +image = "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" +provider = "stateful" +command = ` + toml.Array([]string{"sh", "-c", pipxCommand, "cb-pipx"}) + ` +state_group = "pipx117-py313" +shared_volumes = ["state:/cb/pipx"] +env_set = ["UV_CACHE_DIR=/cb/pipx/launcher-cache", "UV_LINK_MODE=copy", "UV_PYTHON_DOWNLOADS=never", "PIPX_HOME=/cb/pipx/home", "PIPX_BIN_DIR=/cb/pipx/bin", "PIPX_MAN_DIR=/cb/pipx/home/man", "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", "PATH=/cb/pipx/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"] +env_prefixes = ["UV_INDEX_"] +env_names = ["UV_INDEX", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_NO_INDEX", "UV_NATIVE_TLS", "UV_OFFLINE", "UV_NO_PROGRESS", "UV_COLOR", "UV_HTTP_TIMEOUT", "UV_HTTP_RETRIES", "UV_INSECURE_HOST", "UV_KEYRING_PROVIDER", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_NO_INDEX", "PIP_TRUSTED_HOST", "PIP_DISABLE_PIP_VERSION_CHECK", "PIP_TIMEOUT", "PIP_RETRIES", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] +# pipx accepts package specs, URLs and child-command arguments. Do not force +# positional path mapping; explicit Windows path operands use the general mapper. +# The wrapper rewrites only absolute pipx-owned links after successful commands: +# internal links become relative and known image interpreters become copies. +# This keeps the single managed state volume portable through safe backup/restore. + [tools.dotnet] image = "mcr.microsoft.com/dotnet/sdk:10.0" provider = "stateful" diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index b39f410..7d43309 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -26,11 +26,11 @@ func TestParseDefaultRegistry(t *testing.T) { if err != nil { t.Fatal(err) } - if len(reg.Tools) != 24 { - t.Fatalf("expected 24 tools, got %d", len(reg.Tools)) + if len(reg.Tools) != 25 { + t.Fatalf("expected 25 tools, got %d", len(reg.Tools)) } - if len(reg.ToolNames()) != 27 { - t.Fatalf("expected 27 invokable shim names, got %d", len(reg.ToolNames())) + if len(reg.ToolNames()) != 28 { + t.Fatalf("expected 28 invokable shim names, got %d", len(reg.ToolNames())) } jq := reg.Tools["jq"] if jq.Provider != "stateless" || jq.Image != "ghcr.io/jqlang/jq:latest" { @@ -206,7 +206,7 @@ func TestDefaultRegistryHasV06Tools(t *testing.T) { if err != nil { t.Fatal(err) } - for _, name := range []string{"python", "pip", "jq", "yq", "terraform", "ffmpeg", "node24", "npm24", "npx24", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "dotnet", "ruby", "gem", "bundle"} { + for _, name := range []string{"python", "pip", "jq", "yq", "terraform", "ffmpeg", "node24", "npm24", "npx24", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "pipx", "dotnet", "ruby", "gem", "bundle"} { if _, ok := reg.Tools[name]; !ok { t.Fatalf("missing default tool %q", name) } @@ -223,7 +223,7 @@ func TestDefaultRegistryHasV06Tools(t *testing.T) { func TestDefaultToolSections(t *testing.T) { sections := DefaultToolSections() - for _, name := range []string{"python", "yq", "terraform", "ffmpeg", "node24", "node22", "npm24", "npm22", "npx24", "npx22", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "dotnet", "ruby", "gem", "bundle"} { + for _, name := range []string{"python", "yq", "terraform", "ffmpeg", "node24", "node22", "npm24", "npm22", "npx24", "npx22", "go", "gofmt", "rustc", "cargo", "uv", "uvx", "pipx", "dotnet", "ruby", "gem", "bundle"} { if !strings.Contains(sections[name], "[tools."+name+"]") { t.Fatalf("bad section for %s: %q", name, sections[name]) } @@ -674,6 +674,58 @@ func TestUVProfiles(t *testing.T) { } } +func TestPipxProfile(t *testing.T) { + reg, err := ParseTOML(DefaultTOML) + if err != nil { + t.Fatal(err) + } + + pipx := reg.Tools["pipx"] + if pipx.Image != "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" || pipx.Provider != "stateful" || pipx.StateGroup != "pipx117-py313" { + t.Fatalf("bad pipx profile identity: %+v", pipx) + } + if len(pipx.Command) != 4 || pipx.Command[0] != "sh" || pipx.Command[1] != "-c" || pipx.Command[3] != "cb-pipx" || + !strings.Contains(pipx.Command[2], `uvx --from pipx==1.17.4 pipx "$@"`) || + !strings.Contains(pipx.Command[2], `pipx produced unsupported absolute symlink`) { + t.Fatalf("pipx command = %#v", pipx.Command) + } + wantVolumes := []string{"state:/cb/pipx"} + if !reflect.DeepEqual(pipx.SharedVolumes, wantVolumes) { + t.Fatalf("pipx shared_volumes = %#v, want %#v", pipx.SharedVolumes, wantVolumes) + } + for _, entry := range []string{ + "UV_CACHE_DIR=/cb/pipx/launcher-cache", + "UV_LINK_MODE=copy", + "UV_PYTHON_DOWNLOADS=never", + "PIPX_HOME=/cb/pipx/home", + "PIPX_BIN_DIR=/cb/pipx/bin", + "PIPX_MAN_DIR=/cb/pipx/home/man", + "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", + "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", + "PATH=/cb/pipx/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin", + } { + if !containsString(pipx.EnvSet, entry) { + t.Fatalf("pipx env_set missing %q: %#v", entry, pipx.EnvSet) + } + } + if !containsString(pipx.EnvPrefixes, "UV_INDEX_") { + t.Fatalf("pipx env_prefixes missing UV_INDEX_: %#v", pipx.EnvPrefixes) + } + for _, envName := range []string{"UV_INDEX", "UV_DEFAULT_INDEX", "UV_OFFLINE", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_NO_INDEX", "HTTP_PROXY"} { + if !containsString(pipx.EnvNames, envName) { + t.Fatalf("pipx env_names missing %q: %#v", envName, pipx.EnvNames) + } + } + for _, pathOrControlVariable := range []string{"UV_CACHE_DIR", "UV_TOOL_DIR", "UV_TOOL_BIN_DIR", "PIPX_HOME", "PIPX_BIN_DIR", "PIPX_MAN_DIR", "PIPX_COMPLETION_DIR", "PIPX_DEFAULT_PYTHON", "PATH"} { + if containsString(pipx.EnvNames, pathOrControlVariable) { + t.Fatalf("pipx env allowlist must not include controlled variable %q", pathOrControlVariable) + } + } + if len(pipx.ProjectMarkers) != 0 || len(pipx.ProjectVolumes) != 0 || len(pipx.PathNext) != 0 || len(pipx.PathEquals) != 0 || pipx.PathLast { + t.Fatalf("pipx must remain store-scoped without project or forced path policy: %+v", pipx) + } +} + func TestDotnetProfile(t *testing.T) { reg, err := ParseTOML(DefaultTOML) if err != nil { @@ -1159,7 +1211,7 @@ func TestHostMountDefaultTOMLComment(t *testing.T) { if err != nil { t.Fatal(err) } - if len(reg.Tools) != 24 { - t.Fatalf("expected 24 tools, got %d", len(reg.Tools)) + if len(reg.Tools) != 25 { + t.Fatalf("expected 25 tools, got %d", len(reg.Tools)) } } From c0927aa1f778c6fc7f16c34b242545437f463df0 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:57:44 +0100 Subject: [PATCH 02/10] fix: harden pipx state normalization --- README.md | 3 +++ internal/registry/registry.go | 10 +++++++--- internal/registry/registry_test.go | 13 +++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7314505..e39fd36 100644 --- a/README.md +++ b/README.md @@ -427,6 +427,9 @@ fail-closed wrapper changes pipx-owned absolute links to relative links within the state volume and copies only the known image interpreter into its launcher cache and application venvs; this keeps `cb-pipx117-py313-state` portable through selected-volume backup/restore without relaxing archive link validation. +`pipx run` environments remain ephemeral because `PIPX_CACHE_DIR` is not placed +on the managed volume; each `pipx run` may resolve and download its application +again and therefore requires the configured package index to be reachable. ```powershell pipx install cowsay==6.1 diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 7cc15ec..c86782d 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -58,7 +58,7 @@ const MaxSchemaVersion = 2 const pipxCommand = `uvx --from pipx==1.17.4 pipx "$@" status=$? if [ "$status" -eq 0 ]; then - python3 -c ' + /usr/local/bin/python3 -c ' import os import pathlib import shutil @@ -68,7 +68,11 @@ allowed_python = pathlib.Path("/usr/local/bin/python3.13").resolve(strict=True) for link in pathlib.Path("/cb/pipx").rglob("*"): if not link.is_symlink() or not os.path.isabs(os.readlink(link)): continue - target = link.resolve(strict=True) + raw_target = os.readlink(link) + try: + target = link.resolve(strict=True) + except OSError as exc: + raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") from exc try: target.relative_to(root) except ValueError: @@ -76,7 +80,7 @@ for link in pathlib.Path("/cb/pipx").rglob("*"): venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2:] == ("bin", "python") launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") if target != allowed_python or not (venv_python or launcher_python): - raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {os.readlink(link)}") + raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") temporary = link.with_name(link.name + ".cb-copy") if temporary.exists() or temporary.is_symlink(): temporary.unlink() diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 7d43309..8d9e638 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -689,6 +689,19 @@ func TestPipxProfile(t *testing.T) { !strings.Contains(pipx.Command[2], `pipx produced unsupported absolute symlink`) { t.Fatalf("pipx command = %#v", pipx.Command) } + for _, fragment := range []string{ + `/usr/local/bin/python3 -c '`, + `raw_target = os.readlink(link)`, + `except OSError as exc:`, + `raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") from exc`, + } { + if !strings.Contains(pipx.Command[2], fragment) { + t.Fatalf("pipx command missing %q: %q", fragment, pipx.Command[2]) + } + } + if strings.Contains(pipx.Command[2], "\n python3 -c '") { + t.Fatalf("pipx command must invoke the image interpreter by absolute path: %q", pipx.Command[2]) + } wantVolumes := []string{"state:/cb/pipx"} if !reflect.DeepEqual(pipx.SharedVolumes, wantVolumes) { t.Fatalf("pipx shared_volumes = %#v, want %#v", pipx.SharedVolumes, wantVolumes) From 7e6b207689ac67f9b3aecab97b4deaf49b4afa47 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:28:10 +0100 Subject: [PATCH 03/10] fix: support pipx pip backend safely --- README.md | 5 ++++- internal/registry/registry.go | 36 +++++++++++++++++------------- internal/registry/registry_test.go | 9 +++++--- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e39fd36..1bb9dd3 100644 --- a/README.md +++ b/README.md @@ -425,7 +425,10 @@ settings. Automatic Python downloads are disabled and pipx uses the Python 3.13 interpreter already in the locked image. After a successful pipx command, a fail-closed wrapper changes pipx-owned absolute links to relative links within the state volume and copies only the known image interpreter into its launcher -cache and application venvs; this keeps `cb-pipx117-py313-state` portable +cache, application venvs, and the pip backend's shared-libraries venv. This +includes explicit `--backend pip` installs and pipx's forced pip backend for +the `pip` package while still rejecting any other external link target. This +keeps `cb-pipx117-py313-state` portable through selected-volume backup/restore without relaxing archive link validation. `pipx run` environments remain ephemeral because `PIPX_CACHE_DIR` is not placed on the managed volume; each `pipx run` may resolve and download its application diff --git a/internal/registry/registry.go b/internal/registry/registry.go index c86782d..a285208 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -55,12 +55,13 @@ type Registry struct { // startup is not subsequently reported as unsupported by cb doctor. const MaxSchemaVersion = 2 -const pipxCommand = `uvx --from pipx==1.17.4 pipx "$@" +const pipxCommand = `/usr/local/bin/uvx --from pipx==1.17.4 pipx "$@" status=$? if [ "$status" -eq 0 ]; then /usr/local/bin/python3 -c ' import os import pathlib +import secrets import shutil root = pathlib.Path("/cb/pipx").resolve() @@ -77,22 +78,27 @@ for link in pathlib.Path("/cb/pipx").rglob("*"): target.relative_to(root) except ValueError: parts = link.relative_to(root).parts - venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2:] == ("bin", "python") + venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in ("python", allowed_python.name) + shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2:] == ("bin", allowed_python.name) launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") - if target != allowed_python or not (venv_python or launcher_python): + if target != allowed_python or not (venv_python or shared_python or launcher_python): raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") - temporary = link.with_name(link.name + ".cb-copy") - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() - shutil.copy2(target, temporary) - os.replace(temporary, link) + temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") + try: + shutil.copy2(target, temporary) + os.replace(temporary, link) + finally: + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() continue relative = os.path.relpath(target, start=link.parent) - temporary = link.with_name(link.name + ".cb-link") - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() - temporary.symlink_to(relative) - os.replace(temporary, link) + temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") + try: + temporary.symlink_to(relative) + os.replace(temporary, link) + finally: + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() ' || exit $? fi exit "$status"` @@ -333,10 +339,10 @@ path_equals = ["--project", "--directory", "--config-file", "--cache-dir"] [tools.pipx] image = "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" provider = "stateful" -command = ` + toml.Array([]string{"sh", "-c", pipxCommand, "cb-pipx"}) + ` +command = ` + toml.Array([]string{"/bin/sh", "-c", pipxCommand, "cb-pipx"}) + ` state_group = "pipx117-py313" shared_volumes = ["state:/cb/pipx"] -env_set = ["UV_CACHE_DIR=/cb/pipx/launcher-cache", "UV_LINK_MODE=copy", "UV_PYTHON_DOWNLOADS=never", "PIPX_HOME=/cb/pipx/home", "PIPX_BIN_DIR=/cb/pipx/bin", "PIPX_MAN_DIR=/cb/pipx/home/man", "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", "PATH=/cb/pipx/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"] +env_set = ["UV_CACHE_DIR=/cb/pipx/launcher-cache", "UV_LINK_MODE=copy", "UV_PYTHON_DOWNLOADS=never", "PIPX_HOME=/cb/pipx/home", "PIPX_BIN_DIR=/cb/pipx/bin", "PIPX_MAN_DIR=/cb/pipx/home/man", "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", "PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin:/cb/pipx/bin"] env_prefixes = ["UV_INDEX_"] env_names = ["UV_INDEX", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_NO_INDEX", "UV_NATIVE_TLS", "UV_OFFLINE", "UV_NO_PROGRESS", "UV_COLOR", "UV_HTTP_TIMEOUT", "UV_HTTP_RETRIES", "UV_INSECURE_HOST", "UV_KEYRING_PROVIDER", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_NO_INDEX", "PIP_TRUSTED_HOST", "PIP_DISABLE_PIP_VERSION_CHECK", "PIP_TIMEOUT", "PIP_RETRIES", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] # pipx accepts package specs, URLs and child-command arguments. Do not force diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 8d9e638..c4bb250 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -684,8 +684,8 @@ func TestPipxProfile(t *testing.T) { if pipx.Image != "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" || pipx.Provider != "stateful" || pipx.StateGroup != "pipx117-py313" { t.Fatalf("bad pipx profile identity: %+v", pipx) } - if len(pipx.Command) != 4 || pipx.Command[0] != "sh" || pipx.Command[1] != "-c" || pipx.Command[3] != "cb-pipx" || - !strings.Contains(pipx.Command[2], `uvx --from pipx==1.17.4 pipx "$@"`) || + if len(pipx.Command) != 4 || pipx.Command[0] != "/bin/sh" || pipx.Command[1] != "-c" || pipx.Command[3] != "cb-pipx" || + !strings.Contains(pipx.Command[2], `/usr/local/bin/uvx --from pipx==1.17.4 pipx "$@"`) || !strings.Contains(pipx.Command[2], `pipx produced unsupported absolute symlink`) { t.Fatalf("pipx command = %#v", pipx.Command) } @@ -694,6 +694,9 @@ func TestPipxProfile(t *testing.T) { `raw_target = os.readlink(link)`, `except OSError as exc:`, `raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") from exc`, + `parts[:2] == ("home", "shared")`, + `parts[-1] in ("python", allowed_python.name)`, + `secrets.token_hex(8)`, } { if !strings.Contains(pipx.Command[2], fragment) { t.Fatalf("pipx command missing %q: %q", fragment, pipx.Command[2]) @@ -715,7 +718,7 @@ func TestPipxProfile(t *testing.T) { "PIPX_MAN_DIR=/cb/pipx/home/man", "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", - "PATH=/cb/pipx/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin", + "PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin:/cb/pipx/bin", } { if !containsString(pipx.EnvSet, entry) { t.Fatalf("pipx env_set missing %q: %#v", entry, pipx.EnvSet) From 883fda93681664927bec4cec97df3fc02dfc7b16 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:51:25 +0100 Subject: [PATCH 04/10] fix(pipx): serialize and validate managed state --- README.md | 17 +++--- internal/cli/cli.go | 54 +++++++++++++++++++ internal/cli/cli_test.go | 12 +++++ internal/registry/registry.go | 85 +++++++++++++++++------------- internal/registry/registry_test.go | 19 ++++--- 5 files changed, 136 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 1bb9dd3..2a4c175 100644 --- a/README.md +++ b/README.md @@ -422,17 +422,20 @@ launcher cache; later invocations can use that cache offline. The image lock pins the launcher image, while package-index trust and the pipx package download remain governed by the profile's narrowly forwarded uv/pip index, TLS and proxy settings. Automatic Python downloads are disabled and pipx uses the Python 3.13 -interpreter already in the locked image. After a successful pipx command, a -fail-closed wrapper changes pipx-owned absolute links to relative links within -the state volume and copies only the known image interpreter into its launcher -cache, application venvs, and the pip backend's shared-libraries venv. This +interpreter already in the locked image. A volume-local lock serializes pipx +commands that share this state. After every command, including failed commands, +a fail-closed wrapper validates every pipx-owned symlink, changes links that +resolve within the state volume to relative links, and copies only the known +image interpreter into its launcher cache, application venvs, and the pip +backend's shared-libraries venv. This includes explicit `--backend pip` installs and pipx's forced pip backend for the `pip` package while still rejecting any other external link target. This keeps `cb-pipx117-py313-state` portable through selected-volume backup/restore without relaxing archive link validation. -`pipx run` environments remain ephemeral because `PIPX_CACHE_DIR` is not placed -on the managed volume; each `pipx run` may resolve and download its application -again and therefore requires the configured package index to be reachable. +`pipx run` environments remain ephemeral because `PIPX_VENV_CACHEDIR` is not +placed on the managed volume; each `pipx run` may resolve and download its +application again and therefore requires the configured package index to be +reachable. ```powershell pipx install cowsay==6.1 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index acc14d0..9af31b8 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -21,6 +21,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/atomicio" "github.com/AviBackToBlack/container-bin/internal/diag" "github.com/AviBackToBlack/container-bin/internal/dockerrun" + "github.com/AviBackToBlack/container-bin/internal/dockervol" "github.com/AviBackToBlack/container-bin/internal/lockfile" "github.com/AviBackToBlack/container-bin/internal/pathmap" "github.com/AviBackToBlack/container-bin/internal/registry" @@ -426,6 +427,9 @@ func discoverGlobalBins(t registry.Tool, store exposeStore) ([]exposedBin, error if err := inspectExposeImage(t.Name, image); err != nil { return nil, err } + if err := ensureExposeStoreVolumes(t, store); err != nil { + return nil, err + } script := `if [ -d "$1" ]; then for f in "$1"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; printf '%s\000' "${f##*/}"; done; fi` dockerArgs, err := exposeDiscoveryArgs(store, image, script) if err != nil { @@ -439,6 +443,56 @@ func discoverGlobalBins(t registry.Tool, store exposeStore) ([]exposedBin, error return parseExposedBins(out, store) } +func ensureExposeStoreVolumes(t registry.Tool, store exposeStore) error { + volumes, err := exposeStoreManagedVolumes(t, store) + if err != nil { + return err + } + for volumeName, labels := range volumes { + if err := dockervol.EnsureManaged(volumeName, labels); err != nil { + return fmt.Errorf("prepare %s global store: %w", store.kind, err) + } + actual, err := dockervol.Labels(volumeName) + if err != nil { + return fmt.Errorf("verify %s global store: %w", store.kind, err) + } + for key, want := range labels { + if actual[key] != want { + return fmt.Errorf("%s global store volume %s has incompatible label %s=%q (want %q)", store.kind, volumeName, key, actual[key], want) + } + } + } + return nil +} + +func exposeStoreManagedVolumes(t registry.Tool, store exposeStore) (map[string]map[string]string, error) { + needed := map[string]bool{store.volumeName: true} + for _, companion := range store.companionMounts { + needed[companion.volumeName] = true + } + volumes := map[string]map[string]string{} + for _, spec := range t.SharedVolumes { + logical, _, err := registry.ParseVolumeBinding(spec) + if err != nil { + return nil, err + } + volumeName := pathmap.StatefulSharedVolumeID(t.StateGroup, logical) + if !needed[volumeName] { + continue + } + volumes[volumeName] = map[string]string{ + "cb.managed": "true", + "cb.kind": "shared", + "cb.owner": t.StateGroup + "/" + logical, + } + delete(needed, volumeName) + } + if len(needed) != 0 { + return nil, fmt.Errorf("tool %q global store has no matching shared-volume declaration", t.Name) + } + return volumes, nil +} + func exposeDiscoveryError(kind string, out []byte, err error) error { detail := strings.TrimSpace(string(out)) if detail == "" { diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 225357a..fe525c5 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -494,6 +494,18 @@ func TestPipxExposeDiscoveryMountsStateReadOnly(t *testing.T) { if !reflect.DeepEqual(args, want) { t.Fatalf("discovery args = %#v, want %#v", args, want) } + volumes, err := exposeStoreManagedVolumes(reg.Tools["pipx"], store) + if err != nil { + t.Fatal(err) + } + wantLabels := map[string]string{ + "cb.managed": "true", + "cb.kind": "shared", + "cb.owner": "pipx117-py313/state", + } + if !reflect.DeepEqual(volumes["cb-pipx117-py313-state"], wantLabels) { + t.Fatalf("pipx expose volume labels = %#v, want %#v", volumes, wantLabels) + } } func TestExposeStoreRejectsAmbiguousProfile(t *testing.T) { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index a285208..74c3461 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -55,53 +55,66 @@ type Registry struct { // startup is not subsequently reported as unsupported by cb doctor. const MaxSchemaVersion = 2 -const pipxCommand = `/usr/local/bin/uvx --from pipx==1.17.4 pipx "$@" -status=$? -if [ "$status" -eq 0 ]; then - /usr/local/bin/python3 -c ' +const pipxCommand = `import fcntl import os import pathlib import secrets import shutil +import subprocess +import sys root = pathlib.Path("/cb/pipx").resolve() allowed_python = pathlib.Path("/usr/local/bin/python3.13").resolve(strict=True) -for link in pathlib.Path("/cb/pipx").rglob("*"): - if not link.is_symlink() or not os.path.isabs(os.readlink(link)): - continue - raw_target = os.readlink(link) - try: - target = link.resolve(strict=True) - except OSError as exc: - raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") from exc - try: - target.relative_to(root) - except ValueError: - parts = link.relative_to(root).parts - venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in ("python", allowed_python.name) - shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2:] == ("bin", allowed_python.name) - launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") - if target != allowed_python or not (venv_python or shared_python or launcher_python): - raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") +lock_path = root / ".cb-pipx.lock" +with lock_path.open("a+b") as state_lock: + fcntl.flock(state_lock, fcntl.LOCK_EX) + status = subprocess.run( + ["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *sys.argv[1:]], + check=False, + ).returncode + for link in root.rglob("*"): + try: + if not link.is_symlink(): + continue + raw_target = os.readlink(link) + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError(f"pipx produced unreadable symlink: {link}") from exc + try: + target = link.resolve(strict=True) + except FileNotFoundError as exc: + if not link.is_symlink(): + continue + raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc + except OSError as exc: + raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc + try: + target.relative_to(root) + except ValueError: + parts = link.relative_to(root).parts + venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in ("python", allowed_python.name) + shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2:] == ("bin", allowed_python.name) + launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") + if target != allowed_python or not (venv_python or shared_python or launcher_python): + raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") + temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") + try: + shutil.copy2(target, temporary) + os.replace(temporary, link) + finally: + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() + continue + relative = os.path.relpath(target, start=link.parent) temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") try: - shutil.copy2(target, temporary) + temporary.symlink_to(relative) os.replace(temporary, link) finally: if temporary.exists() or temporary.is_symlink(): temporary.unlink() - continue - relative = os.path.relpath(target, start=link.parent) - temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") - try: - temporary.symlink_to(relative) - os.replace(temporary, link) - finally: - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() -' || exit $? -fi -exit "$status"` +raise SystemExit(status)` var DefaultTOML = `# container-bin tool registry schema_version = 2 @@ -339,7 +352,7 @@ path_equals = ["--project", "--directory", "--config-file", "--cache-dir"] [tools.pipx] image = "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" provider = "stateful" -command = ` + toml.Array([]string{"/bin/sh", "-c", pipxCommand, "cb-pipx"}) + ` +command = ` + toml.Array([]string{"/usr/local/bin/python3", "-c", pipxCommand}) + ` state_group = "pipx117-py313" shared_volumes = ["state:/cb/pipx"] env_set = ["UV_CACHE_DIR=/cb/pipx/launcher-cache", "UV_LINK_MODE=copy", "UV_PYTHON_DOWNLOADS=never", "PIPX_HOME=/cb/pipx/home", "PIPX_BIN_DIR=/cb/pipx/bin", "PIPX_MAN_DIR=/cb/pipx/home/man", "PIPX_COMPLETION_DIR=/cb/pipx/home/completions", "PIPX_DEFAULT_PYTHON=/usr/local/bin/python3.13", "PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin:/cb/pipx/bin"] @@ -347,7 +360,7 @@ env_prefixes = ["UV_INDEX_"] env_names = ["UV_INDEX", "UV_DEFAULT_INDEX", "UV_EXTRA_INDEX_URL", "UV_NO_INDEX", "UV_NATIVE_TLS", "UV_OFFLINE", "UV_NO_PROGRESS", "UV_COLOR", "UV_HTTP_TIMEOUT", "UV_HTTP_RETRIES", "UV_INSECURE_HOST", "UV_KEYRING_PROVIDER", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_NO_INDEX", "PIP_TRUSTED_HOST", "PIP_DISABLE_PIP_VERSION_CHECK", "PIP_TIMEOUT", "PIP_RETRIES", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] # pipx accepts package specs, URLs and child-command arguments. Do not force # positional path mapping; explicit Windows path operands use the general mapper. -# The wrapper rewrites only absolute pipx-owned links after successful commands: +# The wrapper serializes pipx mutations and validates links after every command: # internal links become relative and known image interpreters become copies. # This keeps the single managed state volume portable through safe backup/restore. diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index c4bb250..143e185 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -684,26 +684,29 @@ func TestPipxProfile(t *testing.T) { if pipx.Image != "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" || pipx.Provider != "stateful" || pipx.StateGroup != "pipx117-py313" { t.Fatalf("bad pipx profile identity: %+v", pipx) } - if len(pipx.Command) != 4 || pipx.Command[0] != "/bin/sh" || pipx.Command[1] != "-c" || pipx.Command[3] != "cb-pipx" || - !strings.Contains(pipx.Command[2], `/usr/local/bin/uvx --from pipx==1.17.4 pipx "$@"`) || - !strings.Contains(pipx.Command[2], `pipx produced unsupported absolute symlink`) { + if len(pipx.Command) != 3 || pipx.Command[0] != "/usr/local/bin/python3" || pipx.Command[1] != "-c" || + !strings.Contains(pipx.Command[2], `["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *sys.argv[1:]]`) || + !strings.Contains(pipx.Command[2], `pipx produced unsupported symlink`) { t.Fatalf("pipx command = %#v", pipx.Command) } for _, fragment := range []string{ - `/usr/local/bin/python3 -c '`, + `fcntl.flock(state_lock, fcntl.LOCK_EX)`, + `status = subprocess.run(`, `raw_target = os.readlink(link)`, + `except FileNotFoundError:`, `except OSError as exc:`, - `raise RuntimeError(f"pipx produced unsupported absolute symlink: {link} -> {raw_target}") from exc`, + `raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc`, `parts[:2] == ("home", "shared")`, `parts[-1] in ("python", allowed_python.name)`, `secrets.token_hex(8)`, + `raise SystemExit(status)`, } { if !strings.Contains(pipx.Command[2], fragment) { t.Fatalf("pipx command missing %q: %q", fragment, pipx.Command[2]) } } - if strings.Contains(pipx.Command[2], "\n python3 -c '") { - t.Fatalf("pipx command must invoke the image interpreter by absolute path: %q", pipx.Command[2]) + if strings.Contains(pipx.Command[2], "os.path.isabs") { + t.Fatalf("pipx command must validate relative symlinks too: %q", pipx.Command[2]) } wantVolumes := []string{"state:/cb/pipx"} if !reflect.DeepEqual(pipx.SharedVolumes, wantVolumes) { @@ -732,7 +735,7 @@ func TestPipxProfile(t *testing.T) { t.Fatalf("pipx env_names missing %q: %#v", envName, pipx.EnvNames) } } - for _, pathOrControlVariable := range []string{"UV_CACHE_DIR", "UV_TOOL_DIR", "UV_TOOL_BIN_DIR", "PIPX_HOME", "PIPX_BIN_DIR", "PIPX_MAN_DIR", "PIPX_COMPLETION_DIR", "PIPX_DEFAULT_PYTHON", "PATH"} { + for _, pathOrControlVariable := range []string{"UV_CACHE_DIR", "UV_TOOL_DIR", "UV_TOOL_BIN_DIR", "PIPX_HOME", "PIPX_BIN_DIR", "PIPX_MAN_DIR", "PIPX_COMPLETION_DIR", "PIPX_DEFAULT_PYTHON", "PIPX_VENV_CACHEDIR", "PATH"} { if containsString(pipx.EnvNames, pathOrControlVariable) { t.Fatalf("pipx env allowlist must not include controlled variable %q", pathOrControlVariable) } From 3bca197220a89e175f220e1130bf160f59a5d437 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:09:09 +0100 Subject: [PATCH 05/10] fix(pipx): allow standard venv aliases --- internal/registry/registry.go | 5 +++-- internal/registry/registry_test.go | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 74c3461..198c4b7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -93,8 +93,9 @@ with lock_path.open("a+b") as state_lock: target.relative_to(root) except ValueError: parts = link.relative_to(root).parts - venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in ("python", allowed_python.name) - shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2:] == ("bin", allowed_python.name) + interpreter_aliases = ("python", "python3", allowed_python.name) + venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in interpreter_aliases + shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2] == "bin" and parts[-1] in interpreter_aliases launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") if target != allowed_python or not (venv_python or shared_python or launcher_python): raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 143e185..ac274a0 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -697,7 +697,8 @@ func TestPipxProfile(t *testing.T) { `except OSError as exc:`, `raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc`, `parts[:2] == ("home", "shared")`, - `parts[-1] in ("python", allowed_python.name)`, + `interpreter_aliases = ("python", "python3", allowed_python.name)`, + `parts[-1] in interpreter_aliases`, `secrets.token_hex(8)`, `raise SystemExit(status)`, } { From 1c5b64709e06fbb31a68925f408b5165d21079de Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:21:18 +0100 Subject: [PATCH 06/10] test(pipx): execute wrapper invariants --- .github/workflows/ci.yml | 2 + .gitignore | 4 + internal/registry/pipx_wrapper.py | 110 +++++++++++++++++++++++++ internal/registry/pipx_wrapper_test.py | 80 ++++++++++++++++++ internal/registry/registry.go | 64 +------------- internal/registry/registry_test.go | 12 ++- 6 files changed, 207 insertions(+), 65 deletions(-) create mode 100644 internal/registry/pipx_wrapper.py create mode 100644 internal/registry/pipx_wrapper_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b1d250..a07db5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: run: go vet ./... - name: go test (race) run: go test -race ./... + - name: Test embedded pipx wrapper + run: python3 -m unittest -v internal/registry/pipx_wrapper_test.py - name: Verify release-style version injection run: | # The binary MUST be named cb: argv[0] dispatch treats any other diff --git a/.gitignore b/.gitignore index 648bb62..c697a58 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ *.out /dist/ +# Python wrapper-test artifacts +__pycache__/ +*.pyc + # Editor / OS noise .vscode/ .idea/ diff --git a/internal/registry/pipx_wrapper.py b/internal/registry/pipx_wrapper.py new file mode 100644 index 0000000..f520d23 --- /dev/null +++ b/internal/registry/pipx_wrapper.py @@ -0,0 +1,110 @@ +import fcntl +import os +import pathlib +import secrets +import shutil +import subprocess +import sys + + +def normalize_state(root, allowed_python): + for link in root.rglob("*"): + try: + if not link.is_symlink(): + continue + raw_target = os.readlink(link) + except FileNotFoundError: + continue + except OSError as exc: + raise RuntimeError(f"pipx produced unreadable symlink: {link}") from exc + try: + target = link.resolve(strict=True) + except FileNotFoundError as exc: + if not link.is_symlink(): + continue + raise RuntimeError( + f"pipx produced unsupported symlink: {link} -> {raw_target}" + ) from exc + except OSError as exc: + raise RuntimeError( + f"pipx produced unsupported symlink: {link} -> {raw_target}" + ) from exc + try: + target.relative_to(root) + except ValueError: + parts = link.relative_to(root).parts + interpreter_aliases = ("python", "python3", allowed_python.name) + venv_python = ( + len(parts) == 5 + and parts[:2] == ("home", "venvs") + and parts[-2] == "bin" + and parts[-1] in interpreter_aliases + ) + shared_python = ( + len(parts) == 4 + and parts[:2] == ("home", "shared") + and parts[-2] == "bin" + and parts[-1] in interpreter_aliases + ) + launcher_python = ( + len(parts) == 5 + and parts[:2] == ("launcher-cache", "archive-v0") + and parts[-2:] == ("bin", "python") + ) + if target != allowed_python or not ( + venv_python or shared_python or launcher_python + ): + raise RuntimeError( + f"pipx produced unsupported symlink: {link} -> {raw_target}" + ) + temporary = link.with_name( + f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}" + ) + try: + shutil.copy2(target, temporary) + os.replace(temporary, link) + finally: + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() + continue + relative = os.path.relpath(target, start=link.parent) + temporary = link.with_name( + f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}" + ) + try: + temporary.symlink_to(relative) + os.replace(temporary, link) + finally: + if temporary.exists() or temporary.is_symlink(): + temporary.unlink() + + +def run(argv, root, allowed_python, runner=subprocess.run): + root = root.resolve(strict=True) + allowed_python = allowed_python.resolve(strict=True) + lock_path = root / ".cb-pipx.lock" + with lock_path.open("a+b") as state_lock: + fcntl.flock(state_lock, fcntl.LOCK_EX) + status = 1 + try: + status = runner( + ["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *argv], + check=False, + ).returncode + except KeyboardInterrupt: + status = 130 + finally: + normalize_state(root, allowed_python) + if status < 0: + return 128 - status + return status + + +if __name__ == "__main__": + raise SystemExit( + run( + sys.argv[1:], + pathlib.Path("/cb/pipx"), + pathlib.Path("/usr/local/bin/python3.13"), + ) + ) diff --git a/internal/registry/pipx_wrapper_test.py b/internal/registry/pipx_wrapper_test.py new file mode 100644 index 0000000..d3e79af --- /dev/null +++ b/internal/registry/pipx_wrapper_test.py @@ -0,0 +1,80 @@ +import importlib.util +import pathlib +import sys +import tempfile +import unittest + + +MODULE_PATH = pathlib.Path(__file__).with_name("pipx_wrapper.py") +SPEC = importlib.util.spec_from_file_location("pipx_wrapper", MODULE_PATH) +WRAPPER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(WRAPPER) + + +class Result: + def __init__(self, returncode): + self.returncode = returncode + + +class PipxWrapperTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tempdir.name).resolve() + self.allowed_python = pathlib.Path(sys.executable).resolve(strict=True) + + def tearDown(self): + self.tempdir.cleanup() + + def make_internal_link(self, name="demo"): + target = self.root / "home" / "venvs" / name / "bin" / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("fixture") + link = self.root / "bin" / name + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target) + return link, target + + def test_failed_command_still_normalizes_links(self): + link, target = self.make_internal_link() + status = WRAPPER.run( + ["list"], self.root, self.allowed_python, lambda *a, **k: Result(7) + ) + self.assertEqual(status, 7) + self.assertEqual(link.resolve(strict=True), target) + self.assertFalse(pathlib.Path(link.readlink()).is_absolute()) + + def test_interrupt_still_normalizes_links_and_returns_130(self): + link, _ = self.make_internal_link() + + def interrupt(*args, **kwargs): + raise KeyboardInterrupt + + status = WRAPPER.run([], self.root, self.allowed_python, interrupt) + self.assertEqual(status, 130) + self.assertFalse(pathlib.Path(link.readlink()).is_absolute()) + + def test_signal_return_code_maps_to_shell_convention(self): + status = WRAPPER.run( + [], self.root, self.allowed_python, lambda *a, **k: Result(-2) + ) + self.assertEqual(status, 130) + + def test_relative_escape_fails_closed(self): + link = self.root / "bin" / "evil" + link.parent.mkdir(parents=True) + link.symlink_to("../../../etc/passwd") + with self.assertRaisesRegex(RuntimeError, "unsupported symlink"): + WRAPPER.run([], self.root, self.allowed_python, lambda *a, **k: Result(0)) + + def test_known_python3_alias_becomes_regular_file(self): + link = self.root / "home" / "venvs" / "demo" / "bin" / "python3" + link.parent.mkdir(parents=True) + link.symlink_to(self.allowed_python) + status = WRAPPER.run([], self.root, self.allowed_python, lambda *a, **k: Result(0)) + self.assertEqual(status, 0) + self.assertTrue(link.is_file()) + self.assertFalse(link.is_symlink()) + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 198c4b7..a98ca7f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,6 +5,7 @@ package registry import ( "bufio" + _ "embed" "errors" "fmt" "path" @@ -55,67 +56,8 @@ type Registry struct { // startup is not subsequently reported as unsupported by cb doctor. const MaxSchemaVersion = 2 -const pipxCommand = `import fcntl -import os -import pathlib -import secrets -import shutil -import subprocess -import sys - -root = pathlib.Path("/cb/pipx").resolve() -allowed_python = pathlib.Path("/usr/local/bin/python3.13").resolve(strict=True) -lock_path = root / ".cb-pipx.lock" -with lock_path.open("a+b") as state_lock: - fcntl.flock(state_lock, fcntl.LOCK_EX) - status = subprocess.run( - ["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *sys.argv[1:]], - check=False, - ).returncode - for link in root.rglob("*"): - try: - if not link.is_symlink(): - continue - raw_target = os.readlink(link) - except FileNotFoundError: - continue - except OSError as exc: - raise RuntimeError(f"pipx produced unreadable symlink: {link}") from exc - try: - target = link.resolve(strict=True) - except FileNotFoundError as exc: - if not link.is_symlink(): - continue - raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc - except OSError as exc: - raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc - try: - target.relative_to(root) - except ValueError: - parts = link.relative_to(root).parts - interpreter_aliases = ("python", "python3", allowed_python.name) - venv_python = len(parts) == 5 and parts[:2] == ("home", "venvs") and parts[-2] == "bin" and parts[-1] in interpreter_aliases - shared_python = len(parts) == 4 and parts[:2] == ("home", "shared") and parts[-2] == "bin" and parts[-1] in interpreter_aliases - launcher_python = len(parts) == 5 and parts[:2] == ("launcher-cache", "archive-v0") and parts[-2:] == ("bin", "python") - if target != allowed_python or not (venv_python or shared_python or launcher_python): - raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") - temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") - try: - shutil.copy2(target, temporary) - os.replace(temporary, link) - finally: - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() - continue - relative = os.path.relpath(target, start=link.parent) - temporary = link.with_name(f".{link.name}.cb-{os.getpid()}-{secrets.token_hex(8)}") - try: - temporary.symlink_to(relative) - os.replace(temporary, link) - finally: - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() -raise SystemExit(status)` +//go:embed pipx_wrapper.py +var pipxCommand string var DefaultTOML = `# container-bin tool registry schema_version = 2 diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index ac274a0..7b56043 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -685,22 +685,26 @@ func TestPipxProfile(t *testing.T) { t.Fatalf("bad pipx profile identity: %+v", pipx) } if len(pipx.Command) != 3 || pipx.Command[0] != "/usr/local/bin/python3" || pipx.Command[1] != "-c" || - !strings.Contains(pipx.Command[2], `["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *sys.argv[1:]]`) || + !strings.Contains(pipx.Command[2], `["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *argv]`) || !strings.Contains(pipx.Command[2], `pipx produced unsupported symlink`) { t.Fatalf("pipx command = %#v", pipx.Command) } for _, fragment := range []string{ `fcntl.flock(state_lock, fcntl.LOCK_EX)`, - `status = subprocess.run(`, + `status = runner(`, + `except KeyboardInterrupt:`, + "finally:\n normalize_state(root, allowed_python)", `raw_target = os.readlink(link)`, `except FileNotFoundError:`, `except OSError as exc:`, - `raise RuntimeError(f"pipx produced unsupported symlink: {link} -> {raw_target}") from exc`, + `f"pipx produced unsupported symlink: {link} -> {raw_target}"`, + `) from exc`, `parts[:2] == ("home", "shared")`, `interpreter_aliases = ("python", "python3", allowed_python.name)`, `parts[-1] in interpreter_aliases`, `secrets.token_hex(8)`, - `raise SystemExit(status)`, + `return 128 - status`, + `raise SystemExit(`, } { if !strings.Contains(pipx.Command[2], fragment) { t.Fatalf("pipx command missing %q: %q", fragment, pipx.Command[2]) From 1000b9b19f6ae3a0ab63d9925cd2147162f65348 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:26:44 +0100 Subject: [PATCH 07/10] test(pipx): normalize embedded wrapper newlines --- .gitattributes | 1 + internal/registry/registry_test.go | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 49e5634..446e09a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.go text eol=lf *.md text eol=lf +*.py text eol=lf *.yml text eol=lf *.yaml text eol=lf *.toml text eol=lf diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 7b56043..87e16eb 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -684,9 +684,12 @@ func TestPipxProfile(t *testing.T) { if pipx.Image != "ghcr.io/astral-sh/uv:0.12-python3.13-trixie-slim" || pipx.Provider != "stateful" || pipx.StateGroup != "pipx117-py313" { t.Fatalf("bad pipx profile identity: %+v", pipx) } - if len(pipx.Command) != 3 || pipx.Command[0] != "/usr/local/bin/python3" || pipx.Command[1] != "-c" || - !strings.Contains(pipx.Command[2], `["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *argv]`) || - !strings.Contains(pipx.Command[2], `pipx produced unsupported symlink`) { + if len(pipx.Command) != 3 || pipx.Command[0] != "/usr/local/bin/python3" || pipx.Command[1] != "-c" { + t.Fatalf("pipx command = %#v", pipx.Command) + } + command := strings.ReplaceAll(pipx.Command[2], "\r\n", "\n") + if !strings.Contains(command, `["/usr/local/bin/uvx", "--from", "pipx==1.17.4", "pipx", *argv]`) || + !strings.Contains(command, `pipx produced unsupported symlink`) { t.Fatalf("pipx command = %#v", pipx.Command) } for _, fragment := range []string{ @@ -706,12 +709,12 @@ func TestPipxProfile(t *testing.T) { `return 128 - status`, `raise SystemExit(`, } { - if !strings.Contains(pipx.Command[2], fragment) { - t.Fatalf("pipx command missing %q: %q", fragment, pipx.Command[2]) + if !strings.Contains(command, fragment) { + t.Fatalf("pipx command missing %q: %q", fragment, command) } } - if strings.Contains(pipx.Command[2], "os.path.isabs") { - t.Fatalf("pipx command must validate relative symlinks too: %q", pipx.Command[2]) + if strings.Contains(command, "os.path.isabs") { + t.Fatalf("pipx command must validate relative symlinks too: %q", command) } wantVolumes := []string{"state:/cb/pipx"} if !reflect.DeepEqual(pipx.SharedVolumes, wantVolumes) { From a249185352311a16eb34421f71d9357ed88b47eb Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:38:36 +0100 Subject: [PATCH 08/10] fix(expose): reject external pipx targets --- README.md | 3 ++- docs/security-model.md | 3 ++- internal/cli/cli.go | 32 ++++++++++++++++++++++++++++---- internal/cli/cli_test.go | 18 +++++++++++++++++- 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2a4c175..8f18bb2 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,8 @@ The generated application profiles preserve the pipx image, state group, volumes and environment policy. Exposure discovery reads only the managed bin directory within the state volume through the selected locked profile; it does not search a project venv, the host `PATH`, uv's store, or other container -directories. +directories. Each discovered executable must also resolve inside the managed +pipx state volume, so an external link left by a failed install is rejected. Plain `pip` remains for project dependencies, so scripts in `/venv/bin` are deliberately not eligible for global exposure. diff --git a/docs/security-model.md b/docs/security-model.md index 6a60265..0fbde72 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -93,7 +93,8 @@ readable, and dangerous to let others edit. Python package index into a dedicated cache on first use. Index overrides, TLS settings and proxies therefore remain part of that bootstrap's trust boundary; the image lock does not attest package-index artifacts. -- **Fail-closed pipx link normalization.** After a successful pipx command, the +- **Fail-closed pipx link normalization.** After every pipx command, including + failed commands and interrupts, the profile makes pipx-owned absolute links relative within its one state volume and copies the exact image interpreter only at known venv/cache locations. Any other absolute link fails the command; archive traversal checks remain diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 9af31b8..d51d6a9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -281,6 +281,7 @@ type exposeStore struct { mountTarget string binDirectory string installHint string + validateTargets bool companionTargets []string companionMounts []exposeMount } @@ -306,7 +307,7 @@ func exposeStoreForMountTarget(dst string) (exposeStore, bool) { case "/cb/uv-bin": return exposeStore{kind: "uv tool", mountTarget: dst, binDirectory: dst, installHint: "uv tool install ", companionTargets: []string{"/cb/uv-tools"}}, true case "/cb/pipx": - return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install "}, true + return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install ", validateTargets: true}, true case "/root/.dotnet": return exposeStore{kind: ".NET tool", mountTarget: dst, binDirectory: dst + "/tools", installHint: "dotnet tool install --global "}, true case "/cb/ruby-gems": @@ -430,7 +431,8 @@ func discoverGlobalBins(t registry.Tool, store exposeStore) ([]exposedBin, error if err := ensureExposeStoreVolumes(t, store); err != nil { return nil, err } - script := `if [ -d "$1" ]; then for f in "$1"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; printf '%s\000' "${f##*/}"; done; fi` + script := `if [ -d "$1" ]; then for f in "$1"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; if [ -n "${2-}" ]; then newline=' +'; marked=$(readlink -f "$f"; status=$?; printf x; exit "$status") || { printf 'cannot resolve %s\n' "$f" >&2; exit 1; }; resolved=${marked%x}; resolved=${resolved%"$newline"}; printf '%s\000%s\000' "${f##*/}" "$resolved"; else printf '%s\000' "${f##*/}"; fi; done; fi` dockerArgs, err := exposeDiscoveryArgs(store, image, script) if err != nil { return nil, err @@ -512,6 +514,9 @@ func exposeDiscoveryArgs(store exposeStore, image, script string) ([]string, err dockerArgs = append(dockerArgs, "--mount", mount) } dockerArgs = append(dockerArgs, "--entrypoint", "sh", image, "-c", script, "cb-expose", store.binDirectory) + if store.validateTargets { + dockerArgs = append(dockerArgs, store.mountTarget) + } return dockerArgs, nil } @@ -573,16 +578,35 @@ func inspectSharedVolumeFile(t registry.Tool, store exposeStore, command string) } func parseExposedBins(out []byte, store exposeStore) ([]exposedBin, error) { + fields := bytes.Split(out, []byte{0}) + if len(fields) > 0 && len(fields[len(fields)-1]) == 0 { + fields = fields[:len(fields)-1] + } + stride := 1 + if store.validateTargets { + stride = 2 + if len(fields)%stride != 0 { + return nil, fmt.Errorf("inspect %s global bin: malformed resolved-target output", store.kind) + } + } seen := map[string]string{} var bins []exposedBin - for _, raw := range bytes.Split(out, []byte{0}) { - binary := string(raw) + for i := 0; i < len(fields); i += stride { + binary := string(fields[i]) name := strings.ToLower(binary) // Untrusted names discovered inside the container: skip anything that // is not a safe shim name or that would shadow cb / Windows devices. if name == "" || strings.HasPrefix(name, "-") || !registry.ValidToolName(name) || registry.ReservedToolName(name) { continue } + if store.validateTargets { + resolved := string(fields[i+1]) + root := path.Clean(store.mountTarget) + target := path.Clean(resolved) + if !path.IsAbs(resolved) || (target != root && !strings.HasPrefix(target, root+"/")) { + return nil, fmt.Errorf("%s global binary %q resolves outside managed store %s: %s", store.kind, binary, root, resolved) + } + } if previous, ok := seen[name]; ok { if previous != binary { return nil, fmt.Errorf("global binaries %q and %q differ only by case and cannot share a Windows shim", previous, binary) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index fe525c5..0684584 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -489,7 +489,7 @@ func TestPipxExposeDiscoveryMountsStateReadOnly(t *testing.T) { want := []string{ "run", "--rm", "--pull", "never", "--network", "none", "--read-only", "--mount", "type=volume,src=cb-pipx117-py313-state,dst=/cb/pipx,readonly", - "--entrypoint", "sh", "example/pipx:1", "-c", "discover-script", "cb-expose", "/cb/pipx/bin", + "--entrypoint", "sh", "example/pipx:1", "-c", "discover-script", "cb-expose", "/cb/pipx/bin", "/cb/pipx", } if !reflect.DeepEqual(args, want) { t.Fatalf("discovery args = %#v, want %#v", args, want) @@ -685,6 +685,22 @@ func TestParseExposedBinsRejectsCaseCollision(t *testing.T) { } } +func TestParseExposedBinsRejectsResolvedTargetOutsideManagedStore(t *testing.T) { + store := exposeStore{kind: "pipx", mountTarget: "/cb/pipx", binDirectory: "/cb/pipx/bin", validateTargets: true} + if _, err := parseExposedBins([]byte("cowsay\x00/usr/local/bin/cowsay\x00"), store); err == nil || !strings.Contains(err.Error(), "resolves outside managed store /cb/pipx") { + t.Fatalf("external target error = %v", err) + } + + bins, err := parseExposedBins([]byte("cowsay\x00/cb/pipx/home/cowsay/venv/bin/cowsay\x00"), store) + if err != nil { + t.Fatal(err) + } + want := []exposedBin{{name: "cowsay", command: "/cb/pipx/bin/cowsay"}} + if !reflect.DeepEqual(bins, want) { + t.Fatalf("bins = %#v, want %#v", bins, want) + } +} + func TestSelectExposedBinsReportsEveryMissingRequest(t *testing.T) { bins := []exposedBin{ {name: "cargo-add", command: "/cb/cargo-global/bin/cargo-add"}, From dcb4412dba19977370797305b9e62f5670d1b7f5 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:19:38 +0100 Subject: [PATCH 09/10] fix(expose): preserve legacy unlabeled stores --- internal/cli/cli.go | 32 +++++++++++++++++++++++++++----- internal/cli/cli_test.go | 14 ++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d51d6a9..3a35fc3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -282,6 +282,7 @@ type exposeStore struct { binDirectory string installHint string validateTargets bool + requireLabels bool companionTargets []string companionMounts []exposeMount } @@ -307,7 +308,7 @@ func exposeStoreForMountTarget(dst string) (exposeStore, bool) { case "/cb/uv-bin": return exposeStore{kind: "uv tool", mountTarget: dst, binDirectory: dst, installHint: "uv tool install ", companionTargets: []string{"/cb/uv-tools"}}, true case "/cb/pipx": - return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install ", validateTargets: true}, true + return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install ", validateTargets: true, requireLabels: true}, true case "/root/.dotnet": return exposeStore{kind: ".NET tool", mountTarget: dst, binDirectory: dst + "/tools", installHint: "dotnet tool install --global "}, true case "/cb/ruby-gems": @@ -458,10 +459,31 @@ func ensureExposeStoreVolumes(t registry.Tool, store exposeStore) error { if err != nil { return fmt.Errorf("verify %s global store: %w", store.kind, err) } - for key, want := range labels { - if actual[key] != want { - return fmt.Errorf("%s global store volume %s has incompatible label %s=%q (want %q)", store.kind, volumeName, key, actual[key], want) - } + if err := validateExposeStoreLabels(store, volumeName, labels, actual); err != nil { + return err + } + } + return nil +} + +func validateExposeStoreLabels(store exposeStore, volumeName string, want, actual map[string]string) error { + // Runtime versions predating managed-volume labels already created stores + // for the established ecosystems. Docker cannot add labels to an existing + // volume, so preserve those legacy stores instead of silently breaking an + // existing expose workflow. Pipx has no pre-label installed base: its new + // reserved volume name must either carry the exact ownership labels or fail. + if actual["cb.managed"] == "" && !store.requireLabels { + return nil + } + keys := make([]string, 0, len(want)) + for key := range want { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + value := want[key] + if actual[key] != value { + return fmt.Errorf("%s global store volume %s has incompatible label %s=%q (want %q)", store.kind, volumeName, key, actual[key], value) } } return nil diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0684584..ee6c576 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -508,6 +508,20 @@ func TestPipxExposeDiscoveryMountsStateReadOnly(t *testing.T) { } } +func TestExposeStoreLabelValidationPreservesLegacyStoresButRequiresPipxOwnership(t *testing.T) { + want := map[string]string{"cb.managed": "true", "cb.kind": "shared", "cb.owner": "demo/tools"} + if err := validateExposeStoreLabels(exposeStore{kind: "npm"}, "cb-demo-tools", want, nil); err != nil { + t.Fatalf("legacy established store rejected: %v", err) + } + pipx := exposeStore{kind: "pipx", requireLabels: true} + if err := validateExposeStoreLabels(pipx, "cb-pipx117-py313-state", want, nil); err == nil || !strings.Contains(err.Error(), "incompatible label") { + t.Fatalf("unlabeled pipx store error = %v", err) + } + if err := validateExposeStoreLabels(pipx, "cb-pipx117-py313-state", want, want); err != nil { + t.Fatalf("correctly labeled pipx store rejected: %v", err) + } +} + func TestExposeStoreRejectsAmbiguousProfile(t *testing.T) { reg, err := registry.ParseTOML(`[tools.demo] image = "example/demo:1" From 64798af7ec51895b5ec8bc359c3dfc1bf2f7ce19 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:36:15 +0100 Subject: [PATCH 10/10] fix(pipx): serialize exposure discovery --- .github/workflows/ci.yml | 2 +- README.md | 15 ++-- docs/security-model.md | 11 ++- internal/cli/cli.go | 23 +++++-- internal/cli/cli_test.go | 3 +- internal/cli/pipx_discovery.py | 69 +++++++++++++++++++ internal/cli/pipx_discovery_test.py | 95 ++++++++++++++++++++++++++ internal/registry/pipx_wrapper.py | 16 ++++- internal/registry/pipx_wrapper_test.py | 6 ++ 9 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 internal/cli/pipx_discovery.py create mode 100644 internal/cli/pipx_discovery_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a07db5f..5e5f7be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: - name: go test (race) run: go test -race ./... - name: Test embedded pipx wrapper - run: python3 -m unittest -v internal/registry/pipx_wrapper_test.py + run: python3 -m unittest -v internal/registry/pipx_wrapper_test.py internal/cli/pipx_discovery_test.py - name: Verify release-style version injection run: | # The binary MUST be named cb: argv[0] dispatch treats any other diff --git a/README.md b/README.md index 8f18bb2..57ffc34 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,8 @@ directory within the state volume through the selected locked profile; it does not search a project venv, the host `PATH`, uv's store, or other container directories. Each discovered executable must also resolve inside the managed pipx state volume, so an external link left by a failed install is rejected. +Discovery takes a shared lock on the same volume-local lock used exclusively by +pipx commands, so it never scans a partially updated application store. Plain `pip` remains for project dependencies, so scripts in `/venv/bin` are deliberately not eligible for global exposure. @@ -580,11 +582,14 @@ expose a binary installed under the Node 22 runtime, use `cb expose ruby `. Managed-store discovery uses the already-locked local source image with pulls -and networking disabled, a read-only container root, an explicit shell -entrypoint, and read-only mounts for the selected store and any required -companion volume. The source image must provide a POSIX-compatible `sh`; -distroless images without one cannot use automatic discovery. Discovery never -mutates package-manager state. +and networking disabled, a read-only container root, and read-only mounts for +the selected store and any required companion volume. It uses an explicit shell +entrypoint except for pipx, whose locked Python image runs the embedded +lock-aware scanner directly. The pipx wrapper creates its reserved lock before +the first state mutation; discovery reports no applications if that lock does +not exist yet, otherwise it scans under a shared lock. No discovery path mutates +package-manager state. Other source images must provide a POSIX-compatible +`sh`; distroless images without one cannot use automatic discovery. For a custom stateful profile, `cb expose --shared-file TOOL VOLUME FILE` selects one logical name from that profile's `shared_volumes` and one absolute diff --git a/docs/security-model.md b/docs/security-model.md index 0fbde72..296d197 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -62,10 +62,17 @@ readable, and dangerous to let others edit. - **Read-only exposure discovery.** `cb expose` requires the locked source image to exist locally, disables pulls and networking, uses a read-only container root and volume mounts, and overrides the image entrypoint with the discovery - shell. The source image must provide a POSIX-compatible `sh`; distroless - images without one cannot use automatic discovery. Explicit shared-file + shell or pipx's embedded Python scanner. Non-pipx source images must provide a + POSIX-compatible `sh`; distroless images without one cannot use automatic + discovery. Explicit shared-file discovery also rejects a final symlink or a parent directory that resolves outside the selected volume mount. +- **Serialized pipx exposure.** Pipx commands create and hold the volume-local + lock exclusively before mutating state. Discovery mounts the state read-only, + reports no applications when the lock does not exist yet, and otherwise holds + it shared while scanning. This prevents exposure from observing partially + updated application state without granting the discovery container write + access. - **Validated atomic writes.** Registry/lock mutations parse the complete resulting file before atomically replacing the original; backups are restored the same way and only with `--apply`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3a35fc3..b3a3d58 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -7,6 +7,7 @@ package cli import ( "archive/zip" "bytes" + _ "embed" "errors" "fmt" "io" @@ -283,6 +284,7 @@ type exposeStore struct { installHint string validateTargets bool requireLabels bool + discoveryLock string companionTargets []string companionMounts []exposeMount } @@ -308,7 +310,7 @@ func exposeStoreForMountTarget(dst string) (exposeStore, bool) { case "/cb/uv-bin": return exposeStore{kind: "uv tool", mountTarget: dst, binDirectory: dst, installHint: "uv tool install ", companionTargets: []string{"/cb/uv-tools"}}, true case "/cb/pipx": - return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install ", validateTargets: true, requireLabels: true}, true + return exposeStore{kind: "pipx", mountTarget: dst, binDirectory: dst + "/bin", installHint: "pipx install ", validateTargets: true, requireLabels: true, discoveryLock: dst + "/.cb-pipx.lock"}, true case "/root/.dotnet": return exposeStore{kind: ".NET tool", mountTarget: dst, binDirectory: dst + "/tools", installHint: "dotnet tool install --global "}, true case "/cb/ruby-gems": @@ -432,8 +434,10 @@ func discoverGlobalBins(t registry.Tool, store exposeStore) ([]exposedBin, error if err := ensureExposeStoreVolumes(t, store); err != nil { return nil, err } - script := `if [ -d "$1" ]; then for f in "$1"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; if [ -n "${2-}" ]; then newline=' -'; marked=$(readlink -f "$f"; status=$?; printf x; exit "$status") || { printf 'cannot resolve %s\n' "$f" >&2; exit 1; }; resolved=${marked%x}; resolved=${resolved%"$newline"}; printf '%s\000%s\000' "${f##*/}" "$resolved"; else printf '%s\000' "${f##*/}"; fi; done; fi` + script := `if [ -d "$1" ]; then for f in "$1"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; printf '%s\000' "${f##*/}"; done; fi` + if store.discoveryLock != "" { + script = pipxDiscoveryScript + } dockerArgs, err := exposeDiscoveryArgs(store, image, script) if err != nil { return nil, err @@ -446,6 +450,9 @@ func discoverGlobalBins(t registry.Tool, store exposeStore) ([]exposedBin, error return parseExposedBins(out, store) } +//go:embed pipx_discovery.py +var pipxDiscoveryScript string + func ensureExposeStoreVolumes(t registry.Tool, store exposeStore) error { volumes, err := exposeStoreManagedVolumes(t, store) if err != nil { @@ -535,9 +542,13 @@ func exposeDiscoveryArgs(store exposeStore, image, script string) ([]string, err } dockerArgs = append(dockerArgs, "--mount", mount) } - dockerArgs = append(dockerArgs, "--entrypoint", "sh", image, "-c", script, "cb-expose", store.binDirectory) - if store.validateTargets { - dockerArgs = append(dockerArgs, store.mountTarget) + if store.discoveryLock != "" { + dockerArgs = append(dockerArgs, + "--entrypoint", "/usr/local/bin/python3", image, "-c", script, + "discover", store.binDirectory, store.mountTarget, store.discoveryLock, + ) + } else { + dockerArgs = append(dockerArgs, "--entrypoint", "sh", image, "-c", script, "cb-expose", store.binDirectory) } return dockerArgs, nil } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ee6c576..d119be1 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -489,7 +489,8 @@ func TestPipxExposeDiscoveryMountsStateReadOnly(t *testing.T) { want := []string{ "run", "--rm", "--pull", "never", "--network", "none", "--read-only", "--mount", "type=volume,src=cb-pipx117-py313-state,dst=/cb/pipx,readonly", - "--entrypoint", "sh", "example/pipx:1", "-c", "discover-script", "cb-expose", "/cb/pipx/bin", "/cb/pipx", + "--entrypoint", "/usr/local/bin/python3", "example/pipx:1", "-c", "discover-script", + "discover", "/cb/pipx/bin", "/cb/pipx", "/cb/pipx/.cb-pipx.lock", } if !reflect.DeepEqual(args, want) { t.Fatalf("discovery args = %#v, want %#v", args, want) diff --git a/internal/cli/pipx_discovery.py b/internal/cli/pipx_discovery.py new file mode 100644 index 0000000..f419735 --- /dev/null +++ b/internal/cli/pipx_discovery.py @@ -0,0 +1,69 @@ +import fcntl +import os +import pathlib +import stat +import sys + + +def open_lock(path, flags, mode): + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags, 0o600) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise RuntimeError(f"pipx state lock is not a regular file: {path}") + return os.fdopen(descriptor, mode) + except BaseException: + os.close(descriptor) + raise + + +def discover(directory, root, lock_path, output=None): + directory = directory.resolve(strict=False) + root = root.resolve(strict=True) + if output is None: + output = sys.stdout.buffer + try: + state_lock = open_lock(lock_path, os.O_RDONLY, "rb") + except FileNotFoundError: + # The wrapper creates the lock before its first state mutation. No lock + # therefore means there is no legitimate installed state to expose; + # returning empty also closes the first-install race without writing. + return + with state_lock: + fcntl.flock(state_lock, fcntl.LOCK_SH) + if not directory.is_dir(): + return + for entry in directory.iterdir(): + try: + if not entry.is_file() or not os.access(entry, os.X_OK): + continue + resolved = entry.resolve(strict=True) + resolved.relative_to(root) + except ValueError as exc: + raise RuntimeError( + f"pipx global binary {entry.name!r} resolves outside {root}: {resolved}" + ) from exc + except OSError as exc: + raise RuntimeError( + f"cannot resolve pipx global binary {entry.name!r}" + ) from exc + output.write(os.fsencode(entry.name)) + output.write(b"\0") + output.write(os.fsencode(resolved)) + output.write(b"\0") + + +def main(argv): + try: + if len(argv) == 4 and argv[0] == "discover": + discover(*(pathlib.Path(value) for value in argv[1:])) + return 0 + except (OSError, RuntimeError) as exc: + print(exc, file=sys.stderr) + return 1 + print("invalid pipx discovery invocation", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/internal/cli/pipx_discovery_test.py b/internal/cli/pipx_discovery_test.py new file mode 100644 index 0000000..e81307b --- /dev/null +++ b/internal/cli/pipx_discovery_test.py @@ -0,0 +1,95 @@ +import fcntl +import importlib.util +import io +import os +import pathlib +import subprocess +import sys +import tempfile +import time +import unittest + + +MODULE_PATH = pathlib.Path(__file__).with_name("pipx_discovery.py") +SPEC = importlib.util.spec_from_file_location("pipx_discovery", MODULE_PATH) +DISCOVERY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(DISCOVERY) + + +class PipxDiscoveryTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.tempdir.name).resolve() + self.bin_dir = self.root / "bin" + self.bin_dir.mkdir() + self.lock_path = self.root / ".cb-pipx.lock" + self.lock_path.touch(mode=0o600) + + def tearDown(self): + self.tempdir.cleanup() + + def make_executable(self, path): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\n") + path.chmod(0o755) + return path + + def test_internal_target_is_emitted_as_name_and_resolved_path(self): + target = self.make_executable(self.root / "home" / "demo") + link = self.bin_dir / "demo" + link.symlink_to(target) + output = io.BytesIO() + DISCOVERY.discover(self.bin_dir, self.root, self.lock_path, output) + self.assertEqual( + output.getvalue(), + b"demo\0" + os.fsencode(target.resolve(strict=True)) + b"\0", + ) + + def test_external_target_fails_closed(self): + external = self.make_executable(self.root.parent / "outside-pipx") + (self.bin_dir / "evil").symlink_to(external) + with self.assertRaisesRegex(RuntimeError, "resolves outside"): + DISCOVERY.discover(self.bin_dir, self.root, self.lock_path, io.BytesIO()) + + def test_discovery_waits_for_wrapper_exclusive_lock(self): + self.make_executable(self.bin_dir / "demo") + with self.lock_path.open("rb") as state_lock: + fcntl.flock(state_lock, fcntl.LOCK_EX) + process = subprocess.Popen( + [ + sys.executable, + str(MODULE_PATH), + "discover", + str(self.bin_dir), + str(self.root), + str(self.lock_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + time.sleep(0.1) + self.assertIsNone(process.poll(), "discovery did not wait for the lock") + finally: + fcntl.flock(state_lock, fcntl.LOCK_UN) + stdout, stderr = process.communicate(timeout=5) + self.assertEqual(process.returncode, 0, stderr.decode()) + self.assertEqual(stdout, b"demo\0" + os.fsencode(self.bin_dir / "demo") + b"\0") + + def test_lock_symlink_is_rejected(self): + self.lock_path.unlink() + self.lock_path.symlink_to(self.root / "other") + with self.assertRaises(OSError): + DISCOVERY.discover(self.bin_dir, self.root, self.lock_path, io.BytesIO()) + + def test_missing_lock_reports_no_apps_without_scanning(self): + self.lock_path.unlink() + external = self.make_executable(self.root.parent / "outside-pipx") + (self.bin_dir / "evil").symlink_to(external) + output = io.BytesIO() + DISCOVERY.discover(self.bin_dir, self.root, self.lock_path, output) + self.assertEqual(output.getvalue(), b"") + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/registry/pipx_wrapper.py b/internal/registry/pipx_wrapper.py index f520d23..8f68359 100644 --- a/internal/registry/pipx_wrapper.py +++ b/internal/registry/pipx_wrapper.py @@ -3,6 +3,7 @@ import pathlib import secrets import shutil +import stat import subprocess import sys @@ -79,11 +80,24 @@ def normalize_state(root, allowed_python): temporary.unlink() +def open_state_lock(lock_path): + flags = os.O_CREAT | os.O_RDWR | os.O_APPEND + flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(lock_path, flags, 0o600) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise RuntimeError(f"pipx state lock is not a regular file: {lock_path}") + return os.fdopen(descriptor, "a+b") + except BaseException: + os.close(descriptor) + raise + + def run(argv, root, allowed_python, runner=subprocess.run): root = root.resolve(strict=True) allowed_python = allowed_python.resolve(strict=True) lock_path = root / ".cb-pipx.lock" - with lock_path.open("a+b") as state_lock: + with open_state_lock(lock_path) as state_lock: fcntl.flock(state_lock, fcntl.LOCK_EX) status = 1 try: diff --git a/internal/registry/pipx_wrapper_test.py b/internal/registry/pipx_wrapper_test.py index d3e79af..a7ab3bf 100644 --- a/internal/registry/pipx_wrapper_test.py +++ b/internal/registry/pipx_wrapper_test.py @@ -75,6 +75,12 @@ def test_known_python3_alias_becomes_regular_file(self): self.assertTrue(link.is_file()) self.assertFalse(link.is_symlink()) + def test_lock_symlink_is_rejected(self): + lock_path = self.root / ".cb-pipx.lock" + lock_path.symlink_to(self.root / "other") + with self.assertRaises(OSError): + WRAPPER.run([], self.root, self.allowed_python, lambda *a, **k: Result(0)) + if __name__ == "__main__": unittest.main()