From 4a286f100179f7df438e9c0c631991b332b3e7c3 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Tue, 28 Jul 2026 23:43:14 +0000 Subject: [PATCH] Sync Pitot from Intelligence Flow @ 107c94141e23 --- .goreleaser.yml | 79 +++++++++ README.md | 38 +++- UPSTREAM.json | 17 +- cmd/pitot/install.go | 173 ++++++++++++++++++ cmd/pitot/main.go | 16 ++ cmd/pitot/shim.go | 264 ++++++++++++++++++++++++++++ cmd/pitot/substrate_test.go | 341 ++++++++++++++++++++++++++++++++++++ cmd/pitot/upgrade.go | 191 ++++++++++++++++++++ cmd/pitot/version.go | 60 +++++++ cmd/pitot/workbench.go | 16 +- hydrate/doc.go | 31 ++++ hydrate/hydrate.go | 327 ++++++++++++++++++++++++++++++++++ hydrate/hydrate_test.go | 275 +++++++++++++++++++++++++++++ 13 files changed, 1819 insertions(+), 9 deletions(-) create mode 100644 .goreleaser.yml create mode 100644 cmd/pitot/install.go create mode 100644 cmd/pitot/shim.go create mode 100644 cmd/pitot/substrate_test.go create mode 100644 cmd/pitot/upgrade.go create mode 100644 cmd/pitot/version.go create mode 100644 hydrate/doc.go create mode 100644 hydrate/hydrate.go create mode 100644 hydrate/hydrate_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..427a4cf --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,79 @@ +# GoReleaser builds the prebuilt Pitot binaries for each release tag. Pitot is +# pure Go / CGO-free, so cross-compiling the five supported targets is a plain +# matrix build. The release workflow (works-rendered release.yml installed in +# the public repo) tags a version, runs `goreleaser release --clean`, attests +# provenance, then uploads the archives + checksums.txt to the GCP Artifact +# Registry generic `dist` repo fronted by get.operatorstack.systems — which is +# exactly where the repo shim (.pitot/bin/pitot) and `pitot upgrade` hydrate +# from. +# +# Local check: `goreleaser check` (config only) or `goreleaser release +# --snapshot --clean` (full dry run into ./dist, no publish). +version: 2 + +project_name: pitot + +builds: + - id: pitot + main: ./cmd/pitot + binary: pitot + env: + - CGO_ENABLED=0 + flags: + - -trimpath + # Version identity is stamped into package main (see cmd/pitot/version.go), + # matching GoReleaser's default variable names. + ldflags: + - -s -w -X main.version={{ .Version }} -X main.commit={{ .ShortCommit }} -X main.date={{ .CommitDate }} + mod_timestamp: "{{ .CommitTimestamp }}" + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + # Five supported targets: darwin arm64/amd64, linux amd64/arm64, windows amd64. + ignore: + - goos: windows + goarch: arm64 + +archives: + - id: pitot + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + # The public repo carries README.md at the root (projected from + # public-readme-preview). No LICENSE file exists yet — add it here when one + # lands. + files: + - README.md + +checksum: + name_template: checksums.txt + algorithm: sha256 + +# One CycloneDX SBOM per archive, generated by syft (installed by the workflow). +sboms: + - id: archive + artifacts: archive + +# Binaries are published to GCP Artifact Registry (the generic `dist` repo) by +# the release workflow (gcloud artifacts generic upload), fronted by +# get.operatorstack.systems. GoReleaser only builds + checksums + SBOMs; it +# does NOT create a GitHub Release. +release: + disable: true + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/README.md b/README.md index b1b2f81..9f76fb7 100644 --- a/README.md +++ b/README.md @@ -187,13 +187,47 @@ single-response rule before carrying the answer back. Pitot does not know what ## Install -Download a release binary for macOS, Linux, or Windows, or install from source: +**Nobody installs Pitot per machine — the repository pins it.** `pitot init` +writes two substrate files alongside your tenant fragments: + +- `.pitot/version` — one committed semver line, the only version authority +- `.pitot/bin/pitot` (+ `pitot.ps1`) — a committed shim that reads the pin, + hydrates exactly that release into a per-user cache + (`~/.cache/pitot//`, sha256-verified against the published + `checksums.txt`), and execs it + +Fresh clones, CI, and cloud agents run `.pitot/bin/pitot` with zero setup — +the first invocation hydrates, every later one is a cache hit. There is no +fallback to whatever binary happens to be on PATH; a missing release with no +network fails closed with a named error, and `PITOT_NO_HYDRATE=1` makes +hydration cache-only. + +**Upgrades are a reviewed diff.** `pitot upgrade` verifies the new release, +re-checks every tenant fragment against it, and rewrites the one pin line — +nothing else. Commit that diff and every clone hydrates the new version on +its next invocation; roll back by reverting it. + +```bash +pitot upgrade --check # report pinned vs latest +pitot upgrade # hydrate, validate tenants, rewrite .pitot/version +``` + +For a global CLI convenience (running `pitot init` in new repos), grab a +release binary or build from source: ```bash go install github.com/operatorstack/pitot/cmd/pitot@latest ``` -Inspect the effective local boundary at any time: +Typed SDKs install from our own registry through the distribution front door +— never public npm/PyPI, pinned to the CLI's version: + +```bash +pitot install typescript # scoped .npmrc + @operatorstack/pitot@ +pitot install python # .pitot/registry + operatorstack-pitot== +``` + +Inspect the effective local boundary, pin, and cache state at any time: ```bash pitot doctor diff --git a/UPSTREAM.json b/UPSTREAM.json index 25764ca..dc7c1f5 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,8 @@ { "files": { + ".goreleaser.yml": "d4ba852e8c1b8801a562c048b7200ffd9ff74386f72f125f297096846c233bc5", "CONTRIBUTING.md": "23728d8a132d62b8adfb2e5c3eb9d9bfcf8a4d04543765b1e22ad8d55424af8f", - "README.md": "e4a7587107f7db1eb75859f62f6abfb780c99e2b210c808dda3aaa25612eb172", + "README.md": "74c442eb9d40e3c50e2781166798244698c929dc8b4d7a0257f269040a697233", "adapter-verification.json": "f8ad4e206571650f698826a8b66d8c00822be425e8d2de8ae98d98239e575eb4", "adapters/adapters.go": "1b46ba131fa3b2c93eed23526330275a3506451ba4bbd4f497e5378dfab2b6a8", "assets/pitot-boundary.png": "8a0ddb7d81831d94e14813f50ea4ca8670d77417f339ed2f91f0c653bf52f41d", @@ -16,11 +17,16 @@ "cmd/generate-schema/main.go": "6e9d0030290d99e36967433f96e38385a122974f899ad9421aac1ef7e50d8fcb", "cmd/pitot/doctor_host.go": "7ecade40618bfb3510ae8e55fa802361371b4f7fbafedcd61233d19ef46cb219", "cmd/pitot/doctor_host_test.go": "4e6e327f6cf27cf94a0a608e10eb6790d6c11fcd53e6dfd7370007190749952f", + "cmd/pitot/install.go": "016829971a80544bded2231bc9d08f7d2d9abb2be57299b88b035005061d53d3", "cmd/pitot/kimi_control_test.go": "27b38867d4799636a664e3b1726ef55568f5dfd6a9be11f0ce5eca9931d759a4", "cmd/pitot/kimi_smoke_test.go": "01cbf18312902cac42ec1f2547d35362c2bc920938acea508ba7c7f0638a9473", - "cmd/pitot/main.go": "9baae94a571c20b7c2d4e11985026ebaa8fe256372ce70c3198a73cc82d8390b", + "cmd/pitot/main.go": "69d1571a56ae1c024fcd697bb406c3aac0f4774d62b41f3444a949ffe6f4c4d4", "cmd/pitot/main_test.go": "b381fe30dafe3299c82fe23e1899b64f79c4a2b27059b78823179625e3b6679e", - "cmd/pitot/workbench.go": "976c81951c565da1a897637566f662c493b2fe6d279debe852dc783b6fac1db4", + "cmd/pitot/shim.go": "a2a605b3126d4be7d3e2a587f09259a5f6e808c3a132480a760661cf34c1796f", + "cmd/pitot/substrate_test.go": "016c2fec0d61ffb725efa24fc177c6baaa71c69163fc88dbd914cc615275c03b", + "cmd/pitot/upgrade.go": "3f7adf156b04f9bac4847ed66c5fa69dc0906f482cb1362f60cbf3416a5733fc", + "cmd/pitot/version.go": "60ddbabcc8985c5137d5b73e3ba1be0f62fbb9db2a6612027e08314dfd82d618", + "cmd/pitot/workbench.go": "afbba5557974c1a4fb28ed2097ec8bea4d9bb1fa97cda931c9ebf52daec24915", "cmd/pitot/workbench_build_test.go": "8d5c5c35e8cbd59e21cfdd7e206d6b5b769892ab27a99fe5d87b13b1a31d0714", "cmd/pitot/workbench_contract_test.go": "5e465f3d3f8b93ffaacfb4738279d369b13514bf7613b8611952f3cd26896586", "cmd/pitot/workbench_dev_test.go": "abffe81e1a25f086d7f3c2f1c32986ee93618bbfc87220bdfda840ec6c6466bf", @@ -40,6 +46,9 @@ "examples/token-meter/main.go": "4b1b9c1a43c3cf48b09dba6f607776caced9d2b5b562373496b31ed184582dd1", "go.mod": "5b5f818251bbfb9dc3c066b70c3c33512ed18486d5713ba60e7f3141e23139fe", "go.sum": "6e1aefcb6cb7d180f4b9f3eec64b547945cbebf660764b2bc3818e69bb7c8827", + "hydrate/doc.go": "bd350bf37c6357a1d2880ec330f3a5680fd71be682c1f6bbfa5f0889fe07acf5", + "hydrate/hydrate.go": "2d406af04c18c4504c39b4792d396d37cbfead12051f29ebf78f737a52f21728", + "hydrate/hydrate_test.go": "a055ef69c8560db75a47b1d1a8b6c703f6caccfa967199140a37ac8894a9522a", "integrations/codex/PreToolUse.ps1": "a032bdefcaa6bdabc240ea5a91b19caa9804e976697ae67cbbb9c3a407fa4450", "integrations/copilot/PreToolUse": "1a487ca7dfaaa21cbc7db5cec0091fed8ce9337bc116b9a6dec0a109ac5802a5", "integrations/copilot/PreToolUse.ps1": "da5d113b9fd9441e8646414c3a1eb4754b415f40db7d06cbce111b123cdb2c88", @@ -121,7 +130,7 @@ "generator": "operatorstack/pitot:project", "schema_version": 1, "source": { - "commit": "4cd27ab7a10e85184157f71249708254fbf7c208", + "commit": "107c94141e23b7df3449cfa6e7b12a6d45052563", "path": "labs/15-pitot", "repository": "operatorstack/intelligence-flow" } diff --git a/cmd/pitot/install.go b/cmd/pitot/install.go new file mode 100644 index 0000000..abc29ad --- /dev/null +++ b/cmd/pitot/install.go @@ -0,0 +1,173 @@ +// Install wires a project to the typed Pitot SDKs from our own registry, +// through the distribution front door — never public npm/PyPI +// (packages-come-from-our-registry-or-nowhere). Bindings are pinned to the +// CLI's own release version (bindings-move-in-lockstep-with-the-CLI). +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/operatorstack/pitot/hydrate" +) + +const ( + npmScope = "@operatorstack" + npmClientPkg = "@operatorstack/pitot" + pyClientPkg = "operatorstack-pitot" + pyRegistryRef = ".pitot/registry" +) + +func runInstall(args []string, stdout, stderr io.Writer) error { + if len(args) == 0 { + return errors.New("pitot install: requires a language (typescript, python)") + } + lang := args[0] + configureOnly := false + revert := false + for i := 1; i < len(args); i++ { + switch args[i] { + case "--configure-only": + configureOnly = true + case "--revert": + revert = true + case "--host": + if i+1 >= len(args) { + return errors.New("pitot install: --host requires a value") + } + os.Setenv(hydrate.EnvHost, args[i+1]) + i++ + default: + return fmt.Errorf("pitot install: unexpected argument %q", args[i]) + } + } + + switch lang { + case "typescript": + if revert { + return revertNPM(stdout) + } + return installNPM(stdout, configureOnly) + case "python": + if revert { + return revertPython(stdout) + } + return installPython(stdout, configureOnly) + default: + return fmt.Errorf("pitot install: unsupported language %q (want typescript, python)", lang) + } +} + +// npmRegistryLine is the single scoped line install owns in .npmrc: only the +// @operatorstack scope resolves through the front door; everything else stays +// on public registries. +func npmRegistryLine() string { + return npmScope + ":registry=" + hydrate.BaseURL() + "/npm/" +} + +func installNPM(stdout io.Writer, configureOnly bool) error { + if err := upsertNPMRC(npmRegistryLine()); err != nil { + return err + } + fmt.Fprintf(stdout, "configured .npmrc: %s\n", npmRegistryLine()) + spec := npmClientPkg + versionSpec("@") + if configureOnly { + fmt.Fprintf(stdout, "next: npm install %s\n", spec) + return nil + } + return runTool(stdout, "npm", "install", spec) +} + +func installPython(stdout io.Writer, configureOnly bool) error { + index := hydrate.BaseURL() + "/pip/simple/" + if err := os.MkdirAll(filepath.Dir(pyRegistryRef), 0o755); err != nil { + return err + } + if err := os.WriteFile(pyRegistryRef, []byte("PIP_INDEX_URL="+index+"\n"), 0o644); err != nil { + return err + } + fmt.Fprintf(stdout, "configured %s: PIP_INDEX_URL=%s\n", pyRegistryRef, index) + spec := pyClientPkg + versionSpec("==") + if configureOnly { + fmt.Fprintf(stdout, "next: pip install --index-url %s %s\n", index, spec) + return nil + } + if _, err := exec.LookPath("uv"); err == nil { + return runTool(stdout, "uv", "pip", "install", "--index-url", index, spec) + } + return runTool(stdout, "pip", "install", "--index-url", index, spec) +} + +// versionSpec pins the binding to the CLI's own release version; a dev build +// cannot vouch for a binding version and installs unpinned with a warning. +func versionSpec(separator string) string { + if v := releaseVersion(); v != "dev" { + return separator + v + } + return "" +} + +func upsertNPMRC(line string) error { + existing, err := os.ReadFile(".npmrc") + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + var kept []string + for _, l := range strings.Split(string(existing), "\n") { + if l == "" || strings.HasPrefix(l, npmScope+":registry=") { + continue + } + kept = append(kept, l) + } + kept = append(kept, line) + return os.WriteFile(".npmrc", []byte(strings.Join(kept, "\n")+"\n"), 0o644) +} + +func revertNPM(stdout io.Writer) error { + existing, err := os.ReadFile(".npmrc") + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var kept []string + for _, l := range strings.Split(strings.TrimRight(string(existing), "\n"), "\n") { + if strings.HasPrefix(l, npmScope+":registry=") { + continue + } + kept = append(kept, l) + } + if len(kept) == 0 { + fmt.Fprintln(stdout, "removed .npmrc") + return os.Remove(".npmrc") + } + fmt.Fprintln(stdout, "removed the "+npmScope+" registry line from .npmrc") + return os.WriteFile(".npmrc", []byte(strings.Join(kept, "\n")+"\n"), 0o644) +} + +func revertPython(stdout io.Writer) error { + if err := os.Remove(pyRegistryRef); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + fmt.Fprintln(stdout, "removed "+pyRegistryRef) + return nil +} + +func runTool(stdout io.Writer, name string, args ...string) error { + command := exec.Command(name, args...) + command.Stdout = stdout + command.Stderr = stdout + if releaseVersion() == "dev" { + fmt.Fprintln(stdout, "warning: dev build — installing the binding unpinned; a released pitot pins bindings to its own version") + } + if err := command.Run(); err != nil { + return fmt.Errorf("pitot install: %s %s: %w", name, strings.Join(args, " "), err) + } + return nil +} diff --git a/cmd/pitot/main.go b/cmd/pitot/main.go index c57b3e9..f20c8a6 100644 --- a/cmd/pitot/main.go +++ b/cmd/pitot/main.go @@ -53,6 +53,12 @@ func runWithIO(ctx context.Context, args []string, stdin io.Reader, stdout, stde return runHook(ctx, args[1:], stdin, stdout, stderr) case "request": return runRequest(ctx, args[1:], stdout) + case "version", "--version", "-v": + return runVersion(stdout) + case "upgrade": + return runUpgrade(ctx, args[1:], stdout, stderr) + case "install": + return runInstall(args[1:], stdout, stderr) case "-h", "--help", "help": fmt.Fprint(stdout, usage()) return nil @@ -215,9 +221,11 @@ func doctor(args []string, stdout, stderr io.Writer) error { } fmt.Fprintf(stdout, "Pitot %s — local boundary\n", schema.Version) + fmt.Fprintf(stdout, "binary version: %s (%s)\n", releaseVersion(), buildCommit()) fmt.Fprintf(stdout, "adapter version: %s\n", adapters.AdapterVersion) fmt.Fprintln(stdout, "unauthenticated local socket: none") fmt.Fprintln(stdout, "runtime capabilities: hook_control consumer_delivery explicit_request") + printHydrationStatus(stdout) fmt.Fprintln(stdout, "hosts:") for _, host := range adapters.Supported() { probe, err := adapters.CanonicalHookEvent(host) @@ -312,10 +320,18 @@ usage: pitot run [--config PATH] --runtime PATH pitot hook HOST [--runtime PATH] pitot request KIND [--data JSON] --runtime PATH + pitot version + pitot upgrade [--to X.Y.Z] [--check] [--host HOST] + pitot install typescript|python [--configure-only] [--revert] [--host HOST] configuration is tenant-partitioned: each tool or user registers its processes in its own fragment under .pitot/conf.d/; the runtime merges every fragment and rejects collisions. --config PATH overrides discovery with one explicit file. + +the binary is repo-pinned: .pitot/version names the exact release and the +committed shim .pitot/bin/pitot hydrates it on demand (sha256-verified, cached +per user). upgrade rewrites the pin only — commit that diff to upgrade every +clone. install wires typed SDKs from our registry through the front door. ` } diff --git a/cmd/pitot/shim.go b/cmd/pitot/shim.go new file mode 100644 index 0000000..319df5d --- /dev/null +++ b/cmd/pitot/shim.go @@ -0,0 +1,264 @@ +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/operatorstack/pitot/hydrate" +) + +// shimDir holds the committed repo shims that make the pitot binary a +// repository property: they read .pitot/version, hydrate the pinned release +// into the user cache when absent, and exec it. No PATH binary is ever +// consulted. +const shimDir = ".pitot/bin" + +// shimPOSIX is the committed POSIX shim. It carries no policy +// (the-shim-carries-no-policy): pin read, fetch, verify, exec — it never +// reads tenant fragments and never resolves a version the pin does not name +// (pin-is-the-only-version-authority). +const shimPOSIX = `#!/bin/sh +# Pitot repo shim — generated by 'pitot init'. Reads the committed pin +# (.pitot/version), hydrates that exact release into the user cache when +# absent (sha256-verified), and execs it. Upgrades rewrite the pin in a +# reviewed diff; this file never changes behavior on its own. +set -eu + +here="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +pin_file="$here/.pitot/version" +if [ ! -f "$pin_file" ]; then + echo "pitot-shim: missing $pin_file (absence-fails-closed-and-named)" >&2 + exit 1 +fi +version="$(tr -d ' \t\r\n' < "$pin_file")" +case "$version" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) echo "pitot-shim: pin must be one semver line, got '$version'" >&2; exit 1 ;; +esac + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$os" in + linux|darwin) ;; + *) echo "pitot-shim: unsupported OS $os" >&2; exit 1 ;; +esac +arch="$(uname -m)" +case "$arch" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "pitot-shim: unsupported arch $arch" >&2; exit 1 ;; +esac + +cache_root="${PITOT_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/pitot}" +slot_dir="$cache_root/$version/$os-$arch" +slot="$slot_dir/pitot" + +if [ ! -x "$slot" ]; then + no_hydrate="${PITOT_NO_HYDRATE:-0}" + if [ -n "$no_hydrate" ] && [ "$no_hydrate" != "0" ]; then + echo "pitot-shim: version $version is pinned by .pitot/version but $slot is absent and hydration is disabled (PITOT_NO_HYDRATE=1) (absence-fails-closed-and-named)" >&2 + exit 1 + fi + host="${PITOT_GET_HOST:-get.operatorstack.systems}" + case "$host" in + http://*|https://*) base="$host" ;; + *) base="https://$host" ;; + esac + archive="pitot_${version}_${os}_${arch}.tar.gz" + mkdir -p "$slot_dir" + lock="$slot_dir/.hydrate-lock" + tries=0 + while ! mkdir "$lock" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 200 ]; then + echo "pitot-shim: hydration lock $lock held past deadline; remove it if the holder crashed" >&2 + exit 1 + fi + sleep 0.15 + if [ -x "$slot" ]; then + break + fi + done + if [ ! -x "$slot" ]; then + tmp="$(mktemp -d "$slot_dir/.hydrating-XXXXXX")" + cleanup() { rm -rf "$tmp"; rmdir "$lock" 2>/dev/null || true; } + trap cleanup EXIT + if ! curl -fsSL "$base/pitot/dl/$version/$archive" -o "$tmp/$archive"; then + echo "pitot-shim: version $version is pinned by .pitot/version but $slot is absent and the download from $base failed (absence-fails-closed-and-named)" >&2 + exit 1 + fi + if ! curl -fsSL "$base/pitot/dl/$version/checksums.txt" -o "$tmp/checksums.txt"; then + echo "pitot-shim: checksums.txt download failed (nothing-executes-unverified)" >&2 + exit 1 + fi + want="$(awk -v f="$archive" '$2 == f { print $1 }' "$tmp/checksums.txt")" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$tmp/$archive" | awk '{print $1}')" + else + got="$(shasum -a 256 "$tmp/$archive" | awk '{print $1}')" + fi + if [ -z "$want" ] || [ "$got" != "$want" ]; then + echo "pitot-shim: checksum mismatch for $archive — refusing to install (nothing-executes-unverified)" >&2 + exit 1 + fi + tar -xzf "$tmp/$archive" -C "$tmp" + found="$(find "$tmp" -type f -name pitot | head -n 1)" + if [ -z "$found" ]; then + echo "pitot-shim: release archive carries no pitot binary" >&2 + exit 1 + fi + chmod 0755 "$found" + mv "$found" "$slot" + rm -rf "$tmp" + rmdir "$lock" 2>/dev/null || true + trap - EXIT + else + rmdir "$lock" 2>/dev/null || true + fi +fi + +# Text-file-busy can surface briefly when a concurrent hydration just wrote +# the slot; retry a few times before giving up. +tries=0 +while :; do + "$slot" "$@" + code=$? + if [ "$code" -ne 126 ] || [ "$tries" -ge 30 ]; then + exit "$code" + fi + tries=$((tries + 1)) + sleep 0.1 +done +` + +// shimPowerShell is the committed Windows shim (same contract, PowerShell +// dialect; zip archives). +const shimPowerShell = `# Pitot repo shim — generated by 'pitot init'. Reads the committed pin +# (.pitot/version), hydrates that exact release into the user cache when +# absent (sha256-verified), and execs it. Upgrades rewrite the pin in a +# reviewed diff; this file never changes behavior on its own. +$ErrorActionPreference = "Stop" + +$here = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$pinFile = Join-Path $here ".pitot\version" +if (-not (Test-Path $pinFile)) { + Write-Error "pitot-shim: missing .pitot/version (absence-fails-closed-and-named)" + exit 1 +} +$version = (Get-Content $pinFile -Raw).Trim() +if ($version -notmatch '^\d+\.\d+\.\d+$') { + Write-Error "pitot-shim: pin must be one semver line, got '$version'" + exit 1 +} + +$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq "Arm64") { "arm64" } else { "amd64" } +$cacheRoot = if ($env:PITOT_CACHE_DIR) { $env:PITOT_CACHE_DIR } else { Join-Path $env:LOCALAPPDATA "pitot" } +$slotDir = Join-Path $cacheRoot "$version\windows-$arch" +$slot = Join-Path $slotDir "pitot.exe" + +if (-not (Test-Path $slot)) { + if ($env:PITOT_NO_HYDRATE -and $env:PITOT_NO_HYDRATE -ne "0") { + Write-Error "pitot-shim: version $version is pinned by .pitot/version but $slot is absent and hydration is disabled (PITOT_NO_HYDRATE=1) (absence-fails-closed-and-named)" + exit 1 + } + $hostName = if ($env:PITOT_GET_HOST) { $env:PITOT_GET_HOST } else { "get.operatorstack.systems" } + $base = if ($hostName -match '^https?://') { $hostName } else { "https://$hostName" } + $archive = "pitot_${version}_windows_${arch}.zip" + New-Item -ItemType Directory -Force -Path $slotDir | Out-Null + $tmp = Join-Path $slotDir (".hydrating-" + [System.IO.Path]::GetRandomFileName()) + New-Item -ItemType Directory -Path $tmp | Out-Null + try { + Invoke-WebRequest -Uri "$base/pitot/dl/$version/$archive" -OutFile (Join-Path $tmp $archive) + Invoke-WebRequest -Uri "$base/pitot/dl/$version/checksums.txt" -OutFile (Join-Path $tmp "checksums.txt") + $want = (Select-String -Path (Join-Path $tmp "checksums.txt") -Pattern ([regex]::Escape($archive))).Line.Split(" ")[0] + $got = (Get-FileHash -Algorithm SHA256 (Join-Path $tmp $archive)).Hash.ToLower() + if (-not $want -or $got -ne $want.ToLower()) { + Write-Error "pitot-shim: checksum mismatch for $archive — refusing to install (nothing-executes-unverified)" + exit 1 + } + Expand-Archive -Path (Join-Path $tmp $archive) -DestinationPath $tmp + $found = Get-ChildItem -Path $tmp -Recurse -Filter "pitot.exe" | Select-Object -First 1 + if (-not $found) { + Write-Error "pitot-shim: release archive carries no pitot.exe" + exit 1 + } + Move-Item -Path $found.FullName -Destination $slot + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} + +& $slot @args +exit $LASTEXITCODE +` + +// printHydrationStatus renders the doctor's view of the binary substrate: +// pin, slot, shim, and pin-vs-running drift. +func printHydrationStatus(stdout io.Writer) { + fmt.Fprintln(stdout, "hydration:") + pin, err := hydrate.Pin(".") + if err != nil { + fmt.Fprintf(stdout, " pin: none (%s absent or invalid)\n", hydrate.PinPath) + return + } + fmt.Fprintf(stdout, " pin: %s (%s)\n", pin, hydrate.PinPath) + if slot, slotErr := hydrate.Slot(pin); slotErr == nil { + if _, statErr := os.Stat(slot); statErr == nil { + fmt.Fprintf(stdout, " slot: present (%s)\n", slot) + } else { + fmt.Fprintf(stdout, " slot: absent — the next shim invocation hydrates %s\n", pin) + } + } + if _, err := os.Stat(filepath.Join(filepath.FromSlash(shimDir), "pitot")); err == nil { + fmt.Fprintf(stdout, " shim: present (%s/pitot)\n", shimDir) + } else { + fmt.Fprintf(stdout, " shim: absent — run 'pitot init' to write %s/pitot\n", shimDir) + } + if running := releaseVersion(); running != pin { + fmt.Fprintf(stdout, " drift: running binary is %s but the pin is %s — the shim would run %s\n", running, pin, pin) + } +} + +// writeSubstrate ensures the repository substrate exists: the version pin +// (when the running binary carries a release identity) and the committed +// shims. Existing files are left untouched — the substrate is ensured by +// whoever arrives first, owned by no one. +func writeSubstrate(stdout io.Writer, binaryVersion string) ([]string, error) { + var written []string + + pinPath := filepath.FromSlash(hydrate.PinPath) + if _, err := os.Stat(pinPath); os.IsNotExist(err) { + if binaryVersion == "dev" || binaryVersion == "" { + fmt.Fprintf(stdout, "note: no %s written — this is an unreleased pitot build; pin a released version yourself to enable the shim\n", hydrate.PinPath) + } else { + if err := hydrate.WritePin(".", binaryVersion); err != nil { + return nil, err + } + written = append(written, hydrate.PinPath) + } + } + + shims := map[string]string{ + filepath.Join(shimDir, "pitot"): shimPOSIX, + filepath.Join(shimDir, "pitot.ps1"): shimPowerShell, + } + if err := os.MkdirAll(filepath.FromSlash(shimDir), 0o755); err != nil { + return nil, fmt.Errorf("pitot init: create %s: %w", shimDir, err) + } + for path, body := range shims { + if _, err := os.Stat(path); err == nil { + continue + } + mode := os.FileMode(0o644) + if !strings.HasSuffix(path, ".ps1") { + mode = 0o755 + } + if err := os.WriteFile(path, []byte(body), mode); err != nil { + return nil, fmt.Errorf("pitot init: write %s: %w", path, err) + } + written = append(written, filepath.ToSlash(path)) + } + return written, nil +} diff --git a/cmd/pitot/substrate_test.go b/cmd/pitot/substrate_test.go new file mode 100644 index 0000000..5c9b82f --- /dev/null +++ b/cmd/pitot/substrate_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "testing" + + "github.com/operatorstack/pitot/hydrate" +) + +// stubRelease serves a fake front door whose release binary is a POSIX shell +// script (so hydrated "binaries" are runnable in tests without a compiler). +func stubRelease(t *testing.T, version, script string) (base string, shutdown func()) { + t.Helper() + archiveName := fmt.Sprintf("pitot_%s_%s_%s.tar.gz", version, goruntime.GOOS, goruntime.GOARCH) + inner := fmt.Sprintf("pitot_%s_%s_%s/pitot", version, goruntime.GOOS, goruntime.GOARCH) + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + archive := tar.NewWriter(gz) + if err := archive.WriteHeader(&tar.Header{Name: inner, Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := archive.Write([]byte(script)); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + payload := buf.Bytes() + digest := sha256.Sum256(payload) + files := map[string][]byte{ + version + "/" + archiveName: payload, + version + "/checksums.txt": []byte(hex.EncodeToString(digest[:]) + " " + archiveName + "\n"), + } + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/pitot/latest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"version":%q}`, version) + }) + mux.HandleFunc("/pitot/dl/", func(w http.ResponseWriter, r *http.Request) { + payload, ok := files[strings.TrimPrefix(r.URL.Path, "/pitot/dl/")] + if !ok { + http.NotFound(w, r) + return + } + w.Write(payload) + }) + server := &http.Server{Handler: mux} + go server.Serve(listener) + t.Cleanup(func() { server.Close() }) + return "http://" + listener.Addr().String(), func() { server.Close() } +} + +// stubBinaryScript is the hydrated stand-in binary: answers `version` with a +// parseable protocol line and echoes everything else. +func stubBinaryScript(protocol string) string { + return fmt.Sprintf("#!/bin/sh\nif [ \"${1:-}\" = version ]; then\n echo \" protocol version : %s\"\n exit 0\nfi\necho \"stub-run $*\"\n", protocol) +} + +// control-law: the-shim-carries-no-policy +// +// The emitted shims read the pin, fetch, verify, and exec — they carry no +// reference to tenant config or any version channel, and the substrate is +// idempotent (ensured by whoever arrives first, never rewritten). +func TestShimCarriesNoPolicy(t *testing.T) { + t.Chdir(t.TempDir()) + var out bytes.Buffer + written, err := writeSubstrate(&out, "1.2.3") + if err != nil { + t.Fatal(err) + } + if len(written) != 3 { + t.Fatalf("expected pin + 2 shims written, got %v", written) + } + pin, err := hydrate.Pin(".") + if err != nil || pin != "1.2.3" { + t.Fatalf("pin: %q %v", pin, err) + } + for _, shim := range []string{".pitot/bin/pitot", ".pitot/bin/pitot.ps1"} { + body, err := os.ReadFile(filepath.FromSlash(shim)) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"conf.d", "/latest", "controllers", "consumers"} { + if strings.Contains(string(body), forbidden) { + t.Errorf("%s must carry no policy surface, found %q", shim, forbidden) + } + } + for _, required := range []string{".pitot/version", "checksum", "nothing-executes-unverified", "absence-fails-closed-and-named"} { + if !strings.Contains(string(body), required) { + t.Errorf("%s must reference %q", shim, required) + } + } + } + // Idempotent: a second ensure writes nothing and rewrites nothing. + before, _ := os.ReadFile(filepath.FromSlash(".pitot/bin/pitot")) + again, err := writeSubstrate(&out, "9.9.9") + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Fatalf("substrate must be write-once per repo, rewrote %v", again) + } + if pin, _ := hydrate.Pin("."); pin != "1.2.3" { + t.Fatalf("existing pin must never be overwritten, got %q", pin) + } + after, _ := os.ReadFile(filepath.FromSlash(".pitot/bin/pitot")) + if !bytes.Equal(before, after) { + t.Fatal("existing shim was rewritten") + } + + // A dev build cannot vouch for a version: no pin, guidance instead. + t.Chdir(t.TempDir()) + out.Reset() + if _, err := writeSubstrate(&out, "dev"); err != nil { + t.Fatal(err) + } + if _, err := hydrate.Pin("."); err == nil { + t.Fatal("dev build must not write a pin") + } + if !strings.Contains(out.String(), "unreleased pitot build") { + t.Fatalf("dev build must print pin guidance, got: %s", out.String()) + } +} + +// control-law: pin-is-the-only-version-authority +// control-law: absence-fails-closed-and-named +// +// The POSIX shim end to end: first invocation hydrates the pinned release +// from the front door (sha256-verified), later invocations are pure cache +// hits that survive the front door disappearing, and the kill switch with an +// empty cache fails closed by name. +func TestShimHydratesPinnedReleaseThenCacheHits(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("the POSIX shim smoke needs sh, curl, and tar") + } + base, shutdown := stubRelease(t, "1.2.3", stubBinaryScript("1")) + t.Chdir(t.TempDir()) + cache := t.TempDir() + if _, err := writeSubstrate(&bytes.Buffer{}, "1.2.3"); err != nil { + t.Fatal(err) + } + + run := func() (string, error) { + command := exec.Command("sh", filepath.FromSlash(".pitot/bin/pitot"), "hello", "world") + command.Env = append(os.Environ(), "PITOT_GET_HOST="+base, "PITOT_CACHE_DIR="+cache, "PITOT_NO_HYDRATE=") + output, err := command.CombinedOutput() + return string(output), err + } + first, err := run() + if err != nil || !strings.Contains(first, "stub-run hello world") { + t.Fatalf("first shim run: %v\n%s", err, first) + } + shutdown() // front door gone: the cache must carry everything + second, err := run() + if err != nil || !strings.Contains(second, "stub-run hello world") { + t.Fatalf("cache-hit shim run with the front door down: %v\n%s", err, second) + } + + // Kill switch + empty cache: fail closed, by name, nonzero exit. + command := exec.Command("sh", filepath.FromSlash(".pitot/bin/pitot")) + command.Env = append(os.Environ(), "PITOT_GET_HOST="+base, "PITOT_CACHE_DIR="+t.TempDir(), "PITOT_NO_HYDRATE=1") + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "absence-fails-closed-and-named") { + t.Fatalf("kill switch must fail closed by name: err=%v\n%s", err, output) + } +} + +// snapshotTree hashes every file under root (relative path -> sha256). +func snapshotTree(t *testing.T, root string) map[string]string { + t.Helper() + tree := map[string]string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + digest := sha256.Sum256(raw) + rel, _ := filepath.Rel(root, path) + tree[filepath.ToSlash(rel)] = hex.EncodeToString(digest[:]) + return nil + }) + if err != nil { + t.Fatal(err) + } + return tree +} + +// control-law: upgrade-is-a-reviewed-data-diff +// +// The whole repository footprint of an upgrade is one rewritten pin line. +func TestUpgradeIsAReviewedDataDiff(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("the hydrated stand-in binary is a POSIX shell script") + } + base, _ := stubRelease(t, "2.0.0", stubBinaryScript("1")) + t.Chdir(t.TempDir()) + t.Setenv(hydrate.EnvHost, base) + t.Setenv(hydrate.EnvCacheDir, t.TempDir()) + t.Setenv(hydrate.EnvNoHydrate, "") + + if _, err := writeSubstrate(&bytes.Buffer{}, "1.0.0"); err != nil { + t.Fatal(err) + } + writeFragment(t, ".", "tool-a", "requires_protocol: \"1\"\ncontrollers:\n test.approval:\n id: tool-a\n command: [\"true\"]\n deadline_ms: 1000\n on_timeout: deny\n on_unavailable: deny\n") + + before := snapshotTree(t, ".") + var out bytes.Buffer + if err := runUpgrade(context.Background(), []string{"--to", "2.0.0"}, &out, &out); err != nil { + t.Fatalf("upgrade: %v\n%s", err, out.String()) + } + after := snapshotTree(t, ".") + + var changed []string + for path, digest := range after { + if before[path] != digest { + changed = append(changed, path) + } + } + if len(changed) != 1 || changed[0] != ".pitot/version" { + t.Fatalf("upgrade footprint must be exactly the pin, changed: %v", changed) + } + if pin, _ := hydrate.Pin("."); pin != "2.0.0" { + t.Fatalf("pin after upgrade: %q", pin) + } + if !strings.Contains(out.String(), "commit this diff") { + t.Fatalf("upgrade must direct the operator to commit the pin diff:\n%s", out.String()) + } +} + +// control-law: upgrade-preserves-the-tenancy-contract +// +// A new binary that no longer speaks a tenant's protocol blocks the upgrade +// by fragment name, and the pin does not move. +func TestUpgradeRefusesTenantIncompatibleTarget(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("the hydrated stand-in binary is a POSIX shell script") + } + base, _ := stubRelease(t, "2.0.0", stubBinaryScript("2")) + t.Chdir(t.TempDir()) + t.Setenv(hydrate.EnvHost, base) + t.Setenv(hydrate.EnvCacheDir, t.TempDir()) + t.Setenv(hydrate.EnvNoHydrate, "") + + if _, err := writeSubstrate(&bytes.Buffer{}, "1.0.0"); err != nil { + t.Fatal(err) + } + writeFragment(t, ".", "tool-a", "requires_protocol: \"1\"\ncontrollers:\n test.approval:\n id: tool-a\n command: [\"true\"]\n deadline_ms: 1000\n on_timeout: deny\n on_unavailable: deny\n") + + var out bytes.Buffer + err := runUpgrade(context.Background(), []string{"--to", "2.0.0"}, &out, &out) + if err == nil || !strings.Contains(err.Error(), "upgrade-preserves-the-tenancy-contract") || !strings.Contains(err.Error(), "tool-a.yaml") { + t.Fatalf("want tenant-named refusal, got %v", err) + } + if pin, _ := hydrate.Pin("."); pin != "1.0.0" { + t.Fatalf("refused upgrade must not move the pin, got %q", pin) + } +} + +// control-law: bindings-move-in-lockstep-with-the-CLI +// control-law: packages-come-from-our-registry-or-nowhere +// +// Install writes only front-door registry config (scoped npm line, pip index +// file), pins bindings to the CLI's own version, and reverts cleanly. +func TestInstallWritesFrontDoorConfigWithExactPins(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv(hydrate.EnvHost, "front.example.test") + + // A released CLI pins bindings to itself. + previous := version + version = "3.1.4" + defer func() { version = previous }() + + var out bytes.Buffer + if err := runInstall([]string{"typescript", "--configure-only"}, &out, &out); err != nil { + t.Fatal(err) + } + npmrc, err := os.ReadFile(".npmrc") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(npmrc), "@operatorstack:registry=https://front.example.test/npm/") { + t.Fatalf(".npmrc must point the scope at the front door:\n%s", npmrc) + } + if strings.Contains(string(npmrc), "pkg.dev") { + t.Fatal("registry internals must never appear in client config") + } + if !strings.Contains(out.String(), "@operatorstack/pitot@3.1.4") { + t.Fatalf("npm binding must be pinned to the CLI version:\n%s", out.String()) + } + + if err := runInstall([]string{"python", "--configure-only"}, &out, &out); err != nil { + t.Fatal(err) + } + registry, err := os.ReadFile(filepath.FromSlash(".pitot/registry")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(registry), "PIP_INDEX_URL=https://front.example.test/pip/simple/") { + t.Fatalf(".pitot/registry must point pip at the front door:\n%s", registry) + } + if !strings.Contains(out.String(), "operatorstack-pitot==3.1.4") { + t.Fatalf("python binding must be pinned to the CLI version:\n%s", out.String()) + } + + // Revert removes exactly what install owns. + if err := runInstall([]string{"typescript", "--revert"}, &out, &out); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(".npmrc"); !os.IsNotExist(err) { + t.Fatal("revert must remove the .npmrc it created") + } + if err := runInstall([]string{"python", "--revert"}, &out, &out); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.FromSlash(".pitot/registry")); !os.IsNotExist(err) { + t.Fatal("revert must remove .pitot/registry") + } +} diff --git a/cmd/pitot/upgrade.go b/cmd/pitot/upgrade.go new file mode 100644 index 0000000..bd27187 --- /dev/null +++ b/cmd/pitot/upgrade.go @@ -0,0 +1,191 @@ +// Upgrade is a reviewed data diff (upgrade-is-a-reviewed-data-diff): the only +// repository mutation this command makes is rewriting .pitot/version. The new +// binary lands in the write-once cache as a consequence of the committed pin, +// and every tenant fragment must still hold before the pin moves +// (upgrade-preserves-the-tenancy-contract). +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/operatorstack/pitot/config" + "github.com/operatorstack/pitot/hydrate" + "github.com/operatorstack/pitot/schema" + "go.yaml.in/yaml/v4" +) + +func runUpgrade(ctx context.Context, args []string, stdout, stderr io.Writer) error { + target := "" + check := false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--to": + if i+1 >= len(args) { + return errors.New("pitot upgrade: --to requires a version") + } + target = args[i+1] + i++ + case "--check": + check = true + case "--host": + if i+1 >= len(args) { + return errors.New("pitot upgrade: --host requires a value") + } + os.Setenv(hydrate.EnvHost, args[i+1]) + i++ + default: + return fmt.Errorf("pitot upgrade: unexpected argument %q", args[i]) + } + } + + current := "none" + if pin, err := hydrate.Pin("."); err == nil { + current = pin + } + if target == "" { + latest, err := hydrate.Latest(ctx) + if err != nil { + return err + } + target = latest + } + target = strings.TrimPrefix(target, "v") + + if check { + if current == target { + fmt.Fprintf(stdout, "pinned %s — up to date\n", current) + } else { + fmt.Fprintf(stdout, "pinned %s, available %s (run: pitot upgrade)\n", current, target) + } + return nil + } + if current == target { + fmt.Fprintf(stdout, "already pinned to %s\n", current) + return nil + } + + // The new binary must exist and verify before the pin moves. + slot, err := hydrate.Ensure(ctx, target) + if err != nil { + return err + } + // Every tenant must still hold under the new binary before the pin moves. + if err := validateTenantsFor(target, slot); err != nil { + return err + } + if err := hydrate.WritePin(".", target); err != nil { + return err + } + fmt.Fprintf(stdout, "pinned %s -> %s (%s rewritten — commit this diff; every clone hydrates %s on its next invocation)\n", current, target, hydrate.PinPath, target) + return nil +} + +// validateTenantsFor re-checks the tenancy contract against the upgrade +// target: the merged config must still validate, and every fragment's +// requires_protocol must be spoken by the new binary. +func validateTenantsFor(version, slot string) error { + if _, err := config.Discover("."); err != nil { + if errors.Is(err, config.ErrNoConfig) { + return nil // no tenants registered; nothing to preserve + } + return fmt.Errorf("pitot upgrade: refusing to move the pin while the tenant config is broken: %w (upgrade-preserves-the-tenancy-contract)", err) + } + supported, err := binaryProtocol(slot) + if err != nil { + return fmt.Errorf("pitot upgrade: could not determine the protocol of %s: %w", version, err) + } + floors, err := fragmentProtocolFloors(".") + if err != nil { + return err + } + for _, fragment := range sortedKeysOf(floors) { + if floors[fragment] != supported { + return fmt.Errorf("pitot upgrade: tenant %s requires protocol %q but pitot %s speaks protocol %q — refusing to move the pin (upgrade-preserves-the-tenancy-contract)", fragment, floors[fragment], version, supported) + } + } + return nil +} + +// binaryProtocol asks a hydrated binary which protocol it speaks. The current +// binary answers from memory; a different version is executed and parsed. +func binaryProtocol(slot string) (string, error) { + if slot == "" { + return schema.Version, nil + } + output, err := execCapture(slot, "version") + if err != nil { + return "", err + } + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "protocol version") { + fields := strings.Split(line, ":") + return strings.TrimSpace(fields[len(fields)-1]), nil + } + } + return "", fmt.Errorf("binary at %s did not report a protocol version", slot) +} + +// fragmentProtocolFloors reads each tenant fragment's requires_protocol +// declaration (loose projection — full validation already ran via Discover). +func fragmentProtocolFloors(root string) (map[string]string, error) { + dir := filepath.Join(root, ".pitot", "conf.d") + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("pitot upgrade: read %s: %w", dir, err) + } + floors := map[string]string{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + switch filepath.Ext(entry.Name()) { + case ".yaml", ".yml": + default: + continue + } + raw, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + var fragment struct { + RequiresProtocol string `yaml:"requires_protocol"` + } + if err := yaml.Unmarshal(raw, &fragment); err != nil { + continue // Discover already vouched for parseability under strict rules + } + if fragment.RequiresProtocol != "" { + floors[filepath.ToSlash(filepath.Join(config.FragmentDir, entry.Name()))] = fragment.RequiresProtocol + } + } + return floors, nil +} + +func sortedKeysOf(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// execCapture runs a binary and returns its combined output. +func execCapture(binary string, args ...string) (string, error) { + command := exec.Command(binary, args...) + var buffer bytes.Buffer + command.Stdout = &buffer + command.Stderr = &buffer + if err := command.Run(); err != nil { + return "", fmt.Errorf("%s %s: %w\n%s", binary, strings.Join(args, " "), err, buffer.String()) + } + return buffer.String(), nil +} diff --git a/cmd/pitot/version.go b/cmd/pitot/version.go new file mode 100644 index 0000000..e866ca2 --- /dev/null +++ b/cmd/pitot/version.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "io" + "runtime/debug" + + "github.com/operatorstack/pitot/adapters" + "github.com/operatorstack/pitot/schema" +) + +// version, commit, and date carry the release identity of the binary. They are +// injected at build time via ldflags (`-X main.version=...`), matching +// GoReleaser's default variable names so its stock build config stamps them +// without extra wiring. In a plain `go build` / `go run` checkout they stay at +// their defaults, and buildCommit() falls back to the VCS revision from +// debug.ReadBuildInfo(). +var ( + version = "dev" + commit = "" + date = "" +) + +// releaseVersion returns the injected release version, or "dev" for an +// un-stamped build. +func releaseVersion() string { + if version == "" { + return "dev" + } + return version +} + +// buildCommit returns the injected commit, falling back to the VCS revision +// recorded in the build info, or "unknown". +func buildCommit() string { + if commit != "" { + return commit + } + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" && len(setting.Value) >= 12 { + return setting.Value[:12] + } + } + } + return "unknown" +} + +// runVersion prints the binary's release identity and the wire contracts it +// speaks. After a hydrated install this reports the exact tag and commit the +// artifact was built from. +func runVersion(stdout io.Writer) error { + fmt.Fprintf(stdout, "pitot %s (%s)\n", releaseVersion(), buildCommit()) + if date != "" { + fmt.Fprintf(stdout, " built : %s\n", date) + } + fmt.Fprintf(stdout, " protocol version : %s\n", schema.Version) + fmt.Fprintf(stdout, " adapter version : %s\n", adapters.AdapterVersion) + return nil +} diff --git a/cmd/pitot/workbench.go b/cmd/pitot/workbench.go index 2800a45..c3722fa 100644 --- a/cmd/pitot/workbench.go +++ b/cmd/pitot/workbench.go @@ -206,7 +206,12 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { return fmt.Errorf("pitot init: write %s: %w", fragmentPath, err) } - written := []string{filepath.ToSlash(fragmentPath)} + substrate, err := writeSubstrate(stdout, releaseVersion()) + if err != nil { + return err + } + + written := append(substrate, filepath.ToSlash(fragmentPath)) for _, name := range keys(files) { written = append(written, filepath.ToSlash(filepath.Join(dir, name))) } @@ -216,8 +221,13 @@ func runInit(args []string, stdin io.Reader, stdout, stderr io.Writer) error { // command after `--` is the coding agent Pitot supervises, never the // Controller. Everything runs from the repository root. fmt.Fprintln(stdout, "Next:") - fmt.Fprintln(stdout, " 1. Configure a supported host hook (see: pitot doctor --host HOST).") - fmt.Fprintln(stdout, " 2. Run: pitot dev --host HOST -- AGENT [ARGS...]") + step := 1 + if lang == "python" || lang == "typescript" { + fmt.Fprintf(stdout, " %d. Install the typed SDK from our registry: pitot install %s\n", step, lang) + step++ + } + fmt.Fprintf(stdout, " %d. Configure a supported host hook (see: pitot doctor --host HOST).\n", step) + fmt.Fprintf(stdout, " %d. Run: pitot dev --host HOST -- AGENT [ARGS...]\n", step+1) fmt.Fprintln(stdout, " example: pitot dev --host kimi -- kimi -p \"\"") return nil } diff --git a/hydrate/doc.go b/hydrate/doc.go new file mode 100644 index 0000000..5b13caa --- /dev/null +++ b/hydrate/doc.go @@ -0,0 +1,31 @@ +// Package hydrate makes the Pitot binary a repository property: a committed +// pin (.pitot/version) names the exact version, and hydration materializes it +// into a write-once user cache from the distribution front door. Fresh clones, +// CI, and cloud agents need no install step — the first invocation hydrates. +// +// The package is governed by these control laws; each has a tagged +// conformance test (// control-law: ): +// +// - pin-is-the-only-version-authority — nothing resolves "latest" at exec +// time; Ensure takes the pinned version as its only version input. Only +// `pitot upgrade` may consult Latest. +// - nothing-executes-unverified — the sha256 from checksums.txt must match +// before a byte lands in the slot; a failed verify leaves no slot. +// - slots-are-write-once — a populated slot is never rewritten; rollback is +// reverting the pin (a cache hit). +// - upgrade-is-a-reviewed-data-diff — the only repository mutation an +// upgrade makes is rewriting .pitot/version; binaries change only as a +// consequence of the committed pin. +// - upgrade-preserves-the-tenancy-contract — an upgrade re-validates every +// tenant fragment (merge + requires_protocol) before rewriting the pin. +// - absence-fails-closed-and-named — no cache hit plus no network (or +// PITOT_NO_HYDRATE=1) is a specific error naming the pin, the missing +// slot, and the kill switch; there is no fallback to a PATH binary. +// - the-shim-carries-no-policy — the repo shim reads the pin, fetches, +// verifies, and execs; it never reads fragments or makes decisions. +// - bindings-move-in-lockstep-with-the-CLI — one release tag ships binary, +// npm, and python bindings at one version; installs pin exactly. +// - packages-come-from-our-registry-or-nowhere — consumers resolve +// bindings only through the front door host; registry internals never +// appear in client config. +package hydrate diff --git a/hydrate/hydrate.go b/hydrate/hydrate.go new file mode 100644 index 0000000..4890adc --- /dev/null +++ b/hydrate/hydrate.go @@ -0,0 +1,327 @@ +package hydrate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" +) + +const ( + // PinPath is the repository-relative pin file: one trimmed semver line. + PinPath = ".pitot/version" + // DefaultHost is the distribution front door. Overridable via EnvHost for + // tests and air-gapped mirrors; the registry behind it never appears here. + DefaultHost = "get.operatorstack.systems" + // EnvHost overrides the front-door host (scheme-carrying values allowed, + // e.g. http://127.0.0.1:PORT for hermetic tests). + EnvHost = "PITOT_GET_HOST" + // EnvNoHydrate is the kill switch: when set to a non-empty value other + // than "0", hydration is cache-only and misses fail closed. + EnvNoHydrate = "PITOT_NO_HYDRATE" + // EnvCacheDir overrides the cache root (hermetic tests). + EnvCacheDir = "PITOT_CACHE_DIR" +) + +var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`) + +// Pin reads the repository's committed version pin. +func Pin(root string) (string, error) { + raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(PinPath))) + if err != nil { + return "", fmt.Errorf("pitot: read version pin %s: %w", PinPath, err) + } + pin := strings.TrimSpace(string(raw)) + if !semverPattern.MatchString(pin) { + return "", fmt.Errorf("pitot: version pin %s must be one semver line, got %q", PinPath, pin) + } + return pin, nil +} + +// WritePin records a version pin. The pin is the only version authority; a +// pin rewrite is the whole repository footprint of an upgrade. +func WritePin(root, version string) error { + if !semverPattern.MatchString(version) { + return fmt.Errorf("pitot: refusing to pin non-semver version %q", version) + } + path := filepath.Join(root, filepath.FromSlash(PinPath)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("pitot: create %s: %w", filepath.Dir(path), err) + } + return os.WriteFile(path, []byte(version+"\n"), 0o644) +} + +// Host returns the front-door host, honoring the EnvHost override. +func Host() string { + if host := os.Getenv(EnvHost); host != "" { + return host + } + return DefaultHost +} + +// BaseURL normalizes the front-door host into a scheme-carrying base URL. +func BaseURL() string { + host := Host() + if strings.HasPrefix(host, "http://") || strings.HasPrefix(host, "https://") { + return strings.TrimSuffix(host, "/") + } + return "https://" + host +} + +// CacheRoot is the platform cache directory holding version slots. The rule +// must stay byte-identical with the shim scripts: POSIX (including darwin) +// uses ${XDG_CACHE_HOME:-$HOME/.cache}/pitot; Windows uses %LOCALAPPDATA%\pitot. +func CacheRoot() (string, error) { + if dir := os.Getenv(EnvCacheDir); dir != "" { + return dir, nil + } + if runtime.GOOS == "windows" { + if dir := os.Getenv("LOCALAPPDATA"); dir != "" { + return filepath.Join(dir, "pitot"), nil + } + return "", errors.New("pitot: LOCALAPPDATA is not set") + } + if dir := os.Getenv("XDG_CACHE_HOME"); dir != "" { + return filepath.Join(dir, "pitot"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pitot: resolve home directory: %w", err) + } + return filepath.Join(home, ".cache", "pitot"), nil +} + +// Slot returns the write-once binary path for a version on this platform. +func Slot(version string) (string, error) { + root, err := CacheRoot() + if err != nil { + return "", err + } + name := "pitot" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(root, version, runtime.GOOS+"-"+runtime.GOARCH, name), nil +} + +// hydrateDisabled reports the kill switch state. +func hydrateDisabled() bool { + value := os.Getenv(EnvNoHydrate) + return value != "" && value != "0" +} + +// Ensure materializes the pinned version's binary in its cache slot and +// returns the slot path. A populated slot is returned as-is (write-once); a +// miss downloads the release archive and checksums from the front door, +// verifies, extracts, and renames into place under a clone-wide lock. +func Ensure(ctx context.Context, version string) (string, error) { + if !semverPattern.MatchString(version) { + return "", fmt.Errorf("pitot: refusing to hydrate non-semver version %q", version) + } + slot, err := Slot(version) + if err != nil { + return "", err + } + if _, err := os.Stat(slot); err == nil { + return slot, nil + } + if hydrateDisabled() { + return "", fmt.Errorf("pitot: version %s is pinned by %s but its slot %s is absent and hydration is disabled (%s=1); unset the kill switch or hydrate the cache out of band (absence-fails-closed-and-named)", version, PinPath, slot, EnvNoHydrate) + } + + slotDir := filepath.Dir(slot) + if err := os.MkdirAll(slotDir, 0o755); err != nil { + return "", fmt.Errorf("pitot: create slot directory: %w", err) + } + release, err := acquireLock(ctx, slotDir) + if err != nil { + return "", err + } + defer release() + // Another process may have filled the slot while we waited on the lock. + if _, err := os.Stat(slot); err == nil { + return slot, nil + } + + archiveName := fmt.Sprintf("pitot_%s_%s_%s.%s", version, runtime.GOOS, runtime.GOARCH, archiveExt()) + archive, err := fetch(ctx, fmt.Sprintf("%s/pitot/dl/%s/%s", BaseURL(), version, archiveName)) + if err != nil { + return "", fmt.Errorf("pitot: version %s is pinned by %s but its slot %s is absent and the download failed: %w (absence-fails-closed-and-named)", version, PinPath, slot, err) + } + manifest, err := fetch(ctx, fmt.Sprintf("%s/pitot/dl/%s/checksums.txt", BaseURL(), version)) + if err != nil { + return "", fmt.Errorf("pitot: download checksums.txt for %s: %w", version, err) + } + want, err := checksumFor(string(manifest), archiveName) + if err != nil { + return "", err + } + got := sha256.Sum256(archive) + if hex.EncodeToString(got[:]) != want { + return "", fmt.Errorf("pitot: checksum mismatch for %s: manifest says %s, archive is %s — refusing to install (nothing-executes-unverified)", archiveName, want, hex.EncodeToString(got[:])) + } + + binary, err := extractBinary(archive) + if err != nil { + return "", err + } + temp, err := os.CreateTemp(slotDir, ".hydrating-*") + if err != nil { + return "", fmt.Errorf("pitot: stage binary: %w", err) + } + tempPath := temp.Name() + if _, err := temp.Write(binary); err != nil { + temp.Close() + os.Remove(tempPath) + return "", fmt.Errorf("pitot: stage binary: %w", err) + } + if err := temp.Close(); err != nil { + os.Remove(tempPath) + return "", fmt.Errorf("pitot: stage binary: %w", err) + } + if err := os.Chmod(tempPath, 0o755); err != nil { + os.Remove(tempPath) + return "", fmt.Errorf("pitot: stage binary: %w", err) + } + if err := os.Rename(tempPath, slot); err != nil { + os.Remove(tempPath) + return "", fmt.Errorf("pitot: publish slot: %w", err) + } + return slot, nil +} + +// Latest resolves the newest published version from the front door. Only +// `pitot upgrade` may call this — exec-time paths take the pin and nothing +// else (pin-is-the-only-version-authority). +func Latest(ctx context.Context) (string, error) { + raw, err := fetch(ctx, BaseURL()+"/pitot/latest") + if err != nil { + return "", fmt.Errorf("pitot: resolve latest release: %w", err) + } + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return "", fmt.Errorf("pitot: parse latest release: %w", err) + } + version := strings.TrimPrefix(strings.TrimSpace(payload.Version), "v") + if !semverPattern.MatchString(version) { + return "", fmt.Errorf("pitot: latest release reported non-semver version %q", payload.Version) + } + return version, nil +} + +// acquireLock serializes hydration of one slot across processes via an atomic +// mkdir; waiters poll until the holder finishes or the deadline passes. +func acquireLock(ctx context.Context, slotDir string) (func(), error) { + lock := filepath.Join(slotDir, ".hydrate-lock") + deadline := time.Now().Add(30 * time.Second) + for { + err := os.Mkdir(lock, 0o755) + if err == nil { + return func() { os.Remove(lock) }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("pitot: acquire hydration lock: %w", err) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("pitot: hydration lock %s is held past the deadline; remove it if the holder crashed", lock) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(150 * time.Millisecond): + } + } +} + +func archiveExt() string { + if runtime.GOOS == "windows" { + return "zip" + } + return "tar.gz" +} + +func fetch(ctx context.Context, url string) ([]byte, error) { + client := &http.Client{Timeout: 60 * time.Second} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: %s", url, response.Status) + } + return io.ReadAll(io.LimitReader(response.Body, 256<<20)) +} + +// checksumFor extracts the sha256 for name from a "checksums.txt" manifest of +// " " lines. +func checksumFor(manifest, name string) (string, error) { + for _, line := range strings.Split(manifest, "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == name { + return strings.ToLower(fields[0]), nil + } + } + return "", fmt.Errorf("pitot: checksums.txt has no entry for %s", name) +} + +// extractBinary pulls the single pitot binary out of a release archive. +func extractBinary(archive []byte) ([]byte, error) { + want := "pitot" + if runtime.GOOS == "windows" { + reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return nil, fmt.Errorf("pitot: open release zip: %w", err) + } + for _, file := range reader.File { + if filepath.Base(file.Name) != want+".exe" { + continue + } + open, err := file.Open() + if err != nil { + return nil, err + } + defer open.Close() + return io.ReadAll(io.LimitReader(open, 256<<20)) + } + return nil, fmt.Errorf("pitot: release zip carries no %s.exe", want) + } + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("pitot: open release archive: %w", err) + } + defer gz.Close() + entries := tar.NewReader(gz) + for { + header, err := entries.Next() + if errors.Is(err, io.EOF) { + return nil, fmt.Errorf("pitot: release archive carries no %s binary", want) + } + if err != nil { + return nil, fmt.Errorf("pitot: read release archive: %w", err) + } + if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == want { + return io.ReadAll(io.LimitReader(entries, 256<<20)) + } + } +} diff --git a/hydrate/hydrate_test.go b/hydrate/hydrate_test.go new file mode 100644 index 0000000..4e850c7 --- /dev/null +++ b/hydrate/hydrate_test.go @@ -0,0 +1,275 @@ +package hydrate + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" +) + +// buildArchive wraps a stub binary in the release archive shape goreleaser +// produces for this platform (tar.gz on POSIX, zip on Windows, binary nested +// in the archive directory). +func buildArchive(t *testing.T, version string, binary []byte) (name string, payload []byte) { + t.Helper() + name = fmt.Sprintf("pitot_%s_%s_%s.%s", version, runtime.GOOS, runtime.GOARCH, archiveExt()) + inner := fmt.Sprintf("pitot_%s_%s_%s/pitot", version, runtime.GOOS, runtime.GOARCH) + if runtime.GOOS == "windows" { + inner += ".exe" + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + entry, err := writer.Create(inner) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(binary); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return name, buf.Bytes() + } + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + archive := tar.NewWriter(gz) + if err := archive.WriteHeader(&tar.Header{Name: inner, Mode: 0o755, Size: int64(len(binary)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := archive.Write(binary); err != nil { + t.Fatal(err) + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return name, buf.Bytes() +} + +// artifactServer is a hermetic stand-in for the distribution front door, +// serving /pitot/latest and /pitot/dl// from memory. +type artifactServer struct { + base string + downloads atomic.Int64 + close func() +} + +func newArtifactServer(t *testing.T, latest string, files map[string][]byte) *artifactServer { + t.Helper() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := &artifactServer{base: "http://" + listener.Addr().String()} + mux := http.NewServeMux() + mux.HandleFunc("/pitot/latest", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"version":%q}`, latest) + }) + mux.HandleFunc("/pitot/dl/", func(w http.ResponseWriter, r *http.Request) { + payload, ok := files[strings.TrimPrefix(r.URL.Path, "/pitot/dl/")] + if !ok { + http.NotFound(w, r) + return + } + server.downloads.Add(1) + w.Write(payload) + }) + httpServer := &http.Server{Handler: mux} + go httpServer.Serve(listener) + server.close = func() { httpServer.Close() } + t.Cleanup(server.close) + return server +} + +func checksumLine(name string, payload []byte) string { + digest := sha256.Sum256(payload) + return hex.EncodeToString(digest[:]) + " " + name + "\n" +} + +func setupRelease(t *testing.T, version string, binary []byte) *artifactServer { + t.Helper() + name, payload := buildArchive(t, version, binary) + server := newArtifactServer(t, version, map[string][]byte{ + version + "/" + name: payload, + version + "/" + "checksums.txt": []byte(checksumLine(name, payload)), + }) + t.Setenv(EnvHost, server.base) + t.Setenv(EnvCacheDir, t.TempDir()) + t.Setenv(EnvNoHydrate, "") + return server +} + +// control-law: slots-are-write-once +// +// A hydrated slot is never rewritten: the second Ensure is a pure cache hit +// that works with the front door completely gone. +func TestEnsureHydratesOnceThenSlotIsImmutable(t *testing.T) { + binary := []byte("#!/bin/sh\necho stub-1.2.3\n") + server := setupRelease(t, "1.2.3", binary) + + first, err := Ensure(context.Background(), "1.2.3") + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(first) + if err != nil || !bytes.Equal(got, binary) { + t.Fatalf("slot content mismatch (err=%v)", err) + } + afterFirst := server.downloads.Load() + if afterFirst == 0 { + t.Fatal("first Ensure did not download") + } + + server.close() // front door gone: cache must carry everything + second, err := Ensure(context.Background(), "1.2.3") + if err != nil { + t.Fatalf("cache hit failed with the server down: %v", err) + } + if second != first { + t.Fatalf("slot path changed: %s vs %s", first, second) + } + if server.downloads.Load() != afterFirst { + t.Fatal("second Ensure downloaded again") + } +} + +// control-law: nothing-executes-unverified +// +// A tampered archive is refused by name and leaves the slot absent. +func TestEnsureRefusesTamperedArchive(t *testing.T) { + name, payload := buildArchive(t, "1.2.3", []byte("evil")) + server := newArtifactServer(t, "1.2.3", map[string][]byte{ + "1.2.3/" + name: payload, + "1.2.3/" + "checksums.txt": []byte(strings.Repeat("0", 64) + " " + name + "\n"), + }) + t.Setenv(EnvHost, server.base) + t.Setenv(EnvCacheDir, t.TempDir()) + t.Setenv(EnvNoHydrate, "") + + _, err := Ensure(context.Background(), "1.2.3") + if err == nil || !strings.Contains(err.Error(), "nothing-executes-unverified") { + t.Fatalf("want named checksum refusal, got %v", err) + } + slot, err := Slot("1.2.3") + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(slot); !os.IsNotExist(statErr) { + t.Fatalf("tampered hydration must leave no slot, stat err=%v", statErr) + } + entries, _ := os.ReadDir(filepath.Dir(slot)) + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".hydrating-") { + t.Fatalf("staging temp file leaked: %s", entry.Name()) + } + } +} + +// control-law: absence-fails-closed-and-named +// +// The kill switch and a dead front door both fail closed with errors naming +// the pin, the slot, and (for the kill switch) the escape hatch. There is no +// PATH fallback to fail open into. +func TestEnsureAbsenceFailsClosedAndNamed(t *testing.T) { + t.Setenv(EnvCacheDir, t.TempDir()) + + t.Run("kill switch", func(t *testing.T) { + t.Setenv(EnvNoHydrate, "1") + _, err := Ensure(context.Background(), "1.2.3") + if err == nil { + t.Fatal("want refusal") + } + for _, want := range []string{PinPath, EnvNoHydrate, "absence-fails-closed-and-named"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("refusal must name %q, got: %v", want, err) + } + } + }) + + t.Run("front door unreachable", func(t *testing.T) { + t.Setenv(EnvNoHydrate, "") + t.Setenv(EnvHost, "http://127.0.0.1:1") + _, err := Ensure(context.Background(), "1.2.3") + if err == nil || !strings.Contains(err.Error(), "absence-fails-closed-and-named") { + t.Fatalf("want named fail-closed error, got %v", err) + } + }) +} + +// control-law: pin-is-the-only-version-authority +// +// A newer version sitting in the cache is invisible to a pinned Ensure, and +// the pin file itself only accepts one semver line. +func TestPinIsTheOnlyVersionAuthority(t *testing.T) { + binary := []byte("pinned") + setupRelease(t, "1.0.0", binary) + + // Seed a "newer" slot by hand: it must not shadow the pinned version. + newer, err := Slot("9.9.9") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(newer), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newer, []byte("newer"), 0o755); err != nil { + t.Fatal(err) + } + + path, err := Ensure(context.Background(), "1.0.0") + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, binary) { + t.Fatalf("pinned Ensure resolved the wrong binary (err=%v, got %q)", err, got) + } + + root := t.TempDir() + if err := WritePin(root, "1.0.0"); err != nil { + t.Fatal(err) + } + pin, err := Pin(root) + if err != nil || pin != "1.0.0" { + t.Fatalf("pin round-trip: %q %v", pin, err) + } + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(PinPath)), []byte("latest\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Pin(root); err == nil { + t.Fatal("non-semver pin must be rejected — 'latest' is not a version authority") + } +} + +func TestChecksumForAndLatest(t *testing.T) { + manifest := "abc other.tar.gz\n" + checksumLine("pitot_1.0.0_linux_amd64.tar.gz", []byte("x")) + digest := sha256.Sum256([]byte("x")) + got, err := checksumFor(manifest, "pitot_1.0.0_linux_amd64.tar.gz") + if err != nil || got != hex.EncodeToString(digest[:]) { + t.Fatalf("checksumFor: %q %v", got, err) + } + if _, err := checksumFor(manifest, "missing"); err == nil { + t.Fatal("missing manifest entry must error") + } + + server := newArtifactServer(t, "v2.0.0", nil) + t.Setenv(EnvHost, server.base) + latest, err := Latest(context.Background()) + if err != nil || latest != "2.0.0" { + t.Fatalf("Latest: %q %v", latest, err) + } +}