From 8a7a78043dc9780e74420c29633df60d2a46e22a Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:52:01 +0100 Subject: [PATCH 1/3] Ship native WSL release artifact --- .github/workflows/release.yml | 55 +++++++++++++++++---- README.md | 12 ++++- docs/release-matrix.md | 16 ++++++ docs/roadmap-decisions.md | 2 +- docs/roadmap-implementation-requirements.md | 2 +- docs/wsl.md | 34 +++++++++++++ internal/selfupdate/selfupdate.go | 21 +++++++- internal/selfupdate/selfupdate_test.go | 44 +++++++++++++++++ internal/selfupdate/verify.go | 23 +++++++-- internal/selfupdate/verify_test.go | 30 +++++++++++ 10 files changed, 219 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 776602a..4953110 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,8 @@ jobs: fi AMD64_ARCHIVE="container-bin-${VERSION}-windows-amd64.zip" ARM64_ARCHIVE="container-bin-${VERSION}-windows-arm64.zip" - mkdir -p dist/package-amd64 dist/package-arm64 + WSL_AMD64_ARCHIVE="container-bin-${VERSION}-linux-amd64.tar.gz" + mkdir -p dist/package-amd64 dist/package-arm64 dist/package-linux-amd64 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ go build -trimpath -buildvcs=false \ @@ -69,16 +70,23 @@ jobs: go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ -o dist/package-arm64/cb.exe . + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -buildvcs=false \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o dist/package-linux-amd64/cb . cp dist/cb.exe dist/package-amd64/cb.exe cp LICENSE README.md dist/package-amd64/ cp LICENSE README.md dist/package-arm64/ + cp LICENSE README.md dist/package-linux-amd64/ + test "$(dist/package-linux-amd64/cb version)" = "container-bin ${VERSION}" # ZIP stores DOS timestamps. Normalize them and strip extra metadata so # separate runners/checkouts produce byte-identical archives. touch -d '1980-01-01 00:00:00 UTC' \ dist/cb.exe \ dist/package-amd64/cb.exe dist/package-amd64/LICENSE dist/package-amd64/README.md \ - dist/package-arm64/cb.exe dist/package-arm64/LICENSE dist/package-arm64/README.md + dist/package-arm64/cb.exe dist/package-arm64/LICENSE dist/package-arm64/README.md \ + dist/package-linux-amd64/cb dist/package-linux-amd64/LICENSE dist/package-linux-amd64/README.md ( cd dist/package-amd64 zip -X "../${AMD64_ARCHIVE}" cb.exe LICENSE README.md @@ -87,18 +95,28 @@ jobs: cd dist/package-arm64 zip -X "../${ARM64_ARCHIVE}" cb.exe LICENSE README.md ) + ( + cd dist/package-linux-amd64 + tar --format=ustar --owner=0 --group=0 --numeric-owner --mtime='@0' \ + -cf - cb LICENSE README.md | gzip -n > "../${WSL_AMD64_ARCHIVE}" + ) + diff -u \ + <(printf 'LICENSE\nREADME.md\ncb\n') \ + <(tar -tzf "dist/${WSL_AMD64_ARCHIVE}" | sort) ( cd dist sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" \ + "${WSL_AMD64_ARCHIVE}" > SHA256SUMS ) - rm -rf dist/package-amd64 dist/package-arm64 + rm -rf dist/package-amd64 dist/package-arm64 dist/package-linux-amd64 cat dist/SHA256SUMS echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "AMD64_ARCHIVE=${AMD64_ARCHIVE}" >> "$GITHUB_ENV" echo "ARM64_ARCHIVE=${ARM64_ARCHIVE}" >> "$GITHUB_ENV" + echo "WSL_AMD64_ARCHIVE=${WSL_AMD64_ARCHIVE}" >> "$GITHUB_ENV" - name: Verify checksums shell: bash @@ -158,6 +176,7 @@ jobs: path: | dist/cb.exe dist/*.zip + dist/*.tar.gz dist/SHA256SUMS if-no-files-found: error retention-days: 14 @@ -197,7 +216,8 @@ jobs: fi AMD64_ARCHIVE="container-bin-${VERSION}-windows-amd64.zip" ARM64_ARCHIVE="container-bin-${VERSION}-windows-arm64.zip" - mkdir -p second/package-amd64 second/package-arm64 + WSL_AMD64_ARCHIVE="container-bin-${VERSION}-linux-amd64.tar.gz" + mkdir -p second/package-amd64 second/package-arm64 second/package-linux-amd64 GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ @@ -206,13 +226,19 @@ jobs: go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ -o second/package-arm64/cb.exe . + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -buildvcs=false \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o second/package-linux-amd64/cb . cp second/cb.exe second/package-amd64/cb.exe cp LICENSE README.md second/package-amd64/ cp LICENSE README.md second/package-arm64/ + cp LICENSE README.md second/package-linux-amd64/ touch -d '1980-01-01 00:00:00 UTC' \ second/cb.exe \ second/package-amd64/cb.exe second/package-amd64/LICENSE second/package-amd64/README.md \ - second/package-arm64/cb.exe second/package-arm64/LICENSE second/package-arm64/README.md + second/package-arm64/cb.exe second/package-arm64/LICENSE second/package-arm64/README.md \ + second/package-linux-amd64/cb second/package-linux-amd64/LICENSE second/package-linux-amd64/README.md ( cd second/package-amd64 zip -X "../${AMD64_ARCHIVE}" cb.exe LICENSE README.md >/dev/null @@ -221,17 +247,23 @@ jobs: cd second/package-arm64 zip -X "../${ARM64_ARCHIVE}" cb.exe LICENSE README.md >/dev/null ) + ( + cd second/package-linux-amd64 + tar --format=ustar --owner=0 --group=0 --numeric-owner --mtime='@0' \ + -cf - cb LICENSE README.md | gzip -n > "../${WSL_AMD64_ARCHIVE}" + ) ( cd second sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" \ + "${WSL_AMD64_ARCHIVE}" > SHA256SUMS ) - for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" SHA256SUMS; do + for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS; do cmp "first/${artifact}" "second/${artifact}" done - echo 'independent runner reproduced binaries, archives and checksum manifest byte-for-byte' + echo 'independent runner reproduced Windows and native WSL artifacts plus checksum manifest byte-for-byte' release: name: Draft release @@ -255,10 +287,12 @@ jobs: run: | set -euo pipefail test "$(find dist -maxdepth 1 -type f -name '*.zip' | wc -l)" -eq 2 + test "$(find dist -maxdepth 1 -type f -name '*.tar.gz' | wc -l)" -eq 1 test "$(find dist -maxdepth 1 -type f -name '*.exe' | wc -l)" -eq 1 test -f dist/cb.exe test -f "dist/container-bin-${GITHUB_REF_NAME}-windows-amd64.zip" test -f "dist/container-bin-${GITHUB_REF_NAME}-windows-arm64.zip" + test -f "dist/container-bin-${GITHUB_REF_NAME}-linux-amd64.tar.gz" test -f dist/SHA256SUMS (cd dist && sha256sum -c SHA256SUMS) @@ -312,6 +346,7 @@ jobs: subject-path: | dist/cb.exe dist/*.zip + dist/*.tar.gz - name: Create immutable-ready draft release env: @@ -354,4 +389,4 @@ jobs: fi gh release create "${GITHUB_REF_NAME}" \ "${RELEASE_ARGS[@]}" \ - dist/cb.exe dist/*.zip dist/SHA256SUMS + dist/cb.exe dist/*.zip dist/*.tar.gz dist/SHA256SUMS diff --git a/README.md b/README.md index 986df45..cea4ba9 100644 --- a/README.md +++ b/README.md @@ -1214,9 +1214,19 @@ download. The ARM64 archive is release-provenance coverage, not a support claim: full Windows ARM64 support still requires real Docker Desktop qualification. +The v2 release also includes +`container-bin-VERSION-linux-amd64.tar.gz`, containing exactly the native `cb` +binary, `LICENSE` and `README.md`. It is the bootstrap artifact for the supported +WSL2 frontend, not a standalone-Linux support claim. Verify the archive itself +with `gh attestation verify ... --repo AviBackToBlack/container-bin` before +extracting it in the distribution-local Linux filesystem; the explicit +bootstrap/install sequence is documented in [docs/wsl.md](docs/wsl.md). + `cb self-update --check` selects the raw `cb.exe` on Windows amd64 and the ARM64 archive on Windows arm64 directly from Go's native `GOARCH`; unsupported architectures fail explicitly. The verifier authenticates the selected asset before extracting the ARM64 `cb.exe`, accepts the legacy two-entry checksum manifest for pre-ARM64 amd64 releases, and requires the canonical three-entry -manifest for dual-architecture releases. +manifest for dual-Windows-architecture releases. V2 releases require the +canonical four-entry manifest that also binds the native WSL archive; this +changes no Windows artifact selection or replacement behavior. diff --git a/docs/release-matrix.md b/docs/release-matrix.md index ae40501..277bde3 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -193,6 +193,22 @@ So CI validates compilation and pure/unit logic on a GitHub-hosted Windows runner. The matrix is what validates the `docs/shell-contract.md` semantics on a real Windows 11 + Docker Desktop host before a release. +### Native WSL release artifact + +V2 release candidates add one deterministic +`container-bin-VERSION-linux-amd64.tar.gz` archive containing exactly `cb`, +`LICENSE` and `README.md`. The release workflow executes the contained binary, +checks its injected version, includes the archive in `SHA256SUMS`, independently +reproduces it byte-for-byte and attests it with the Windows assets. + +For the real WSL2 qualification cell, retain the archive checksum and +`gh attestation verify` output, extract it only on the distribution-local Linux +filesystem, run its `cb version`, and use that exact binary for +`cb wsl prepare --apply` and `cb wsl install --apply`. Confirm the installed +`~/.local/bin/cb version` matches before exercising managed tool shims. The +archive is not standalone-Linux evidence and does not qualify WSL on Windows +ARM64 hardware. + ### Live published-release self-update qualification The repository includes a Windows/amd64 harness that builds the current source diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 347aae3..f4fdffb 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -16,7 +16,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | RM-24 Python / uv | **Keep both** | Decision complete. Built-in `python`/`pip` keep the dedicated Python provider; `uv`/`uvx` remain separate opt-in stateful profiles. | | RM-26 Python global CLI exposure | **pipx yes; plain pip expose no** | Completed in PR #74. The separate stateful pipx profile and managed store shipped; project/compat `/venv/bin` remains intentionally unexposed. | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all and explicit binary selection are sufficient. Reopen only for a concrete unmet use case. | -| WSL2 | **Native WSL frontend** | Fixed-layout install, managed-tool runtime/Docker composition, proof-bound retained-container reconciliation, and the v2-required state/GC lifecycle are wired. Integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | +| WSL2 | **Native WSL frontend** | Fixed-layout install, managed-tool runtime/Docker composition, proof-bound retained-container reconciliation, v2-required state/GC, and the deterministic Linux/amd64 bootstrap artifact are wired. Integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | | Enterprise policy | **Machine-owned constraint layer** | Foundation shipped in PR #75. Authenticated registry and image-trust follow-ups must extend this boundary and cannot be weakened by lower layers. | | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 33f62c8..4075322 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Native runtime/state wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup, proof-bound state/GC, orphan reconciliation and exit propagation are composed. Integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | +| WSL2 | **Native runtime/state/artifact wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup, proof-bound state/GC, orphan reconciliation, exit propagation and deterministic Linux/amd64 bootstrap artifact are composed. Integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | diff --git a/docs/wsl.md b/docs/wsl.md index 14f1da7..8b67eda 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -41,6 +41,40 @@ Environment variables alone never promote an ordinary Linux kernel to WSL2. Custom kernels that remove the Microsoft WSL2 identity markers fail closed; Docker Desktop also documents custom WSL kernels as unsupported. +## Release artifact and bootstrap + +Starting with v2, the release workflow publishes the deterministic, +provenance-attested `container-bin-VERSION-linux-amd64.tar.gz` archive for the +native WSL frontend. The archive contains exactly `cb`, `LICENSE` and +`README.md`. Its Linux name describes the executable format; it is a WSL2 +bootstrap artifact and does not create a standalone-Linux support claim. + +Download and verify the archive inside a distribution-local temporary +directory, not under `/mnt/*`, then install from that exact running binary: + +```bash +VERSION=v2.0.0 +ARCHIVE="container-bin-${VERSION}-linux-amd64.tar.gz" +BOOTSTRAP_DIR="$(mktemp -d "${HOME}/.container-bin-bootstrap.XXXXXX")" +cd "${BOOTSTRAP_DIR}" +curl --fail --location --remote-name \ + "https://github.com/AviBackToBlack/container-bin/releases/download/${VERSION}/${ARCHIVE}" +curl --fail --location --remote-name \ + "https://github.com/AviBackToBlack/container-bin/releases/download/${VERSION}/SHA256SUMS" +grep -F " ${ARCHIVE}" SHA256SUMS | sha256sum --check - +gh attestation verify "${ARCHIVE}" --repo AviBackToBlack/container-bin +tar -xzf "${ARCHIVE}" +./cb wsl prepare --apply +./cb wsl install --apply +~/.local/bin/cb version +``` + +The checksum detects corruption but is not an authentication mechanism; +GitHub provenance verification authenticates the archive. Remove only the +exact `BOOTSTRAP_DIR` after the managed installation has been revalidated. +Native WSL self-update is not implied: future upgrades use a newly verified +bootstrap archive and the same explicit install transaction. + ## Native layout and state identity The WSL frontend uses fixed, distribution-local locations. `XDG_*`, `PATH` and diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index f6e7b6a..0520f93 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -66,6 +66,7 @@ type checksumLayout uint8 const ( checksumLayoutLegacyAMD64 checksumLayout = iota + 1 checksumLayoutDualArch + checksumLayoutV2WSL ) type downgradeAuthorization struct { @@ -399,10 +400,12 @@ func validateRelease(selected release, goarch string) (Asset, Asset, Asset, chec } amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", tag.raw) arm64Archive := fmt.Sprintf("container-bin-%s-windows-arm64.zip", tag.raw) + wslArchive := fmt.Sprintf("container-bin-%s-linux-amd64.tar.gz", tag.raw) wanted := map[string]int64{ "cb.exe": maxBinarySize, amd64Archive: maxArchiveSize, arm64Archive: maxArchiveSize, + wslArchive: maxArchiveSize, "SHA256SUMS": maxChecksumSize, } found := map[string]Asset{} @@ -428,11 +431,25 @@ func validateRelease(selected release, goarch string) (Asset, Asset, Asset, chec return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", name) } } + if tag.major >= 2 { + for _, name := range []string{arm64Archive, wslArchive} { + if _, ok := found[name]; !ok { + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", name) + } + } + } layout := checksumLayoutLegacyAMD64 - if _, ok := found[arm64Archive]; ok { + _, hasARM64 := found[arm64Archive] + _, hasWSL := found[wslArchive] + if hasWSL && !hasARM64 { + return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", arm64Archive) + } + if hasARM64 && hasWSL { + layout = checksumLayoutV2WSL + } else if hasARM64 { layout = checksumLayoutDualArch } - if goarch == "arm64" && layout != checksumLayoutDualArch { + if goarch == "arm64" && !hasARM64 { return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", arm64Archive) } if goarch == "arm64" { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index d517704..c79acba 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -135,6 +135,30 @@ func TestPlanSelectsARM64ArchiveFromGOARCH(t *testing.T) { } } +func TestPlanRequiresCanonicalWSLArtifactForV2(t *testing.T) { + selected := canonicalDualArchRelease("v2.0.0", false) + _, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err == nil || !strings.Contains(err.Error(), "container-bin-v2.0.0-linux-amd64.tar.gz") { + t.Fatalf("v2 release without WSL artifact error = %v", err) + } + + selected = canonicalV2Release("v2.0.0", false) + plan, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + if plan.Binary.Name != "cb.exe" || plan.checksumLayout != checksumLayoutV2WSL { + t.Fatalf("unexpected v2 release plan: %+v", plan) + } + arm64Plan, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "arm64", Options{Check: true}) + if err != nil { + t.Fatal(err) + } + if arm64Plan.Binary.Name != "container-bin-v2.0.0-windows-arm64.zip" || arm64Plan.checksumLayout != checksumLayoutV2WSL { + t.Fatalf("unexpected v2 ARM64 release plan: %+v", arm64Plan) + } +} + func TestPlanExactAndDowngradePolicy(t *testing.T) { selected := canonicalRelease("v1.0.0", false) c := checker{doer: releaseDoer(t, apiRoot+"/releases/tags/v1.0.0", selected)} @@ -240,6 +264,15 @@ func TestPlanRejectsUnsafeCompanionARM64MetadataOnAMD64(t *testing.T) { } } +func TestPlanRejectsUnsafeCompanionWSLMetadataOnAMD64(t *testing.T) { + selected := canonicalV2Release("v2.0.0", false) + selected.Assets[len(selected.Assets)-1].BrowserDownloadURL = "https://evil.example/wsl.tar.gz" + _, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) + if err == nil || !strings.Contains(err.Error(), "non-canonical download URL") { + t.Fatalf("unsafe companion WSL metadata error = %v", err) + } +} + func TestGetJSONBoundsAndTransportFailures(t *testing.T) { for _, tc := range []struct { name string @@ -307,6 +340,17 @@ func canonicalDualArchRelease(tag string, prerelease bool) release { return release } +func canonicalV2Release(tag string, prerelease bool) release { + release := canonicalDualArchRelease(tag, prerelease) + name := "container-bin-" + tag + "-linux-amd64.tar.gz" + release.Assets = append(release.Assets, releaseAsset{ + Name: name, + Size: 2 << 20, + BrowserDownloadURL: releaseWebRoot + "/download/" + tag + "/" + name, + }) + return release +} + func releaseDoer(t *testing.T, wantURL string, value any) httpDoer { t.Helper() return doerFunc(func(req *http.Request) (*http.Response, error) { diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index f8fe263..1d60636 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -236,16 +236,12 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { binaryName := "cb.exe" archiveName := amd64Archive binaryLimit := int64(maxBinarySize) - wantLayout := checksumLayoutLegacyAMD64 if plan.Arch == "arm64" { binaryName = arm64Archive archiveName = arm64Archive binaryLimit = maxArchiveSize - wantLayout = checksumLayoutDualArch - } else if plan.checksumLayout == checksumLayoutDualArch { - wantLayout = checksumLayoutDualArch } - if plan.checksumLayout != wantLayout { + if !validChecksumLayout(target, plan.Arch, plan.checksumLayout) { return semanticVersion{}, errors.New("self-update verification plan has an unexpected checksum layout") } if plan.Binary.Name != binaryName || plan.Archive.Name != archiveName || plan.Checksums.Name != "SHA256SUMS" { @@ -271,6 +267,16 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { return target, nil } +func validChecksumLayout(target semanticVersion, arch string, layout checksumLayout) bool { + if target.major >= 2 { + return layout == checksumLayoutV2WSL + } + if arch == "arm64" { + return layout == checksumLayoutDualArch || layout == checksumLayoutV2WSL + } + return layout == checksumLayoutLegacyAMD64 || layout == checksumLayoutDualArch || layout == checksumLayoutV2WSL +} + func bindInstalledExecutable(ctx context.Context, path, version string) (installedIdentity, error) { clean, info, err := canonicalApplyFile(path, "installed management executable") if err != nil { @@ -437,6 +443,13 @@ func checksumManifestNames(plan Plan) ([]string, error) { return []string{"cb.exe", amd64Archive}, nil case checksumLayoutDualArch: return []string{"cb.exe", amd64Archive, fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw)}, nil + case checksumLayoutV2WSL: + return []string{ + "cb.exe", + amd64Archive, + fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw), + fmt.Sprintf("container-bin-%s-linux-amd64.tar.gz", target.raw), + }, nil default: return nil, errors.New("checksum manifest plan has an unexpected layout") } diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index e9ea199..97fa1e3 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -326,6 +326,36 @@ func TestVerifyAMD64AcceptsCanonicalDualArchitectureManifest(t *testing.T) { } } +func TestVerifyAMD64RequiresCanonicalV2WSLManifest(t *testing.T) { + fixture := newVerificationFixture(t) + fixture.plan.Target = "v2.0.0" + fixture.plan.ReleaseURL = releaseWebRoot + "/tag/v2.0.0" + fixture.plan.Archive.Name = "container-bin-v2.0.0-windows-amd64.zip" + fixture.plan.Archive.URL = releaseWebRoot + "/download/v2.0.0/" + fixture.plan.Archive.Name + fixture.plan.Binary.URL = releaseWebRoot + "/download/v2.0.0/cb.exe" + fixture.plan.Checksums.URL = releaseWebRoot + "/download/v2.0.0/SHA256SUMS" + fixture.plan.ExpectedRef = "refs/tags/v2.0.0" + fixture.plan.checksumLayout = checksumLayoutV2WSL + manifest := fixture.digest + " cb.exe\n" + + strings.Repeat("a", 64) + " container-bin-v2.0.0-windows-amd64.zip\n" + + strings.Repeat("b", 64) + " container-bin-v2.0.0-windows-arm64.zip\n" + + strings.Repeat("c", 64) + " container-bin-v2.0.0-linux-amd64.tar.gz\n" + if err := os.WriteFile(fixture.checksums, []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + fixture.plan.Checksums.Size = int64(len(manifest)) + if _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { + return attestationJSON(fixture.digest), nil, nil + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh); err != nil { + t.Fatal(err) + } + + fixture.plan.checksumLayout = checksumLayoutDualArch + if _, err := validateVerificationPlan(fixture.plan); err == nil || !strings.Contains(err.Error(), "unexpected checksum layout") { + t.Fatalf("v2 dual-architecture-only layout error = %v", err) + } +} + func TestVerifyARM64RejectsUnexpectedArchiveEntryAfterAuthentication(t *testing.T) { fixture := newARM64VerificationFixture(t, map[string][]byte{"unexpected.bin": []byte("unsafe")}) called := false From dd7bf61ee47d2f4d4a9e4a3d85de86da746abcd4 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:58:21 +0100 Subject: [PATCH 2/3] Fix WSL release reproducibility check --- .github/workflows/release.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4953110..40174e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,10 @@ jobs: shell: bash run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/repro/package-amd64" "$RUNNER_TEMP/repro/package-arm64" + mkdir -p \ + "$RUNNER_TEMP/repro/package-amd64" \ + "$RUNNER_TEMP/repro/package-arm64" \ + "$RUNNER_TEMP/repro/package-linux-amd64" GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ @@ -137,9 +140,14 @@ jobs: go build -trimpath -buildvcs=false \ -ldflags "-s -w -X main.version=${VERSION}" \ -o "$RUNNER_TEMP/repro/package-arm64/cb.exe" . + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ + go build -trimpath -buildvcs=false \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o "$RUNNER_TEMP/repro/package-linux-amd64/cb" . cp "$RUNNER_TEMP/repro/cb.exe" "$RUNNER_TEMP/repro/package-amd64/cb.exe" cp LICENSE README.md "$RUNNER_TEMP/repro/package-amd64/" cp LICENSE README.md "$RUNNER_TEMP/repro/package-arm64/" + cp LICENSE README.md "$RUNNER_TEMP/repro/package-linux-amd64/" touch -d '1980-01-01 00:00:00 UTC' \ "$RUNNER_TEMP/repro/cb.exe" \ "$RUNNER_TEMP/repro/package-amd64/cb.exe" \ @@ -147,7 +155,10 @@ jobs: "$RUNNER_TEMP/repro/package-amd64/README.md" \ "$RUNNER_TEMP/repro/package-arm64/cb.exe" \ "$RUNNER_TEMP/repro/package-arm64/LICENSE" \ - "$RUNNER_TEMP/repro/package-arm64/README.md" + "$RUNNER_TEMP/repro/package-arm64/README.md" \ + "$RUNNER_TEMP/repro/package-linux-amd64/cb" \ + "$RUNNER_TEMP/repro/package-linux-amd64/LICENSE" \ + "$RUNNER_TEMP/repro/package-linux-amd64/README.md" ( cd "$RUNNER_TEMP/repro/package-amd64" zip -X "../${AMD64_ARCHIVE}" cb.exe LICENSE README.md >/dev/null @@ -156,18 +167,24 @@ jobs: cd "$RUNNER_TEMP/repro/package-arm64" zip -X "../${ARM64_ARCHIVE}" cb.exe LICENSE README.md >/dev/null ) + ( + cd "$RUNNER_TEMP/repro/package-linux-amd64" + tar --format=ustar --owner=0 --group=0 --numeric-owner --mtime='@0' \ + -cf - cb LICENSE README.md | gzip -n > "../${WSL_AMD64_ARCHIVE}" + ) ( cd "$RUNNER_TEMP/repro" sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" \ + "${WSL_AMD64_ARCHIVE}" > SHA256SUMS ) - for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" SHA256SUMS; do + for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS; do sha256sum "dist/${artifact}" "$RUNNER_TEMP/repro/${artifact}" cmp "dist/${artifact}" "$RUNNER_TEMP/repro/${artifact}" done - echo 'amd64 and arm64 binaries, archives and checksum manifest are reproducible' + echo 'Windows and native WSL artifacts plus checksum manifest are reproducible' - name: Upload verified release bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From a1cf8198cca81f6d5b302d78b934a9a16706ba29 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:09:15 +0100 Subject: [PATCH 3/3] Preserve v1 self-update compatibility --- .github/workflows/release.yml | 40 ++++++++++----- README.md | 7 +-- docs/release-matrix.md | 7 ++- docs/roadmap-decisions.md | 6 ++- docs/wsl.md | 4 +- internal/selfupdate/selfupdate.go | 21 +------- internal/selfupdate/selfupdate_test.go | 71 ++++++++++++-------------- internal/selfupdate/verify.go | 23 ++------- internal/selfupdate/verify_test.go | 30 ----------- 9 files changed, 82 insertions(+), 127 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40174e6..22b1189 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,11 +108,15 @@ jobs: sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" \ - "${WSL_AMD64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" > SHA256SUMS + sha256sum "${WSL_AMD64_ARCHIVE}" > SHA256SUMS-WSL + test "$(wc -l < SHA256SUMS)" -eq 3 + test "$(wc -l < SHA256SUMS-WSL)" -eq 1 + test "$(awk '{print $2}' SHA256SUMS-WSL)" = "${WSL_AMD64_ARCHIVE}" ) rm -rf dist/package-amd64 dist/package-arm64 dist/package-linux-amd64 cat dist/SHA256SUMS + cat dist/SHA256SUMS-WSL echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "AMD64_ARCHIVE=${AMD64_ARCHIVE}" >> "$GITHUB_ENV" echo "ARM64_ARCHIVE=${ARM64_ARCHIVE}" >> "$GITHUB_ENV" @@ -122,7 +126,11 @@ jobs: shell: bash run: | set -euo pipefail - (cd dist && sha256sum -c SHA256SUMS) + ( + cd dist + sha256sum -c SHA256SUMS + sha256sum -c SHA256SUMS-WSL + ) - name: Verify reproducibility shell: bash @@ -177,10 +185,10 @@ jobs: sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" \ - "${WSL_AMD64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" > SHA256SUMS + sha256sum "${WSL_AMD64_ARCHIVE}" > SHA256SUMS-WSL ) - for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS; do + for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS SHA256SUMS-WSL; do sha256sum "dist/${artifact}" "$RUNNER_TEMP/repro/${artifact}" cmp "dist/${artifact}" "$RUNNER_TEMP/repro/${artifact}" done @@ -194,7 +202,7 @@ jobs: dist/cb.exe dist/*.zip dist/*.tar.gz - dist/SHA256SUMS + dist/SHA256SUMS* if-no-files-found: error retention-days: 14 @@ -274,10 +282,10 @@ jobs: sha256sum \ cb.exe \ "${AMD64_ARCHIVE}" \ - "${ARM64_ARCHIVE}" \ - "${WSL_AMD64_ARCHIVE}" > SHA256SUMS + "${ARM64_ARCHIVE}" > SHA256SUMS + sha256sum "${WSL_AMD64_ARCHIVE}" > SHA256SUMS-WSL ) - for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS; do + for artifact in cb.exe "${AMD64_ARCHIVE}" "${ARM64_ARCHIVE}" "${WSL_AMD64_ARCHIVE}" SHA256SUMS SHA256SUMS-WSL; do cmp "first/${artifact}" "second/${artifact}" done echo 'independent runner reproduced Windows and native WSL artifacts plus checksum manifest byte-for-byte' @@ -311,7 +319,15 @@ jobs: test -f "dist/container-bin-${GITHUB_REF_NAME}-windows-arm64.zip" test -f "dist/container-bin-${GITHUB_REF_NAME}-linux-amd64.tar.gz" test -f dist/SHA256SUMS - (cd dist && sha256sum -c SHA256SUMS) + test -f dist/SHA256SUMS-WSL + ( + cd dist + test "$(wc -l < SHA256SUMS)" -eq 3 + test "$(wc -l < SHA256SUMS-WSL)" -eq 1 + test "$(awk '{print $2}' SHA256SUMS-WSL)" = "container-bin-${GITHUB_REF_NAME}-linux-amd64.tar.gz" + sha256sum -c SHA256SUMS + sha256sum -c SHA256SUMS-WSL + ) - name: Refuse to release an untrusted tag commit env: @@ -406,4 +422,4 @@ jobs: fi gh release create "${GITHUB_REF_NAME}" \ "${RELEASE_ARGS[@]}" \ - dist/cb.exe dist/*.zip dist/*.tar.gz dist/SHA256SUMS + dist/cb.exe dist/*.zip dist/*.tar.gz dist/SHA256SUMS dist/SHA256SUMS-WSL diff --git a/README.md b/README.md index cea4ba9..585dd1f 100644 --- a/README.md +++ b/README.md @@ -1227,6 +1227,7 @@ ARM64 archive on Windows arm64 directly from Go's native `GOARCH`; unsupported architectures fail explicitly. The verifier authenticates the selected asset before extracting the ARM64 `cb.exe`, accepts the legacy two-entry checksum manifest for pre-ARM64 amd64 releases, and requires the canonical three-entry -manifest for dual-Windows-architecture releases. V2 releases require the -canonical four-entry manifest that also binds the native WSL archive; this -changes no Windows artifact selection or replacement behavior. +`SHA256SUMS` manifest for dual-Windows-architecture releases. The WSL archive +uses a separate one-entry `SHA256SUMS-WSL` manifest so already-released Windows +clients retain direct self-update compatibility with v2; this changes no +Windows artifact selection, verification or replacement behavior. diff --git a/docs/release-matrix.md b/docs/release-matrix.md index 277bde3..f8ca281 100644 --- a/docs/release-matrix.md +++ b/docs/release-matrix.md @@ -198,8 +198,11 @@ real Windows 11 + Docker Desktop host before a release. V2 release candidates add one deterministic `container-bin-VERSION-linux-amd64.tar.gz` archive containing exactly `cb`, `LICENSE` and `README.md`. The release workflow executes the contained binary, -checks its injected version, includes the archive in `SHA256SUMS`, independently -reproduces it byte-for-byte and attests it with the Windows assets. +checks its injected version, binds it in the separate one-entry +`SHA256SUMS-WSL` manifest, independently reproduces it byte-for-byte and attests +it with the Windows assets. The existing three-entry Windows `SHA256SUMS` +contract stays unchanged so released v1.x clients can self-update directly to +v2. For the real WSL2 qualification cell, retain the archive checksum and `gh attestation verify` output, extract it only on the distribution-local Linux diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index f4fdffb..60eb34d 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -206,8 +206,10 @@ and exactly one explicit `GH_TOKEN` or `GITHUB_TOKEN`. These host paths come from Windows APIs rather than inherited variables. GitHub host, config-directory, proxy, custom-CA and other inherited settings are not passed through. It accepts the legacy two-entry `SHA256SUMS` layout for pre-ARM64 amd64 releases -and requires the canonical three-entry layout for dual-architecture releases, -invokes +and requires the canonical three-entry layout for dual-Windows-architecture +releases. Native WSL bootstrap archives use the separate one-entry +`SHA256SUMS-WSL` manifest so Windows self-update compatibility remains +unchanged. The verifier invokes `gh attestation verify` with the repository, exact workflow-and-tag certificate identity, tag ref and SLSA provenance predicate fixed in argv, validates the reported subject digest and re-hashes the selected artifact after verification. diff --git a/docs/wsl.md b/docs/wsl.md index 8b67eda..38b7e6d 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -60,8 +60,8 @@ cd "${BOOTSTRAP_DIR}" curl --fail --location --remote-name \ "https://github.com/AviBackToBlack/container-bin/releases/download/${VERSION}/${ARCHIVE}" curl --fail --location --remote-name \ - "https://github.com/AviBackToBlack/container-bin/releases/download/${VERSION}/SHA256SUMS" -grep -F " ${ARCHIVE}" SHA256SUMS | sha256sum --check - + "https://github.com/AviBackToBlack/container-bin/releases/download/${VERSION}/SHA256SUMS-WSL" +sha256sum --check SHA256SUMS-WSL gh attestation verify "${ARCHIVE}" --repo AviBackToBlack/container-bin tar -xzf "${ARCHIVE}" ./cb wsl prepare --apply diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 0520f93..f6e7b6a 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -66,7 +66,6 @@ type checksumLayout uint8 const ( checksumLayoutLegacyAMD64 checksumLayout = iota + 1 checksumLayoutDualArch - checksumLayoutV2WSL ) type downgradeAuthorization struct { @@ -400,12 +399,10 @@ func validateRelease(selected release, goarch string) (Asset, Asset, Asset, chec } amd64Archive := fmt.Sprintf("container-bin-%s-windows-amd64.zip", tag.raw) arm64Archive := fmt.Sprintf("container-bin-%s-windows-arm64.zip", tag.raw) - wslArchive := fmt.Sprintf("container-bin-%s-linux-amd64.tar.gz", tag.raw) wanted := map[string]int64{ "cb.exe": maxBinarySize, amd64Archive: maxArchiveSize, arm64Archive: maxArchiveSize, - wslArchive: maxArchiveSize, "SHA256SUMS": maxChecksumSize, } found := map[string]Asset{} @@ -431,25 +428,11 @@ func validateRelease(selected release, goarch string) (Asset, Asset, Asset, chec return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", name) } } - if tag.major >= 2 { - for _, name := range []string{arm64Archive, wslArchive} { - if _, ok := found[name]; !ok { - return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", name) - } - } - } layout := checksumLayoutLegacyAMD64 - _, hasARM64 := found[arm64Archive] - _, hasWSL := found[wslArchive] - if hasWSL && !hasARM64 { - return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", arm64Archive) - } - if hasARM64 && hasWSL { - layout = checksumLayoutV2WSL - } else if hasARM64 { + if _, ok := found[arm64Archive]; ok { layout = checksumLayoutDualArch } - if goarch == "arm64" && !hasARM64 { + if goarch == "arm64" && layout != checksumLayoutDualArch { return Asset{}, Asset{}, Asset{}, 0, fmt.Errorf("release is missing required asset %q", arm64Archive) } if goarch == "arm64" { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index c79acba..1985c4e 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -135,27 +135,40 @@ func TestPlanSelectsARM64ArchiveFromGOARCH(t *testing.T) { } } -func TestPlanRequiresCanonicalWSLArtifactForV2(t *testing.T) { +func TestPlanKeepsV1ToV2WindowsSelfUpdateCompatibleWithSeparateWSLAssets(t *testing.T) { selected := canonicalDualArchRelease("v2.0.0", false) - _, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) - if err == nil || !strings.Contains(err.Error(), "container-bin-v2.0.0-linux-amd64.tar.gz") { - t.Fatalf("v2 release without WSL artifact error = %v", err) - } - - selected = canonicalV2Release("v2.0.0", false) - plan, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) - if err != nil { - t.Fatal(err) - } - if plan.Binary.Name != "cb.exe" || plan.checksumLayout != checksumLayoutV2WSL { - t.Fatalf("unexpected v2 release plan: %+v", plan) - } - arm64Plan, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "arm64", Options{Check: true}) - if err != nil { - t.Fatal(err) + for _, name := range []string{ + "container-bin-v2.0.0-linux-amd64.tar.gz", + "SHA256SUMS-WSL", + } { + selected.Assets = append(selected.Assets, releaseAsset{ + Name: name, + Size: 2 << 20, + BrowserDownloadURL: releaseWebRoot + "/download/v2.0.0/" + name, + }) } - if arm64Plan.Binary.Name != "container-bin-v2.0.0-windows-arm64.zip" || arm64Plan.checksumLayout != checksumLayoutV2WSL { - t.Fatalf("unexpected v2 ARM64 release plan: %+v", arm64Plan) + for _, tc := range []struct { + arch string + wantBinary string + }{ + {arch: "amd64", wantBinary: "cb.exe"}, + {arch: "arm64", wantBinary: "container-bin-v2.0.0-windows-arm64.zip"}, + } { + t.Run(tc.arch, func(t *testing.T) { + plan, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", tc.arch, Options{Check: true}) + if err != nil { + t.Fatal(err) + } + if plan.Binary.Name != tc.wantBinary || plan.checksumLayout != checksumLayoutDualArch { + t.Fatalf("unexpected v1 to v2 %s plan: %+v", tc.arch, plan) + } + if err := validateStagingPlan(plan); err != nil { + t.Fatalf("v1 to v2 %s staging validation: %v", tc.arch, err) + } + if _, err := validateVerificationPlan(plan); err != nil { + t.Fatalf("v1 to v2 %s verification validation: %v", tc.arch, err) + } + }) } } @@ -264,15 +277,6 @@ func TestPlanRejectsUnsafeCompanionARM64MetadataOnAMD64(t *testing.T) { } } -func TestPlanRejectsUnsafeCompanionWSLMetadataOnAMD64(t *testing.T) { - selected := canonicalV2Release("v2.0.0", false) - selected.Assets[len(selected.Assets)-1].BrowserDownloadURL = "https://evil.example/wsl.tar.gz" - _, err := (checker{doer: releaseDoer(t, apiRoot+"/releases/latest", selected)}).Plan(context.Background(), "v1.1.0", "windows", "amd64", Options{Check: true}) - if err == nil || !strings.Contains(err.Error(), "non-canonical download URL") { - t.Fatalf("unsafe companion WSL metadata error = %v", err) - } -} - func TestGetJSONBoundsAndTransportFailures(t *testing.T) { for _, tc := range []struct { name string @@ -340,17 +344,6 @@ func canonicalDualArchRelease(tag string, prerelease bool) release { return release } -func canonicalV2Release(tag string, prerelease bool) release { - release := canonicalDualArchRelease(tag, prerelease) - name := "container-bin-" + tag + "-linux-amd64.tar.gz" - release.Assets = append(release.Assets, releaseAsset{ - Name: name, - Size: 2 << 20, - BrowserDownloadURL: releaseWebRoot + "/download/" + tag + "/" + name, - }) - return release -} - func releaseDoer(t *testing.T, wantURL string, value any) httpDoer { t.Helper() return doerFunc(func(req *http.Request) (*http.Response, error) { diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index 1d60636..f8fe263 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -236,12 +236,16 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { binaryName := "cb.exe" archiveName := amd64Archive binaryLimit := int64(maxBinarySize) + wantLayout := checksumLayoutLegacyAMD64 if plan.Arch == "arm64" { binaryName = arm64Archive archiveName = arm64Archive binaryLimit = maxArchiveSize + wantLayout = checksumLayoutDualArch + } else if plan.checksumLayout == checksumLayoutDualArch { + wantLayout = checksumLayoutDualArch } - if !validChecksumLayout(target, plan.Arch, plan.checksumLayout) { + if plan.checksumLayout != wantLayout { return semanticVersion{}, errors.New("self-update verification plan has an unexpected checksum layout") } if plan.Binary.Name != binaryName || plan.Archive.Name != archiveName || plan.Checksums.Name != "SHA256SUMS" { @@ -267,16 +271,6 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { return target, nil } -func validChecksumLayout(target semanticVersion, arch string, layout checksumLayout) bool { - if target.major >= 2 { - return layout == checksumLayoutV2WSL - } - if arch == "arm64" { - return layout == checksumLayoutDualArch || layout == checksumLayoutV2WSL - } - return layout == checksumLayoutLegacyAMD64 || layout == checksumLayoutDualArch || layout == checksumLayoutV2WSL -} - func bindInstalledExecutable(ctx context.Context, path, version string) (installedIdentity, error) { clean, info, err := canonicalApplyFile(path, "installed management executable") if err != nil { @@ -443,13 +437,6 @@ func checksumManifestNames(plan Plan) ([]string, error) { return []string{"cb.exe", amd64Archive}, nil case checksumLayoutDualArch: return []string{"cb.exe", amd64Archive, fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw)}, nil - case checksumLayoutV2WSL: - return []string{ - "cb.exe", - amd64Archive, - fmt.Sprintf("container-bin-%s-windows-arm64.zip", target.raw), - fmt.Sprintf("container-bin-%s-linux-amd64.tar.gz", target.raw), - }, nil default: return nil, errors.New("checksum manifest plan has an unexpected layout") } diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 97fa1e3..e9ea199 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -326,36 +326,6 @@ func TestVerifyAMD64AcceptsCanonicalDualArchitectureManifest(t *testing.T) { } } -func TestVerifyAMD64RequiresCanonicalV2WSLManifest(t *testing.T) { - fixture := newVerificationFixture(t) - fixture.plan.Target = "v2.0.0" - fixture.plan.ReleaseURL = releaseWebRoot + "/tag/v2.0.0" - fixture.plan.Archive.Name = "container-bin-v2.0.0-windows-amd64.zip" - fixture.plan.Archive.URL = releaseWebRoot + "/download/v2.0.0/" + fixture.plan.Archive.Name - fixture.plan.Binary.URL = releaseWebRoot + "/download/v2.0.0/cb.exe" - fixture.plan.Checksums.URL = releaseWebRoot + "/download/v2.0.0/SHA256SUMS" - fixture.plan.ExpectedRef = "refs/tags/v2.0.0" - fixture.plan.checksumLayout = checksumLayoutV2WSL - manifest := fixture.digest + " cb.exe\n" + - strings.Repeat("a", 64) + " container-bin-v2.0.0-windows-amd64.zip\n" + - strings.Repeat("b", 64) + " container-bin-v2.0.0-windows-arm64.zip\n" + - strings.Repeat("c", 64) + " container-bin-v2.0.0-linux-amd64.tar.gz\n" - if err := os.WriteFile(fixture.checksums, []byte(manifest), 0o600); err != nil { - t.Fatal(err) - } - fixture.plan.Checksums.Size = int64(len(manifest)) - if _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { - return attestationJSON(fixture.digest), nil, nil - })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh); err != nil { - t.Fatal(err) - } - - fixture.plan.checksumLayout = checksumLayoutDualArch - if _, err := validateVerificationPlan(fixture.plan); err == nil || !strings.Contains(err.Error(), "unexpected checksum layout") { - t.Fatalf("v2 dual-architecture-only layout error = %v", err) - } -} - func TestVerifyARM64RejectsUnexpectedArchiveEntryAfterAuthentication(t *testing.T) { fixture := newARM64VerificationFixture(t, map[string][]byte{"unexpected.bin": []byte("unsafe")}) called := false