diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eeb7345..4b3231c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -275,10 +275,16 @@ jobs: exit 1 ;; esac + RELEASE_ARGS=( + --repo "${GITHUB_REPOSITORY}" + --verify-tag + --draft + --title "ContainerBin ${GITHUB_REF_NAME}" + --generate-notes + ) + if [[ "${GITHUB_REF_NAME}" == *-* ]]; then + RELEASE_ARGS+=(--prerelease) + fi gh release create "${GITHUB_REF_NAME}" \ - --repo "${GITHUB_REPOSITORY}" \ - --verify-tag \ - --draft \ - --title "ContainerBin ${GITHUB_REF_NAME}" \ - --generate-notes \ + "${RELEASE_ARGS[@]}" \ dist/cb.exe dist/*.zip dist/SHA256SUMS diff --git a/README.md b/README.md index ab2b617..8eca054 100644 --- a/README.md +++ b/README.md @@ -789,6 +789,7 @@ cb env cb list cb default cb default set node 22 +cb self-update --check # read-only stable-release selection; no download or file changes ``` `cb bugreport` assembles `cb version`, the Windows and PowerShell versions @@ -807,6 +808,22 @@ failure. The Node 22 checks run when the `node22` profile is registered. An older registry without that newer default gets an actionable skip rather than a false failure; run `cb setup` to append the current default profiles. +### Self-update release selection + +`cb self-update --check` is the first, read-only phase of transactional +self-update support. It compares a release-qualified Windows/amd64 build with +the latest stable release and reports the exact binary, archive, checksum and +provenance policy that later phases must verify. It does not download assets or +change any files, and development builds fail closed because their installed +version cannot be proved. + +Stable selection is the default. Use `--prerelease` to select the highest +canonical prerelease among the 30 most recent published releases, or +`--version vX.Y.Z` to inspect one exact published release; those two selectors +are mutually exclusive. Selecting a version older +than the running build is rejected unless `--allow-downgrade` is explicit. The +current slice supports only Windows/amd64, matching the release artifacts. + ### `cb self-test --json` report format `--json` prints one JSON document to stdout and nothing else (no progress diff --git a/docs/architecture.md b/docs/architecture.md index 6893957..be9871b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -231,7 +231,8 @@ the tier below it; see the exact edges further down for that): ``` main argv[0] dispatch, subcommand switch, version, usage, - exit codes + fatalf/osExit, withMutationLock's signal wrapper + exit codes + fatalf/osExit, bootstrap self-update selection, + withMutationLock's signal wrapper ↓ internal/cli setup, install, add, expose, unexpose, uninstall, inspect, trace, env, backup, restore, lock, update @@ -257,13 +258,14 @@ internal/registry Tool/Registry, TOML parser, defaults, registry file internal/toml the shared TOML subset lexer (leaf) internal/atomicio crash-safe write + .bak recovery (leaf) internal/mutationlock the registry mutation lock primitive (leaf) +internal/selfupdate canonical release selection and read-only plan (leaf) ``` The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, project-internal imports only: ``` -main -> cli, diag, dockerrun, mutationlock, policy, registry, state +main -> cli, diag, dockerrun, mutationlock, policy, registry, selfupdate, state cli -> atomicio, diag, dockerrun, lockfile, pathmap, policy, registry, statearchive, toml diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry dockerrun -> dockervol, lockfile, pathmap, policy, registry @@ -273,7 +275,7 @@ lockfile -> atomicio, policy, registry, toml pathmap -> registry registry -> atomicio, toml policy -> toml -atomicio, dockervol, mutationlock, toml -> (leaves) +atomicio, dockervol, mutationlock, selfupdate, toml -> (leaves) ``` Notably: `lockfile` and `pathmap` both depend on `registry` directly, not on @@ -298,3 +300,10 @@ Two boundaries are load-bearing rather than cosmetic: release workflow inject it with `-ldflags "-X main.version=..."`, so that symbol path is part of the release contract. Packages that need it take it as a parameter. + +`cb self-update --check` is dispatched before machine policy and registry +loading, like the bootstrap help/version path. Release selection therefore +remains available when either local configuration source is missing or invalid. +`internal/selfupdate` has no project imports and performs only bounded metadata +queries and plan output; downloading, attestation verification and installed-file +replacement remain separate later phases. diff --git a/docs/security-model.md b/docs/security-model.md index 3341f8c..8645ebe 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -95,6 +95,13 @@ readable, and dangerous to let others edit. Tar extraction occurs only inside the named Docker volume through an immutable, network-disabled helper with a read-only container root; no archive member is turned into a Windows host path. +- **Fail-closed self-update selection.** `cb self-update --check` accepts only a + release-qualified Windows/amd64 build, queries the canonical GitHub repository + over HTTPS with a bounded response, and requires exact canonical release and + asset URLs, names and sizes. Downgrades and prereleases require explicit + flags. This phase performs no asset download and changes no installed files; + later phases must require both checksums and GitHub provenance without a + fallback before replacement is possible. ## What ContainerBin does NOT protect against diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..5980f76 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,320 @@ +// Package selfupdate implements the read-only selection and planning phase of +// ContainerBin's transactional self-update. Download, attestation verification +// and installed-file replacement are deliberately separate later phases. +package selfupdate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "runtime" + "sort" + "strings" + "time" +) + +const ( + apiRoot = "https://api.github.com/repos/AviBackToBlack/container-bin" + releaseWebRoot = "https://github.com/AviBackToBlack/container-bin/releases" + apiVersion = "2026-03-10" + maxResponseSize = 1 << 20 + maxBinarySize = 100 << 20 + maxArchiveSize = 100 << 20 + maxChecksumSize = 64 << 10 +) + +type Options struct { + Check bool + Prerelease bool + Version string + AllowDowngrade bool +} + +type Asset struct { + Name string + URL string + Size int64 +} + +type Plan struct { + Current string + Target string + Channel string + Status string + OS string + Arch string + ReleaseURL string + Binary Asset + Archive Asset + Checksums Asset + ExpectedRepo string + ExpectedRef string + Workflow string +} + +type releaseAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +type release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Assets []releaseAsset `json:"assets"` +} + +type httpDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type checker struct { + doer httpDoer +} + +func ParseArgs(args []string) (Options, error) { + var opts Options + for i := 0; i < len(args); i++ { + switch args[i] { + case "--check": + if opts.Check { + return Options{}, errors.New("--check may be specified only once") + } + opts.Check = true + case "--prerelease": + if opts.Prerelease { + return Options{}, errors.New("--prerelease may be specified only once") + } + opts.Prerelease = true + case "--version": + if opts.Version != "" || i+1 == len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "-") { + return Options{}, errors.New("--version requires one canonical version") + } + i++ + opts.Version = args[i] + case "--allow-downgrade": + if opts.AllowDowngrade { + return Options{}, errors.New("--allow-downgrade may be specified only once") + } + opts.AllowDowngrade = true + default: + return Options{}, fmt.Errorf("unknown self-update option %q", args[i]) + } + } + if !opts.Check { + return Options{}, errors.New("this build implements only the read-only selection phase; use `cb self-update --check`") + } + if opts.Prerelease && opts.Version != "" { + return Options{}, errors.New("--prerelease and --version are mutually exclusive") + } + if opts.Version != "" { + if _, err := parseVersion(opts.Version); err != nil { + return Options{}, err + } + } + return opts, nil +} + +func Check(ctx context.Context, current string, args []string, out io.Writer) error { + opts, err := ParseArgs(args) + if err != nil { + return err + } + client := &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + plan, err := (checker{doer: client}).Plan(ctx, current, runtime.GOOS, runtime.GOARCH, opts) + if err != nil { + return err + } + printPlan(out, plan) + return nil +} + +func (c checker) Plan(ctx context.Context, current, goos, goarch string, opts Options) (Plan, error) { + currentParsed, err := currentVersion(current) + if err != nil { + return Plan{}, err + } + if goos != "windows" || goarch != "amd64" { + return Plan{}, fmt.Errorf("self-update has no qualified artifact for %s/%s (supported: windows/amd64)", goos, goarch) + } + selected, channel, err := c.selectRelease(ctx, current, opts) + if err != nil { + return Plan{}, err + } + target, err := parseVersion(selected.TagName) + if err != nil { + return Plan{}, fmt.Errorf("selected release has invalid tag: %w", err) + } + comparison := currentParsed.compare(target) + status := "UPDATE AVAILABLE" + switch { + case comparison == 0: + status = "CURRENT" + case comparison > 0 && !opts.AllowDowngrade: + return Plan{}, fmt.Errorf("selected target %s is older than current %s; pass --allow-downgrade explicitly", target.raw, currentParsed.raw) + case comparison > 0: + status = "DOWNGRADE AUTHORIZED (CHECK ONLY)" + } + binary, archive, checksums, err := validateRelease(selected, goos, goarch) + if err != nil { + return Plan{}, err + } + return Plan{ + Current: currentParsed.raw, + Target: target.raw, + Channel: channel, + Status: status, + OS: goos, + Arch: goarch, + ReleaseURL: selected.HTMLURL, + Binary: binary, + Archive: archive, + Checksums: checksums, + ExpectedRepo: "AviBackToBlack/container-bin", + ExpectedRef: "refs/tags/" + target.raw, + Workflow: ".github/workflows/release.yml", + }, nil +} + +func (c checker) selectRelease(ctx context.Context, current string, opts Options) (release, string, error) { + switch { + case opts.Version != "": + var selected release + if err := c.getJSON(ctx, apiRoot+"/releases/tags/"+opts.Version, current, &selected); err != nil { + return release{}, "", err + } + if selected.Draft || selected.TagName != opts.Version { + return release{}, "", fmt.Errorf("exact release response does not identify published target %s", opts.Version) + } + return selected, "exact", nil + case opts.Prerelease: + var releases []release + if err := c.getJSON(ctx, apiRoot+"/releases?per_page=30", current, &releases); err != nil { + return release{}, "", err + } + var candidates []struct { + release release + version semanticVersion + } + for _, candidate := range releases { + if candidate.Draft || !candidate.Prerelease { + continue + } + v, err := parseVersion(candidate.TagName) + if err != nil || len(v.prerelease) == 0 { + continue + } + candidates = append(candidates, struct { + release release + version semanticVersion + }{candidate, v}) + } + if len(candidates) == 0 { + return release{}, "", errors.New("no published canonical prerelease is available") + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].version.compare(candidates[j].version) > 0 }) + return candidates[0].release, "prerelease", nil + default: + var selected release + if err := c.getJSON(ctx, apiRoot+"/releases/latest", current, &selected); err != nil { + return release{}, "", err + } + v, err := parseVersion(selected.TagName) + if selected.Draft || selected.Prerelease || err != nil || len(v.prerelease) != 0 { + return release{}, "", errors.New("latest stable endpoint returned a draft, prerelease, or invalid tag") + } + return selected, "stable", nil + } +} + +func (c checker) getJSON(ctx context.Context, endpoint, current string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", "container-bin/"+current) + endpointLabel := req.URL.RequestURI() + resp, err := c.doer.Do(req) + if err != nil { + return fmt.Errorf("GitHub release query %s failed: %w", endpointLabel, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GitHub release query %s returned HTTP %d", endpointLabel, resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1)) + if err != nil { + return fmt.Errorf("read GitHub release response: %w", err) + } + if len(body) > maxResponseSize { + return errors.New("GitHub release response exceeded the 1 MiB limit") + } + if err := json.Unmarshal(body, dst); err != nil { + return fmt.Errorf("decode GitHub release response: %w", err) + } + return nil +} + +func validateRelease(selected release, goos, goarch string) (Asset, Asset, Asset, error) { + tag, err := parseVersion(selected.TagName) + if err != nil { + return Asset{}, Asset{}, Asset{}, err + } + wantReleaseURL := releaseWebRoot + "/tag/" + tag.raw + if selected.HTMLURL != wantReleaseURL { + return Asset{}, Asset{}, Asset{}, fmt.Errorf("release URL %q is outside the canonical release page", selected.HTMLURL) + } + archiveName := fmt.Sprintf("container-bin-%s-%s-%s.zip", tag.raw, goos, goarch) + wanted := map[string]int64{"cb.exe": maxBinarySize, archiveName: maxArchiveSize, "SHA256SUMS": maxChecksumSize} + found := map[string]Asset{} + for _, candidate := range selected.Assets { + limit, required := wanted[candidate.Name] + if !required { + continue + } + if _, duplicate := found[candidate.Name]; duplicate { + return Asset{}, Asset{}, Asset{}, fmt.Errorf("release contains duplicate required asset %q", candidate.Name) + } + if candidate.Size <= 0 || candidate.Size > limit { + return Asset{}, Asset{}, Asset{}, fmt.Errorf("release asset %q has invalid size %d (limit %d)", candidate.Name, candidate.Size, limit) + } + wantURL := releaseWebRoot + "/download/" + tag.raw + "/" + candidate.Name + if candidate.BrowserDownloadURL != wantURL { + return Asset{}, Asset{}, Asset{}, fmt.Errorf("release asset %q has non-canonical download URL", candidate.Name) + } + found[candidate.Name] = Asset{Name: candidate.Name, URL: candidate.BrowserDownloadURL, Size: candidate.Size} + } + for name := range wanted { + if _, ok := found[name]; !ok { + return Asset{}, Asset{}, Asset{}, fmt.Errorf("release is missing required asset %q", name) + } + } + return found["cb.exe"], found[archiveName], found["SHA256SUMS"], nil +} + +func printPlan(out io.Writer, plan Plan) { + fmt.Fprintln(out, "self-update check (read-only; no files changed)") + fmt.Fprintf(out, "current: %s\n", plan.Current) + fmt.Fprintf(out, "target: %s\n", plan.Target) + fmt.Fprintf(out, "channel: %s\n", plan.Channel) + fmt.Fprintf(out, "status: %s\n", plan.Status) + fmt.Fprintf(out, "platform: %s/%s\n", plan.OS, plan.Arch) + fmt.Fprintf(out, "release: %s\n", plan.ReleaseURL) + fmt.Fprintf(out, "binary: %s (%d bytes)\n", plan.Binary.Name, plan.Binary.Size) + fmt.Fprintf(out, "archive: %s (%d bytes)\n", plan.Archive.Name, plan.Archive.Size) + fmt.Fprintf(out, "checksums: %s (%d bytes)\n", plan.Checksums.Name, plan.Checksums.Size) + fmt.Fprintf(out, "verification: gh attestation verify; repository=%s workflow=%s ref=%s; checksums additionally required; no fallback\n", plan.ExpectedRepo, plan.Workflow, plan.ExpectedRef) + fmt.Fprintln(out, "apply: unavailable in this slice; verified download and transactional replacement are separate roadmap phases") +} diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..91f9c16 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,285 @@ +package selfupdate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { return f(req) } + +func TestParseArgs(t *testing.T) { + cases := []struct { + name string + args []string + want Options + wantErr string + }{ + {name: "stable", args: []string{"--check"}, want: Options{Check: true}}, + {name: "prerelease", args: []string{"--check", "--prerelease"}, want: Options{Check: true, Prerelease: true}}, + {name: "exact downgrade", args: []string{"--version", "v1.0.0", "--allow-downgrade", "--check"}, want: Options{Check: true, Version: "v1.0.0", AllowDowngrade: true}}, + {name: "check required", wantErr: "only the read-only selection phase"}, + {name: "mutually exclusive", args: []string{"--check", "--prerelease", "--version", "v1.0.0"}, wantErr: "mutually exclusive"}, + {name: "bad exact", args: []string{"--check", "--version", "latest"}, wantErr: "canonical"}, + {name: "missing exact", args: []string{"--check", "--version"}, wantErr: "requires"}, + {name: "empty exact", args: []string{"--check", "--version", ""}, wantErr: "requires"}, + {name: "missing exact before flag", args: []string{"--check", "--version", "--prerelease"}, wantErr: "requires"}, + {name: "unknown", args: []string{"--check", "--apply"}, wantErr: "unknown"}, + {name: "duplicate check", args: []string{"--check", "--check"}, wantErr: "only once"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseArgs(tc.args) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("ParseArgs(%v) error = %v, want %q", tc.args, err, tc.wantErr) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("ParseArgs(%v) = (%+v, %v), want %+v", tc.args, got, err, tc.want) + } + }) + } +} + +func TestVersionParsingAndPrecedence(t *testing.T) { + ordered := []string{ + "v1.0.0-alpha", + "v1.0.0-alpha.1", + "v1.0.0-alpha-beta", + "v1.0.0-beta", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-beta.184467440737095516160", + "v1.0.0-rc.1", + "v1.0.0", + "v1.0.1", + "v2.0.0", + } + for i, raw := range ordered { + v, err := parseVersion(raw) + if err != nil { + t.Fatalf("parseVersion(%q): %v", raw, err) + } + if i > 0 { + previous, _ := parseVersion(ordered[i-1]) + if previous.compare(v) >= 0 || v.compare(previous) <= 0 { + t.Fatalf("version order not strict: %s, %s", previous.raw, v.raw) + } + } + } + for _, raw := range []string{"dev", "1.2.3", "v1.2", "v01.2.3", "v1.2.3-01", "v1.2.3+meta", "v1.2.3-"} { + if _, err := parseVersion(raw); err == nil { + t.Errorf("parseVersion(%q) succeeded", raw) + } + } +} + +func TestPlanStableSelectionAndHeaders(t *testing.T) { + selected := canonicalRelease("v1.2.0", false) + c := checker{doer: doerFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != apiRoot+"/releases/latest" { + t.Fatalf("request URL = %s", req.URL) + } + if req.Header.Get("Accept") != "application/vnd.github+json" || req.Header.Get("X-GitHub-Api-Version") != apiVersion || req.Header.Get("User-Agent") != "container-bin/v1.1.0" { + t.Fatalf("unexpected request headers: %v", req.Header) + } + return jsonResponse(t, selected), nil + })} + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + if plan.Target != "v1.2.0" || plan.Channel != "stable" || plan.Status != "UPDATE AVAILABLE" { + t.Fatalf("unexpected plan: %+v", plan) + } + if plan.Archive.Name != "container-bin-v1.2.0-windows-amd64.zip" || plan.Binary.Name != "cb.exe" || plan.Checksums.Name != "SHA256SUMS" { + t.Fatalf("unexpected assets: %+v", plan) + } + if plan.ExpectedRepo != "AviBackToBlack/container-bin" || plan.ExpectedRef != "refs/tags/v1.2.0" || plan.Workflow != ".github/workflows/release.yml" { + t.Fatalf("unexpected verification policy: %+v", plan) + } +} + +func TestPlanExactAndDowngradePolicy(t *testing.T) { + selected := canonicalRelease("v1.0.0", false) + c := checker{doer: releaseDoer(t, apiRoot+"/releases/tags/v1.0.0", selected)} + _, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true, Version: "v1.0.0"}) + if err == nil || !strings.Contains(err.Error(), "--allow-downgrade") { + t.Fatalf("downgrade error = %v", err) + } + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true, Version: "v1.0.0", AllowDowngrade: true}) + if err != nil { + t.Fatal(err) + } + if plan.Channel != "exact" || plan.Status != "DOWNGRADE AUTHORIZED (CHECK ONLY)" { + t.Fatalf("unexpected exact downgrade plan: %+v", plan) + } +} + +func TestPlanCurrent(t *testing.T) { + selected := canonicalRelease("v1.1.0", false) + c := checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)} + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err != nil || plan.Status != "CURRENT" { + t.Fatalf("current plan = (%+v, %v)", plan, err) + } +} + +func TestPlanPrereleaseSelectsHighestCanonicalPublishedCandidate(t *testing.T) { + releases := []release{ + canonicalRelease("v1.3.0-rc.1", true), + canonicalRelease("v1.2.0", false), + canonicalRelease("not-semver", true), + canonicalRelease("v1.3.0-beta.2", true), + canonicalRelease("v2.0.0-rc.1", true), + } + releases[4].Draft = true + c := checker{doer: releaseDoer(t, apiRoot+"/releases?per_page=30", releases)} + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true, Prerelease: true}) + if err != nil { + t.Fatal(err) + } + if plan.Target != "v1.3.0-rc.1" || plan.Channel != "prerelease" { + t.Fatalf("unexpected prerelease plan: %+v", plan) + } +} + +func TestPlanRejectsDevelopmentAndUnsupportedPlatformBeforeNetwork(t *testing.T) { + called := false + c := checker{doer: doerFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("unexpected") + })} + for _, tc := range []struct { + current, goos, goarch, want string + }{ + {"dev", "windows", "amd64", "development builds"}, + {"v0.0.0-dev.abc", "windows", "amd64", "development builds"}, + {"v1.1.0", "windows", "arm64", "no qualified artifact"}, + {"v1.1.0", "linux", "amd64", "no qualified artifact"}, + } { + _, err := c.Plan(context.Background(), tc.current, tc.goos, tc.goarch, Options{Check: true}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("Plan(%q,%s/%s) error = %v, want %q", tc.current, tc.goos, tc.goarch, err, tc.want) + } + } + if called { + t.Fatal("network called for locally rejected plan") + } +} + +func TestPlanRejectsUnsafeReleaseMetadata(t *testing.T) { + cases := []struct { + name string + mutate func(*release) + want string + }{ + {name: "missing", mutate: func(r *release) { r.Assets = r.Assets[:2] }, want: "missing required asset"}, + {name: "duplicate", mutate: func(r *release) { r.Assets = append(r.Assets, r.Assets[0]) }, want: "duplicate required asset"}, + {name: "external asset URL", mutate: func(r *release) { r.Assets[0].BrowserDownloadURL = "https://evil.example/cb.exe" }, want: "non-canonical download URL"}, + {name: "external release URL", mutate: func(r *release) { r.HTMLURL = "https://evil.example/v1.2.0" }, want: "outside the canonical release page"}, + {name: "oversize", mutate: func(r *release) { r.Assets[0].Size = maxBinarySize + 1 }, want: "invalid size"}, + {name: "prerelease from stable", mutate: func(r *release) { r.Prerelease = true }, want: "prerelease"}, + {name: "prerelease tag without flag", mutate: func(r *release) { r.TagName = "v1.2.0-rc.1" }, want: "prerelease"}, + {name: "invalid stable tag", mutate: func(r *release) { r.TagName = "latest" }, want: "invalid tag"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + selected := canonicalRelease("v1.2.0", false) + tc.mutate(&selected) + c := checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)} + _, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("unsafe metadata error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestGetJSONBoundsAndTransportFailures(t *testing.T) { + for _, tc := range []struct { + name string + doer httpDoer + want string + }{ + {name: "network", doer: doerFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("offline") }), want: "offline"}, + {name: "http", doer: doerFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusForbidden, "{}"), nil }), want: "/repos/AviBackToBlack/container-bin/releases/latest returned HTTP 403"}, + {name: "truncated JSON", doer: doerFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, "{"), nil }), want: "decode"}, + {name: "oversize", doer: doerFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusOK, strings.Repeat("x", maxResponseSize+1)), nil + }), want: "1 MiB"}, + } { + t.Run(tc.name, func(t *testing.T) { + var dst release + err := (checker{doer: tc.doer}).getJSON(context.Background(), apiRoot+"/releases/latest", "v1.0.0", &dst) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("getJSON error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestPrintPlanStatesReadOnlyBoundary(t *testing.T) { + selected := canonicalRelease("v1.2.0", false) + c := checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)} + plan, err := c.Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + printPlan(&out, plan) + for _, want := range []string{"read-only; no files changed", "UPDATE AVAILABLE", "container-bin-v1.2.0-windows-amd64.zip", "gh attestation verify", "no fallback", "apply: unavailable"} { + if !strings.Contains(out.String(), want) { + t.Errorf("plan output missing %q:\n%s", want, out.String()) + } + } +} + +func canonicalRelease(tag string, prerelease bool) release { + archive := "container-bin-" + tag + "-windows-amd64.zip" + asset := func(name string, size int64) releaseAsset { + return releaseAsset{Name: name, Size: size, BrowserDownloadURL: releaseWebRoot + "/download/" + tag + "/" + name} + } + return release{ + TagName: tag, + HTMLURL: releaseWebRoot + "/tag/" + tag, + Prerelease: prerelease, + Assets: []releaseAsset{ + asset("cb.exe", 3<<20), + asset(archive, 2<<20), + asset("SHA256SUMS", 178), + }, + } +} + +func releaseDoer(t *testing.T, wantURL string, value any) httpDoer { + t.Helper() + return doerFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.String() != wantURL { + t.Fatalf("request URL = %s, want %s", req.URL, wantURL) + } + return jsonResponse(t, value), nil + }) +} + +func jsonResponse(t *testing.T, value any) *http.Response { + t.Helper() + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(value); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(b.Bytes())), Header: make(http.Header)} +} + +func response(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} diff --git a/internal/selfupdate/version.go b/internal/selfupdate/version.go new file mode 100644 index 0000000..e8fba8d --- /dev/null +++ b/internal/selfupdate/version.go @@ -0,0 +1,152 @@ +package selfupdate + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +type semanticVersion struct { + raw string + major uint64 + minor uint64 + patch uint64 + prerelease []string +} + +func parseVersion(raw string) (semanticVersion, error) { + if !strings.HasPrefix(raw, "v") || strings.ContainsAny(raw, "+/\\ \r\n") { + return semanticVersion{}, fmt.Errorf("version %q must be canonical vMAJOR.MINOR.PATCH with an optional prerelease", raw) + } + coreAndPre := strings.SplitN(raw[1:], "-", 2) + if len(coreAndPre) > 2 { + return semanticVersion{}, fmt.Errorf("version %q has an invalid prerelease", raw) + } + core := strings.Split(coreAndPre[0], ".") + if len(core) != 3 { + return semanticVersion{}, fmt.Errorf("version %q must have three numeric components", raw) + } + parts := make([]uint64, 3) + for i, value := range core { + if value == "" || (len(value) > 1 && value[0] == '0') { + return semanticVersion{}, fmt.Errorf("version %q has a non-canonical numeric component", raw) + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return semanticVersion{}, fmt.Errorf("version %q has an invalid numeric component", raw) + } + parts[i] = parsed + } + v := semanticVersion{raw: raw, major: parts[0], minor: parts[1], patch: parts[2]} + if len(coreAndPre) == 2 { + if coreAndPre[1] == "" { + return semanticVersion{}, fmt.Errorf("version %q has an empty prerelease", raw) + } + for _, identifier := range strings.Split(coreAndPre[1], ".") { + if identifier == "" { + return semanticVersion{}, fmt.Errorf("version %q has an empty prerelease identifier", raw) + } + numeric := true + for _, r := range identifier { + if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || r == '-' { + if r < '0' || r > '9' { + numeric = false + } + continue + } + return semanticVersion{}, fmt.Errorf("version %q has an invalid prerelease identifier", raw) + } + if numeric && len(identifier) > 1 && identifier[0] == '0' { + return semanticVersion{}, fmt.Errorf("version %q has a non-canonical numeric prerelease identifier", raw) + } + v.prerelease = append(v.prerelease, identifier) + } + } + return v, nil +} + +func (v semanticVersion) compare(other semanticVersion) int { + for _, pair := range [][2]uint64{{v.major, other.major}, {v.minor, other.minor}, {v.patch, other.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if len(v.prerelease) == 0 && len(other.prerelease) == 0 { + return 0 + } + if len(v.prerelease) == 0 { + return 1 + } + if len(other.prerelease) == 0 { + return -1 + } + for i := 0; i < len(v.prerelease) && i < len(other.prerelease); i++ { + a, b := v.prerelease[i], other.prerelease[i] + if a == b { + continue + } + aNumber := numericIdentifier(a) + bNumber := numericIdentifier(b) + switch { + case aNumber && bNumber: + if len(a) < len(b) || (len(a) == len(b) && a < b) { + return -1 + } + return 1 + case aNumber: + return -1 + case bNumber: + return 1 + case a < b: + return -1 + default: + return 1 + } + } + if len(v.prerelease) < len(other.prerelease) { + return -1 + } + if len(v.prerelease) > len(other.prerelease) { + return 1 + } + return 0 +} + +func numericIdentifier(value string) bool { + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return value != "" +} + +func (v semanticVersion) isDevelopment() bool { + if v.raw == "dev" { + return true + } + for _, identifier := range v.prerelease { + if strings.EqualFold(identifier, "dev") { + return true + } + } + return false +} + +func currentVersion(raw string) (semanticVersion, error) { + if raw == "dev" { + return semanticVersion{}, errors.New("self-update is unavailable for development builds") + } + v, err := parseVersion(raw) + if err != nil { + return semanticVersion{}, fmt.Errorf("current build version is not release-qualified: %w", err) + } + if v.isDevelopment() { + return semanticVersion{}, errors.New("self-update is unavailable for development builds") + } + return v, nil +} diff --git a/main.go b/main.go index d0e4ac0..9c7d27a 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "os" "os/signal" @@ -13,6 +14,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/mutationlock" "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/selfupdate" "github.com/AviBackToBlack/container-bin/internal/state" ) @@ -28,11 +30,21 @@ var version = "dev" var loadRegistry = registry.Load var loadPolicy = policy.Load +// runSelfUpdateCheck is a test seam for proving self-update selection remains +// available before policy or registry I/O. Production always uses selfupdate.Check. +var runSelfUpdateCheck = selfupdate.Check + func main() { invoked := invokedName(os.Args[0]) if isManagementInvocation(invoked) && handleBootstrapCommand(os.Args[1:]) { return } + if isManagementInvocation(invoked) && len(os.Args) > 1 && os.Args[1] == "self-update" { + if err := runSelfUpdateCheck(context.Background(), version, os.Args[2:], os.Stdout); err != nil { + fatalf("self-update: %v", err) + } + return + } machinePolicy, err := loadPolicy() if err != nil { fatalf("machine policy: %v", err) @@ -252,6 +264,8 @@ Commands: cb backup back up registry + lock; --state adds explicitly named volumes cb restore validate/restore a backup (dry-run unless --apply; state is opt-in) cb self-test [--json] [--release] run offline end-to-end compatibility checks + cb self-update --check [--prerelease | --version VERSION] [--allow-downgrade] + report a release update plan without downloading or changing files cb list list configured tool profiles cb default list defaults; "cb default set FAMILY VERSION" switches a family cb trace show raw/normalized/mapped argv for a tool without running it diff --git a/main_test.go b/main_test.go index 9f554dc..bb04605 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "io" "os" "strings" "testing" @@ -79,6 +81,42 @@ func TestBootstrapCommandsDoNotLoadRegistry(t *testing.T) { } } +func TestSelfUpdateCheckDoesNotLoadPolicyOrRegistry(t *testing.T) { + oldArgs := os.Args + oldLoadRegistry := loadRegistry + oldLoadPolicy := loadPolicy + oldRunSelfUpdateCheck := runSelfUpdateCheck + defer func() { + os.Args = oldArgs + loadRegistry = oldLoadRegistry + loadPolicy = oldLoadPolicy + runSelfUpdateCheck = oldRunSelfUpdateCheck + }() + + loadRegistry = func() (registry.Registry, string, error) { + panic("self-update check attempted to load the registry") + } + loadPolicy = func() (policy.Policy, error) { + panic("self-update check attempted to load machine policy") + } + runSelfUpdateCheck = func(_ context.Context, current string, args []string, out io.Writer) error { + if current != "dev" { + t.Fatalf("current version = %q, want dev", current) + } + if strings.Join(args, " ") != "--check --version v1.1.0" { + t.Fatalf("self-update args = %q", args) + } + _, err := io.WriteString(out, "self-update seam reached\n") + return err + } + os.Args = []string{"cb.exe", "self-update", "--check", "--version", "v1.1.0"} + + out := captureMainStdout(t, main) + if !strings.Contains(out, "self-update seam reached") { + t.Fatalf("output %q does not contain self-update marker", out) + } +} + func captureMainStdout(t *testing.T, fn func()) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "stdout-*.txt")