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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b1d250..5e5f7be 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 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/.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/README.md b/README.md index 9550c6d..57ffc34 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,62 @@ 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. 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_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 +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. 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. + +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 +544,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 +564,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,16 +576,20 @@ 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 `. 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 @@ -851,10 +917,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..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`. @@ -84,10 +91,21 @@ 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 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 + 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..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" @@ -21,6 +22,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" @@ -280,6 +282,9 @@ type exposeStore struct { mountTarget string binDirectory string installHint string + validateTargets bool + requireLabels bool + discoveryLock string companionTargets []string companionMounts []exposeMount } @@ -304,6 +309,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 ", 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": @@ -335,7 +342,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] @@ -424,7 +431,13 @@ 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` + if store.discoveryLock != "" { + script = pipxDiscoveryScript + } dockerArgs, err := exposeDiscoveryArgs(store, image, script) if err != nil { return nil, err @@ -437,6 +450,80 @@ 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 { + 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) + } + 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 +} + +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 == "" { @@ -455,7 +542,14 @@ 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.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 } @@ -517,16 +611,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 2e5138f..d119be1 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,53 @@ 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", "/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) + } + 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 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" @@ -637,6 +700,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"}, @@ -798,6 +877,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 +963,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 +986,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/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/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/pipx_wrapper.py b/internal/registry/pipx_wrapper.py new file mode 100644 index 0000000..8f68359 --- /dev/null +++ b/internal/registry/pipx_wrapper.py @@ -0,0 +1,124 @@ +import fcntl +import os +import pathlib +import secrets +import shutil +import stat +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 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 open_state_lock(lock_path) 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..a7ab3bf --- /dev/null +++ b/internal/registry/pipx_wrapper_test.py @@ -0,0 +1,86 @@ +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()) + + 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() diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 1b43da3..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,7 +56,10 @@ type Registry struct { // startup is not subsequently reported as unsupported by cb doctor. const MaxSchemaVersion = 2 -const DefaultTOML = `# container-bin tool registry +//go:embed pipx_wrapper.py +var pipxCommand string + +var DefaultTOML = `# container-bin tool registry schema_version = 2 # Generated by cb. Edit this file, then run: cb install # @@ -288,6 +292,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{"/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"] +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 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. + [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..87e16eb 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,85 @@ 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) != 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{ + `fcntl.flock(state_lock, fcntl.LOCK_EX)`, + `status = runner(`, + `except KeyboardInterrupt:`, + "finally:\n normalize_state(root, allowed_python)", + `raw_target = os.readlink(link)`, + `except FileNotFoundError:`, + `except OSError as 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)`, + `return 128 - status`, + `raise SystemExit(`, + } { + if !strings.Contains(command, fragment) { + t.Fatalf("pipx command missing %q: %q", fragment, command) + } + } + 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) { + 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=/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) + } + } + 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", "PIPX_VENV_CACHEDIR", "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 +1238,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)) } }