From cd1c6d690f8fd547602e63f4b0c3ed702758e2cd Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:30:06 +0100 Subject: [PATCH 1/6] feat: add rollback-safe self-update transaction --- README.md | 20 +- internal/selfupdate/apply.go | 374 ++++++++++++++++++++++++++++++ internal/selfupdate/apply_test.go | 242 +++++++++++++++++++ internal/selfupdate/selfupdate.go | 7 +- 4 files changed, 634 insertions(+), 9 deletions(-) create mode 100644 internal/selfupdate/apply.go create mode 100644 internal/selfupdate/apply_test.go diff --git a/README.md b/README.md index 2ba97cd..afdf439 100644 --- a/README.md +++ b/README.md @@ -875,12 +875,20 @@ 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. +`cb self-update --check` is the currently exposed, read-only command. It +compares a release-qualified Windows/amd64 build with the latest stable release +and reports the exact binary, archive, checksum and provenance policy. It does +not download assets or change any files, and development builds fail closed +because their installed version cannot be proved. + +The internal next phases use private same-volume staging, exact checksum plus +GitHub build-provenance verification, and a rollback-safe Windows replacement +transaction. That transaction re-hashes the verified bytes before mutation, +serializes with registry/shim changes, discovers only shims proven to contain +the installed ContainerBin bytes, replaces and checks the complete proven set, +and restores the prior set after any smoke-test or identity failure. The helper +that waits for the invoking process to exit and the user-facing apply command +are not wired yet, so `--check` remains the only accepted command mode. Stable selection is the default. Use `--prerelease` to select the highest canonical prerelease among the 30 most recent published releases, or diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go new file mode 100644 index 0000000..82cdafc --- /dev/null +++ b/internal/selfupdate/apply.go @@ -0,0 +1,374 @@ +package selfupdate + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/AviBackToBlack/container-bin/internal/mutationlock" + "github.com/AviBackToBlack/container-bin/internal/registry" +) + +const ( + rollbackPrefix = ".container-bin-update-rollback-" + applyTimeout = 30 * time.Second +) + +type applyTransaction struct { + replace func(string, string, bool) error + smoke func(context.Context, string, string) error +} + +// ApplyVerified replaces an installed Windows management executable with an +// opaque, previously verified release artifact. It is intended to run only in +// the self-update helper after the invoking ContainerBin process has exited. +// The helper/waiting command surface is a separate slice; exposing this +// transaction does not make ordinary self-update mutate installed files. +func ApplyVerified(ctx context.Context, verified Verified, installedExecutable string) error { + if runtime.GOOS != "windows" { + return errors.New("self-update replacement is supported only on native Windows") + } + installedExecutable, _, err := canonicalApplyFile(installedExecutable, "installed management executable") + if err != nil { + return err + } + if !strings.EqualFold(filepath.Base(installedExecutable), "cb.exe") { + return fmt.Errorf("installed management executable must be named cb.exe, got %q", filepath.Base(installedExecutable)) + } + unlock, err := mutationlock.Acquire(filepath.Join(filepath.Dir(installedExecutable), "container-bin.toml"), mutationlock.Wait) + if err != nil { + return fmt.Errorf("serialize self-update replacement: %w", err) + } + defer unlock() + return (applyTransaction{replace: replaceManagedFile, smoke: smokeUpdatedBinary}).apply(ctx, verified, installedExecutable) +} + +func (tx applyTransaction) apply(ctx context.Context, verified Verified, installedExecutable string) (err error) { + if tx.replace == nil || tx.smoke == nil { + return errors.New("self-update replacement transaction is incomplete") + } + installedExecutable, installedInfo, err := canonicalApplyFile(installedExecutable, "installed management executable") + if err != nil { + return err + } + if !strings.EqualFold(filepath.Base(installedExecutable), "cb.exe") { + return fmt.Errorf("installed management executable must be named cb.exe, got %q", filepath.Base(installedExecutable)) + } + target, err := parseVersion(verified.target) + if err != nil { + return fmt.Errorf("verified self-update target: %w", err) + } + staged, stagedInfo, err := canonicalApplyFile(verified.binaryPath, "verified staged executable") + if err != nil { + return err + } + installDir := filepath.Dir(installedExecutable) + stageDir := filepath.Dir(staged) + if filepath.Base(staged) != "cb.exe" || filepath.Dir(stageDir) != installDir || !strings.HasPrefix(filepath.Base(stageDir), stagingPrefix) { + return errors.New("verified executable is outside the exact private same-volume staging layout") + } + if !strings.EqualFold(filepath.VolumeName(staged), filepath.VolumeName(installedExecutable)) { + return errors.New("verified executable is not on the installed executable volume") + } + stagedDigest, stagedSize, err := hashVerificationFile(staged, stagedInfo) + if err != nil { + return fmt.Errorf("revalidate verified staged executable: %w", err) + } + if stagedDigest != verified.digest || stagedSize != verified.size { + return errors.New("verified staged executable changed before replacement") + } + oldDigest, _, err := hashVerificationFile(installedExecutable, installedInfo) + if err != nil { + return fmt.Errorf("hash installed management executable: %w", err) + } + if installedInfo.Size() <= 0 || installedInfo.Size() > maxBinarySize { + return fmt.Errorf("installed management executable size %d is outside the self-update safety limit", installedInfo.Size()) + } + if oldDigest == stagedDigest { + return errors.New("verified update bytes are identical to the installed management executable") + } + shims, err := discoverManagedShims(installDir, installedExecutable, installedInfo, oldDigest) + if err != nil { + return err + } + + rollbackDir, err := os.MkdirTemp(installDir, rollbackPrefix) + if err != nil { + return fmt.Errorf("create self-update rollback directory: %w", err) + } + keepRollback := false + defer func() { + if !keepRollback { + if cleanupErr := os.RemoveAll(rollbackDir); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("remove self-update rollback directory: %w", cleanupErr)) + } + } + }() + if err := restrictStagingPath(rollbackDir, true); err != nil { + return fmt.Errorf("restrict self-update rollback directory: %w", err) + } + rollbackBinary := filepath.Join(rollbackDir, "cb.exe") + if err := copyApplyFile(installedExecutable, rollbackBinary); err != nil { + return fmt.Errorf("create self-update rollback executable: %w", err) + } + if err := restrictStagingPath(rollbackBinary, false); err != nil { + return fmt.Errorf("restrict self-update rollback executable: %w", err) + } + rollbackInfo, err := os.Lstat(rollbackBinary) + if err != nil { + return fmt.Errorf("inspect self-update rollback executable: %w", err) + } + rollbackDigest, _, err := hashVerificationFile(rollbackBinary, rollbackInfo) + if err != nil || rollbackDigest != oldDigest { + return errors.New("self-update rollback executable does not match installed bytes") + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("self-update canceled before installed-file mutation: %w", err) + } + + changed := false + fail := func(cause error) error { + if !changed { + return cause + } + rollbackErr := tx.rollback(installedExecutable, rollbackBinary, shims, oldDigest, stagedDigest) + if rollbackErr != nil { + keepRollback = true + return errors.Join(cause, fmt.Errorf("self-update rollback failed; recovery executable preserved at %s: %w", rollbackBinary, rollbackErr)) + } + return errors.Join(cause, errors.New("installed ContainerBin files were restored to their previous bytes")) + } + + if err := requireApplyDigest(installedExecutable, oldDigest, "management executable immediately before replacement"); err != nil { + return err + } + if err := tx.replace(staged, installedExecutable, false); err != nil { + if digest, digestErr := applyFileDigest(installedExecutable); digestErr == nil && digest == stagedDigest { + changed = true + return fail(fmt.Errorf("replace installed management executable reported failure after changing bytes: %w", err)) + } + return fmt.Errorf("replace installed management executable: %w", err) + } + changed = true + if err := requireApplyDigest(installedExecutable, stagedDigest, "updated management executable"); err != nil { + return fail(err) + } + for _, shim := range shims { + if err := requireApplyDigest(shim, oldDigest, "managed shim before replacement"); err != nil { + return fail(err) + } + if err := tx.replace(installedExecutable, shim, true); err != nil { + return fail(fmt.Errorf("replace managed shim %s: %w", shim, err)) + } + } + if err := tx.smoke(ctx, installedExecutable, target.raw); err != nil { + return fail(fmt.Errorf("updated management executable smoke test: %w", err)) + } + if err := requireApplyDigest(installedExecutable, stagedDigest, "updated management executable after smoke test"); err != nil { + return fail(err) + } + for _, shim := range shims { + if err := requireApplyDigest(shim, stagedDigest, "managed shim after replacement"); err != nil { + return fail(err) + } + } + return nil +} + +func (tx applyTransaction) rollback(installedExecutable, rollbackBinary string, shims []string, oldDigest, newDigest string) error { + var result error + if digest, err := applyFileDigest(installedExecutable); err != nil { + result = errors.Join(result, err) + } else if digest == newDigest { + if err := tx.replace(rollbackBinary, installedExecutable, false); err != nil { + result = errors.Join(result, fmt.Errorf("restore management executable: %w", err)) + } + } else if digest != oldDigest { + result = errors.Join(result, errors.New("management executable changed outside the update transaction; refusing to overwrite it during rollback")) + } + for _, shim := range shims { + digest, err := applyFileDigest(shim) + if err != nil { + result = errors.Join(result, err) + continue + } + if digest == oldDigest { + continue + } + if digest != newDigest { + result = errors.Join(result, fmt.Errorf("managed shim %s changed outside the update transaction; refusing to overwrite it during rollback", shim)) + continue + } + if err := tx.replace(rollbackBinary, shim, true); err != nil { + result = errors.Join(result, fmt.Errorf("restore managed shim %s: %w", shim, err)) + } + } + if result == nil { + if err := requireApplyDigest(installedExecutable, oldDigest, "restored management executable"); err != nil { + result = errors.Join(result, err) + } + for _, shim := range shims { + if err := requireApplyDigest(shim, oldDigest, "restored managed shim"); err != nil { + result = errors.Join(result, err) + } + } + } + return result +} + +func discoverManagedShims(dir, installedExecutable string, installedInfo os.FileInfo, oldDigest string) ([]string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("enumerate installed shims: %w", err) + } + var shims []string + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".exe") { + continue + } + path := filepath.Join(dir, entry.Name()) + if strings.EqualFold(path, installedExecutable) { + continue + } + name := strings.TrimSuffix(strings.ToLower(entry.Name()), strings.ToLower(filepath.Ext(entry.Name()))) + if !registry.ValidToolName(name) || registry.ReservedToolName(name) { + continue + } + info, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("inspect candidate managed shim %s: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + continue + } + if os.SameFile(installedInfo, info) { + shims = append(shims, path) + continue + } + if info.Size() != installedInfo.Size() { + continue + } + digest, _, err := hashVerificationFile(path, info) + if err != nil { + return nil, fmt.Errorf("hash candidate managed shim %s: %w", path, err) + } + if digest == oldDigest { + shims = append(shims, path) + } + } + sort.Slice(shims, func(i, j int) bool { return strings.ToLower(shims[i]) < strings.ToLower(shims[j]) }) + return shims, nil +} + +func canonicalApplyFile(path, label string) (string, os.FileInfo, error) { + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return "", nil, fmt.Errorf("%s path must be clean and absolute", label) + } + info, err := os.Lstat(path) + if err != nil { + return "", nil, fmt.Errorf("inspect %s: %w", label, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", nil, fmt.Errorf("%s must be a regular non-symlink file", label) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return "", nil, fmt.Errorf("resolve %s: %w", label, err) + } + resolved, err = filepath.Abs(resolved) + if err != nil { + return "", nil, fmt.Errorf("make resolved %s absolute: %w", label, err) + } + if !strings.EqualFold(resolved, path) { + return "", nil, fmt.Errorf("%s path resolves to %q instead of itself", label, resolved) + } + return path, info, nil +} + +func applyFileDigest(path string) (string, error) { + clean, info, err := canonicalApplyFile(path, "managed self-update file") + if err != nil { + return "", err + } + digest, _, err := hashVerificationFile(clean, info) + return digest, err +} + +func requireApplyDigest(path, want, label string) error { + digest, err := applyFileDigest(path) + if err != nil { + return fmt.Errorf("validate %s: %w", label, err) + } + if digest != want { + return fmt.Errorf("%s digest changed", label) + } + return nil +} + +func replaceManagedFile(source, destination string, preferHardlink bool) error { + tmp, err := os.CreateTemp(filepath.Dir(destination), "."+filepath.Base(destination)+"-update-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + if err := os.Remove(tmpPath); err != nil { + return err + } + defer os.Remove(tmpPath) + if preferHardlink { + if err := os.Link(source, tmpPath); err == nil { + return os.Rename(tmpPath, destination) + } + } + if err := copyApplyFile(source, tmpPath); err != nil { + return err + } + return os.Rename(tmpPath, destination) +} + +func copyApplyFile(source, destination string) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) + if err != nil { + return err + } + _, copyErr := out.ReadFrom(in) + syncErr := out.Sync() + closeErr := out.Close() + return errors.Join(copyErr, syncErr, closeErr) +} + +func smokeUpdatedBinary(ctx context.Context, executable, target string) error { + smokeCtx, cancel := context.WithTimeout(ctx, applyTimeout) + defer cancel() + cmd := exec.CommandContext(smokeCtx, executable, "version") + cmd.Dir = filepath.Dir(executable) + cmd.Env = []string{} + output, err := cmd.Output() + if errors.Is(smokeCtx.Err(), context.DeadlineExceeded) { + return errors.New("bootstrap version command timed out") + } + if err != nil { + return err + } + want := "container-bin " + target + "\n" + if string(output) != want { + return fmt.Errorf("bootstrap version output %q does not match %q", output, want) + } + return nil +} diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go new file mode 100644 index 0000000..6f7f9fe --- /dev/null +++ b/internal/selfupdate/apply_test.go @@ -0,0 +1,242 @@ +package selfupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyTransactionReplacesCompleteProvenManagedSet(t *testing.T) { + fixture := newApplyFixture(t) + copyShim := filepath.Join(fixture.dir, "gofmt.exe") + writeApplyFile(t, copyShim, fixture.oldBytes) + unrelated := filepath.Join(fixture.dir, "unrelated.exe") + writeApplyFile(t, unrelated, []byte("not ContainerBin")) + reserved := filepath.Join(fixture.dir, "cb-v1.0.0.exe") + writeApplyFile(t, reserved, fixture.oldBytes) + + var smoked bool + tx := applyTransaction{ + replace: replaceManagedFile, + smoke: func(_ context.Context, executable, target string) error { + smoked = true + if executable != fixture.installed || target != "v1.1.0" { + t.Fatalf("smoke inputs = %q, %q", executable, target) + } + assertApplyBytes(t, executable, fixture.newBytes) + assertApplyBytes(t, fixture.hardlinkShim, fixture.newBytes) + assertApplyBytes(t, copyShim, fixture.newBytes) + return nil + }, + } + if err := tx.apply(context.Background(), fixture.verified, fixture.installed); err != nil { + t.Fatal(err) + } + if !smoked { + t.Fatal("updated executable was not smoke tested") + } + assertApplyBytes(t, fixture.installed, fixture.newBytes) + assertApplyBytes(t, fixture.hardlinkShim, fixture.newBytes) + assertApplyBytes(t, copyShim, fixture.newBytes) + assertApplyBytes(t, unrelated, []byte("not ContainerBin")) + assertApplyBytes(t, reserved, fixture.oldBytes) + assertNoApplyRecoveryArtifacts(t, fixture.dir) + + installedInfo, err := os.Stat(fixture.installed) + if err != nil { + t.Fatal(err) + } + for _, shim := range []string{fixture.hardlinkShim, copyShim} { + shimInfo, err := os.Stat(shim) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(installedInfo, shimInfo) { + t.Errorf("managed shim %s was not reconciled as a hardlink", shim) + } + } +} + +func TestApplyTransactionRollsBackCompleteManagedSetAfterSmokeFailure(t *testing.T) { + fixture := newApplyFixture(t) + copyShim := filepath.Join(fixture.dir, "gofmt.exe") + writeApplyFile(t, copyShim, fixture.oldBytes) + tx := applyTransaction{ + replace: replaceManagedFile, + smoke: func(context.Context, string, string) error { + return errors.New("new binary did not start") + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "restored to their previous bytes") { + t.Fatalf("apply error = %v, want successful rollback evidence", err) + } + for _, path := range []string{fixture.installed, fixture.hardlinkShim, copyShim} { + assertApplyBytes(t, path, fixture.oldBytes) + } + assertNoApplyRecoveryArtifacts(t, fixture.dir) +} + +func TestApplyTransactionRollsBackReplacementThatReportedFailureAfterChangingBytes(t *testing.T) { + fixture := newApplyFixture(t) + first := true + tx := applyTransaction{ + replace: func(source, destination string, preferHardlink bool) error { + if err := replaceManagedFile(source, destination, preferHardlink); err != nil { + return err + } + if first { + first = false + return errors.New("ambiguous replacement result") + } + return nil + }, + smoke: func(context.Context, string, string) error { + t.Fatal("ambiguous replacement reached smoke test") + return nil + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "reported failure after changing bytes") || !strings.Contains(err.Error(), "restored to their previous bytes") { + t.Fatalf("apply error = %v", err) + } + assertApplyBytes(t, fixture.installed, fixture.oldBytes) + assertApplyBytes(t, fixture.hardlinkShim, fixture.oldBytes) + assertNoApplyRecoveryArtifacts(t, fixture.dir) +} + +func TestApplyTransactionRejectsChangedVerifiedBytesBeforeInstalledMutation(t *testing.T) { + fixture := newApplyFixture(t) + writeApplyFileReplace(t, fixture.verified.binaryPath, []byte("changed after attestation")) + smoked := false + tx := applyTransaction{ + replace: replaceManagedFile, + smoke: func(context.Context, string, string) error { + smoked = true + return nil + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "changed before replacement") { + t.Fatalf("apply error = %v", err) + } + if smoked { + t.Fatal("changed staged bytes reached the smoke test") + } + assertApplyBytes(t, fixture.installed, fixture.oldBytes) + assertApplyBytes(t, fixture.hardlinkShim, fixture.oldBytes) + assertNoApplyRecoveryArtifacts(t, fixture.dir) +} + +func TestApplyTransactionPreservesRecoveryArtifactWhenRollbackFails(t *testing.T) { + fixture := newApplyFixture(t) + replacements := 0 + tx := applyTransaction{ + replace: func(source, destination string, preferHardlink bool) error { + replacements++ + if replacements >= 3 { + return errors.New("filesystem denied rollback") + } + return replaceManagedFile(source, destination, preferHardlink) + }, + smoke: func(context.Context, string, string) error { + return errors.New("smoke failure") + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "recovery executable preserved") { + t.Fatalf("apply error = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(fixture.dir, rollbackPrefix+"*", "cb.exe")) + if globErr != nil { + t.Fatal(globErr) + } + if len(matches) != 1 { + t.Fatalf("recovery executables = %v, want one", matches) + } + assertApplyBytes(t, matches[0], fixture.oldBytes) +} + +type applyFixture struct { + dir string + installed string + hardlinkShim string + verified Verified + oldBytes []byte + newBytes []byte +} + +func newApplyFixture(t *testing.T) applyFixture { + t.Helper() + dir := t.TempDir() + oldBytes := []byte("old signed ContainerBin executable") + newBytes := []byte("new attested ContainerBin executable") + installed := filepath.Join(dir, "cb.exe") + writeApplyFile(t, installed, oldBytes) + hardlinkShim := filepath.Join(dir, "go.exe") + if err := os.Link(installed, hardlinkShim); err != nil { + t.Fatal(err) + } + stageDir, err := os.MkdirTemp(dir, stagingPrefix) + if err != nil { + t.Fatal(err) + } + staged := filepath.Join(stageDir, "cb.exe") + writeApplyFile(t, staged, newBytes) + sum := sha256.Sum256(newBytes) + return applyFixture{ + dir: dir, + installed: installed, + hardlinkShim: hardlinkShim, + verified: Verified{ + binaryPath: staged, + target: "v1.1.0", + digest: hex.EncodeToString(sum[:]), + size: int64(len(newBytes)), + }, + oldBytes: oldBytes, + newBytes: newBytes, + } +} + +func writeApplyFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.WriteFile(path, data, 0o700); err != nil { + t.Fatal(err) + } +} + +func writeApplyFileReplace(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + writeApplyFile(t, path, data) +} + +func assertApplyBytes(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("%s bytes = %q, want %q", path, got, want) + } +} + +func assertNoApplyRecoveryArtifacts(t *testing.T, dir string) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, rollbackPrefix+"*")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("unexpected rollback artifacts: %v", matches) + } +} diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index d5973ff..5213dc0 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -1,6 +1,7 @@ -// Package selfupdate implements release selection, same-volume staging and -// artifact verification for ContainerBin's transactional self-update. -// Installed-file replacement remains a deliberately separate later phase. +// Package selfupdate implements release selection, same-volume staging, +// artifact verification and rollback-safe installed-set replacement for +// ContainerBin's transactional self-update. The temporary helper that waits +// for the invoking process and the user-facing apply command remain separate. package selfupdate import ( From 9d5dd6a8f2094db62fbdb5d4ad8625987ca0400b Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:39:47 +0100 Subject: [PATCH 2/6] Harden self-update replacement identity --- internal/selfupdate/apply.go | 16 ++- internal/selfupdate/apply_test.go | 50 ++++++++- internal/selfupdate/replace_file_other.go | 9 ++ internal/selfupdate/replace_file_windows.go | 39 +++++++ internal/selfupdate/verify.go | 102 +++++++++++++++--- .../verify_integration_windows_test.go | 7 +- internal/selfupdate/verify_test.go | 75 +++++++++++-- 7 files changed, 263 insertions(+), 35 deletions(-) create mode 100644 internal/selfupdate/replace_file_other.go create mode 100644 internal/selfupdate/replace_file_windows.go diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go index 82cdafc..ba1a3f8 100644 --- a/internal/selfupdate/apply.go +++ b/internal/selfupdate/apply.go @@ -91,6 +91,18 @@ func (tx applyTransaction) apply(ctx context.Context, verified Verified, install if installedInfo.Size() <= 0 || installedInfo.Size() > maxBinarySize { return fmt.Errorf("installed management executable size %d is outside the self-update safety limit", installedInfo.Size()) } + if !strings.EqualFold(installedExecutable, verified.installedPath) || + installedInfo.Size() != verified.installedSize || + oldDigest != verified.installedDigest { + return errors.New("installed management executable changed after update verification") + } + current, err := currentVersion(verified.installedVersion) + if err != nil { + return fmt.Errorf("verified installed version: %w", err) + } + if current.compare(target) == 0 { + return errors.New("verified self-update target is already installed") + } if oldDigest == stagedDigest { return errors.New("verified update bytes are identical to the installed management executable") } @@ -328,13 +340,13 @@ func replaceManagedFile(source, destination string, preferHardlink bool) error { defer os.Remove(tmpPath) if preferHardlink { if err := os.Link(source, tmpPath); err == nil { - return os.Rename(tmpPath, destination) + return replaceExistingFile(tmpPath, destination) } } if err := copyApplyFile(source, tmpPath); err != nil { return err } - return os.Rename(tmpPath, destination) + return replaceExistingFile(tmpPath, destination) } func copyApplyFile(source, destination string) error { diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index 6f7f9fe..9c3d73b 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -133,6 +133,43 @@ func TestApplyTransactionRejectsChangedVerifiedBytesBeforeInstalledMutation(t *t assertNoApplyRecoveryArtifacts(t, fixture.dir) } +func TestApplyTransactionRejectsInstalledExecutableChangedAfterVerification(t *testing.T) { + fixture := newApplyFixture(t) + writeApplyFileReplace(t, fixture.installed, []byte("newer independently installed ContainerBin executable")) + replaced := false + tx := applyTransaction{ + replace: func(string, string, bool) error { + replaced = true + return nil + }, + smoke: func(context.Context, string, string) error { + t.Fatal("stale update reached smoke test") + return nil + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "changed after update verification") { + t.Fatalf("apply error = %v", err) + } + if replaced { + t.Fatal("stale update attempted an installed-file replacement") + } + assertNoApplyRecoveryArtifacts(t, fixture.dir) +} + +func TestReplaceManagedFileReplacesExistingDestination(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source.exe") + destination := filepath.Join(dir, "destination.exe") + writeApplyFile(t, source, []byte("new bytes")) + writeApplyFile(t, destination, []byte("old bytes")) + if err := replaceManagedFile(source, destination, false); err != nil { + t.Fatal(err) + } + assertApplyBytes(t, source, []byte("new bytes")) + assertApplyBytes(t, destination, []byte("new bytes")) +} + func TestApplyTransactionPreservesRecoveryArtifactWhenRollbackFails(t *testing.T) { fixture := newApplyFixture(t) replacements := 0 @@ -189,15 +226,20 @@ func newApplyFixture(t *testing.T) applyFixture { staged := filepath.Join(stageDir, "cb.exe") writeApplyFile(t, staged, newBytes) sum := sha256.Sum256(newBytes) + oldSum := sha256.Sum256(oldBytes) return applyFixture{ dir: dir, installed: installed, hardlinkShim: hardlinkShim, verified: Verified{ - binaryPath: staged, - target: "v1.1.0", - digest: hex.EncodeToString(sum[:]), - size: int64(len(newBytes)), + binaryPath: staged, + target: "v1.1.0", + digest: hex.EncodeToString(sum[:]), + size: int64(len(newBytes)), + installedPath: installed, + installedVersion: "v1.0.0", + installedDigest: hex.EncodeToString(oldSum[:]), + installedSize: int64(len(oldBytes)), }, oldBytes: oldBytes, newBytes: newBytes, diff --git a/internal/selfupdate/replace_file_other.go b/internal/selfupdate/replace_file_other.go new file mode 100644 index 0000000..b746057 --- /dev/null +++ b/internal/selfupdate/replace_file_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package selfupdate + +import "os" + +func replaceExistingFile(source, destination string) error { + return os.Rename(source, destination) +} diff --git a/internal/selfupdate/replace_file_windows.go b/internal/selfupdate/replace_file_windows.go new file mode 100644 index 0000000..37fa36f --- /dev/null +++ b/internal/selfupdate/replace_file_windows.go @@ -0,0 +1,39 @@ +//go:build windows + +package selfupdate + +import ( + "fmt" + "syscall" + "unsafe" +) + +const ( + moveFileReplaceExisting = 0x1 + moveFileWriteThrough = 0x8 +) + +var moveFileExW = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW") + +func replaceExistingFile(source, destination string) error { + sourcePtr, err := syscall.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := syscall.UTF16PtrFromString(destination) + if err != nil { + return err + } + result, _, callErr := moveFileExW.Call( + uintptr(unsafe.Pointer(sourcePtr)), + uintptr(unsafe.Pointer(destinationPtr)), + moveFileReplaceExisting|moveFileWriteThrough, + ) + if result == 0 { + if errno, ok := callErr.(syscall.Errno); ok && errno != 0 { + return fmt.Errorf("replace existing file with MoveFileExW: %w", errno) + } + return fmt.Errorf("replace existing file with MoveFileExW failed") + } + return nil +} diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go index e16b3b0..16e39ea 100644 --- a/internal/selfupdate/verify.go +++ b/internal/selfupdate/verify.go @@ -25,14 +25,18 @@ const ( ) // Verified is an opaque result binding a successful checksum and provenance -// verification to the exact staged executable digest. A later replacement -// phase must re-hash the file and match this result before changing installed -// bytes. +// verification to both the exact staged executable and the installed binary +// identity/version that selected the update. A later replacement phase must +// re-hash both identities before changing installed bytes. type Verified struct { - binaryPath string - target string - digest string - size int64 + binaryPath string + target string + digest string + size int64 + installedPath string + installedVersion string + installedDigest string + installedSize int64 } func (v Verified) BinaryPath() string { return v.binaryPath } @@ -45,8 +49,16 @@ type attestationRunner interface { } type verifier struct { - runner attestationRunner - authenticate func(context.Context, string) (string, error) + runner attestationRunner + authenticate func(context.Context, string) (string, error) + bindInstalled func(context.Context, string, string) (installedIdentity, error) +} + +type installedIdentity struct { + path string + version string + digest string + size int64 } // Verify checks the release checksum and GitHub build-provenance attestation @@ -54,20 +66,24 @@ type verifier struct { // regular executable with a valid GitHub, Inc. Authenticode signature. Verify // never searches PATH, passes inherited verifier configuration, or falls back // to checksum-only acceptance. -func Verify(ctx context.Context, plan Plan, binaryPath, checksumsPath, ghExecutable string) (Verified, error) { +func Verify(ctx context.Context, plan Plan, binaryPath, checksumsPath, installedExecutable, ghExecutable string) (Verified, error) { return (verifier{ - runner: commandAttestationRunner{}, - authenticate: authenticateGitHubCLI, - }).Verify(ctx, plan, binaryPath, checksumsPath, ghExecutable) + runner: commandAttestationRunner{}, + authenticate: authenticateGitHubCLI, + bindInstalled: bindInstalledExecutable, + }).Verify(ctx, plan, binaryPath, checksumsPath, installedExecutable, ghExecutable) } -func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPath, ghExecutable string) (Verified, error) { +func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPath, installedExecutable, ghExecutable string) (Verified, error) { if v.runner == nil { return Verified{}, errors.New("self-update verifier has no attestation runner") } if v.authenticate == nil { return Verified{}, errors.New("self-update verifier has no GitHub CLI authenticator") } + if v.bindInstalled == nil { + return Verified{}, errors.New("self-update verifier has no installed-executable binder") + } target, err := validateVerificationPlan(plan) if err != nil { return Verified{}, err @@ -162,11 +178,25 @@ func (v verifier) Verify(ctx context.Context, plan Plan, binaryPath, checksumsPa if postPath != binaryPath || postSize != size || postDigest != digest { return Verified{}, errors.New("staged executable changed during verification") } - return Verified{binaryPath: binaryPath, target: target.raw, digest: digest, size: size}, nil + installed, err := v.bindInstalled(ctx, installedExecutable, plan.Current) + if err != nil { + return Verified{}, fmt.Errorf("bind installed management executable: %w", err) + } + return Verified{ + binaryPath: binaryPath, + target: target.raw, + digest: digest, + size: size, + installedPath: installed.path, + installedVersion: installed.version, + installedDigest: installed.digest, + installedSize: installed.size, + }, nil } func validateVerificationPlan(plan Plan) (semanticVersion, error) { - if _, err := currentVersion(plan.Current); err != nil { + current, err := currentVersion(plan.Current) + if err != nil { return semanticVersion{}, fmt.Errorf("invalid self-update verification plan: %w", err) } target, err := parseVersion(plan.Target) @@ -192,9 +222,49 @@ func validateVerificationPlan(plan Plan) (semanticVersion, error) { if plan.Binary.Size <= 0 || plan.Binary.Size > maxBinarySize || plan.Archive.Size <= 0 || plan.Archive.Size > maxArchiveSize || plan.Checksums.Size <= 0 || plan.Checksums.Size > maxChecksumSize { return semanticVersion{}, errors.New("self-update verification plan has an invalid asset size") } + switch comparison := current.compare(target); { + case comparison == 0: + return semanticVersion{}, errors.New("self-update verification target is already installed") + case comparison > 0 && (plan.downgradeAuthorization.current != current.raw || plan.downgradeAuthorization.target != target.raw): + return semanticVersion{}, errors.New("self-update verification plan does not authorize the requested downgrade") + case comparison < 0 && plan.downgradeAuthorization != (downgradeAuthorization{}): + return semanticVersion{}, errors.New("self-update verification plan has inconsistent downgrade authorization") + } return target, nil } +func bindInstalledExecutable(ctx context.Context, path, version string) (installedIdentity, error) { + clean, info, err := canonicalApplyFile(path, "installed management executable") + if err != nil { + return installedIdentity{}, err + } + if !strings.EqualFold(filepath.Base(clean), "cb.exe") { + return installedIdentity{}, fmt.Errorf("installed management executable must be named cb.exe, got %q", filepath.Base(clean)) + } + if info.Size() <= 0 || info.Size() > maxBinarySize { + return installedIdentity{}, fmt.Errorf("installed management executable size %d is outside the self-update safety limit", info.Size()) + } + digest, size, err := hashRegularFile(clean, info, "installed management executable") + if err != nil { + return installedIdentity{}, err + } + if err := smokeUpdatedBinary(ctx, clean, version); err != nil { + return installedIdentity{}, fmt.Errorf("confirm installed management executable version: %w", err) + } + postPath, postInfo, err := canonicalApplyFile(clean, "installed management executable") + if err != nil { + return installedIdentity{}, err + } + postDigest, postSize, err := hashRegularFile(postPath, postInfo, "installed management executable") + if err != nil { + return installedIdentity{}, err + } + if postPath != clean || postSize != size || postDigest != digest { + return installedIdentity{}, errors.New("installed management executable changed while binding the update") + } + return installedIdentity{path: clean, version: version, digest: digest, size: size}, nil +} + func canonicalVerificationFile(path, label string) (string, os.FileInfo, error) { if !filepath.IsAbs(path) { return "", nil, fmt.Errorf("%s path must be absolute", label) diff --git a/internal/selfupdate/verify_integration_windows_test.go b/internal/selfupdate/verify_integration_windows_test.go index 1819edb..cdaf3f5 100644 --- a/internal/selfupdate/verify_integration_windows_test.go +++ b/internal/selfupdate/verify_integration_windows_test.go @@ -12,8 +12,9 @@ func TestVerifyRealGitHubCLIRelease(t *testing.T) { gh := os.Getenv("CB_TEST_REAL_GH") binary := os.Getenv("CB_TEST_RELEASE_CB") checksums := os.Getenv("CB_TEST_RELEASE_SUMS") - if gh == "" || binary == "" || checksums == "" { - t.Skip("set CB_TEST_REAL_GH, CB_TEST_RELEASE_CB and CB_TEST_RELEASE_SUMS to run the live release verification") + installed := os.Getenv("CB_TEST_INSTALLED_CB") + if gh == "" || binary == "" || checksums == "" || installed == "" { + t.Skip("set CB_TEST_REAL_GH, CB_TEST_RELEASE_CB, CB_TEST_RELEASE_SUMS and CB_TEST_INSTALLED_CB to run the live release verification") } binaryInfo, err := os.Stat(binary) if err != nil { @@ -36,7 +37,7 @@ func TestVerifyRealGitHubCLIRelease(t *testing.T) { ExpectedRef: "refs/tags/v1.1.0", Workflow: expectedReleaseWorkflow, } - verified, err := Verify(context.Background(), plan, binary, checksums, gh) + verified, err := Verify(context.Background(), plan, binary, checksums, installed, gh) if err != nil { t.Fatal(err) } diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go index 5308f87..55f0688 100644 --- a/internal/selfupdate/verify_test.go +++ b/internal/selfupdate/verify_test.go @@ -23,7 +23,7 @@ func TestVerifyRequiresChecksumAndExactAttestationPolicy(t *testing.T) { gotArgs = append([]string(nil), args...) return attestationJSON(fixture.digest), nil, nil }) - verified, err := testVerifier(runner).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + verified, err := testVerifier(runner).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err != nil { t.Fatal(err) } @@ -91,7 +91,7 @@ func TestVerifyRejectsChecksumFailuresBeforeAttestation(t *testing.T) { _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { called = true return nil, nil, nil - })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), tc.want) || called { t.Fatalf("Verify = %v, called=%v, want %q before attestation", err, called, tc.want) } @@ -126,7 +126,7 @@ func TestVerifyRejectsInvalidPolicyLayoutAndPathsBeforeAttestation(t *testing.T) _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { called = true return nil, nil, nil - })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), tc.want) || called { t.Fatalf("Verify = %v, called=%v, want %q before attestation", err, called, tc.want) } @@ -145,13 +145,48 @@ func TestVerifyRejectsInvalidPolicyLayoutAndPathsBeforeAttestation(t *testing.T) } _, err = testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { return nil, nil, errors.New("unexpected") - })).Verify(context.Background(), fixture.plan, fixture.binary, other, fixture.gh) + })).Verify(context.Background(), fixture.plan, fixture.binary, other, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "exact staged layout") { t.Fatalf("separate-directory Verify = %v", err) } }) } +func TestValidateVerificationPlanRequiresAuthorizedVersionTransition(t *testing.T) { + cases := []struct { + name string + mutate func(*Plan) + want string + }{ + {name: "already installed", mutate: func(plan *Plan) { plan.Current = plan.Target }, want: "already installed"}, + {name: "unauthorized downgrade", mutate: func(plan *Plan) { + plan.Current = "v1.3.0" + }, want: "does not authorize"}, + {name: "inconsistent upgrade authorization", mutate: func(plan *Plan) { + plan.downgradeAuthorization = downgradeAuthorization{current: "v1.1.0", target: "v1.2.0"} + }, want: "inconsistent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + plan := newVerificationFixture(t).plan + tc.mutate(&plan) + _, err := validateVerificationPlan(plan) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("validateVerificationPlan = %v, want %q", err, tc.want) + } + }) + } + + t.Run("authorized downgrade", func(t *testing.T) { + plan := newVerificationFixture(t).plan + plan.Current = "v1.3.0" + plan.downgradeAuthorization = downgradeAuthorization{current: "v1.3.0", target: "v1.2.0"} + if _, err := validateVerificationPlan(plan); err != nil { + t.Fatal(err) + } + }) +} + func TestVerifyRejectsAttestationFailureOrInvalidResult(t *testing.T) { cases := []struct { name string @@ -180,7 +215,7 @@ func TestVerifyRejectsAttestationFailureOrInvalidResult(t *testing.T) { } _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { return stdout, tc.stderr, tc.err - })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("Verify = %v, want %q", err, tc.want) } @@ -197,7 +232,7 @@ func TestVerifyDetectsExecutableMutationDuringAttestation(t *testing.T) { t.Fatal(writeErr) } return attestationJSON(fixture.digest), nil, nil - })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + })).Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "changed during verification") { t.Fatalf("mutation Verify = %v", err) } @@ -215,8 +250,9 @@ func TestVerifyRejectsGitHubCLIAuthenticationFailureOrMutation(t *testing.T) { authenticate: func(context.Context, string) (string, error) { return "", errors.New("untrusted publisher") }, + bindInstalled: testInstalledBinder, } - _, err := v.Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + _, err := v.Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "untrusted publisher") || called { t.Fatalf("Verify = %v, called=%v", err, called) } @@ -233,8 +269,9 @@ func TestVerifyRejectsGitHubCLIAuthenticationFailureOrMutation(t *testing.T) { authentications++ return fmt.Sprintf("digest-%d", authentications), nil }, + bindInstalled: testInstalledBinder, } - _, err := v.Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + _, err := v.Verify(context.Background(), fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "GitHub CLI executable changed") || authentications != 2 { t.Fatalf("Verify = %v, authentications=%d", err, authentications) } @@ -282,8 +319,9 @@ func TestVerifyPropagatesCancellationToGitHubCLIAuthentication(t *testing.T) { authenticated = true return "", ctx.Err() }, + bindInstalled: testInstalledBinder, } - _, err := v.Verify(ctx, fixture.plan, fixture.binary, fixture.checksums, fixture.gh) + _, err := v.Verify(ctx, fixture.plan, fixture.binary, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "context canceled") || !authenticated || runnerCalled { t.Fatalf("Verify() error = %v, authenticated=%t, runnerCalled=%t", err, authenticated, runnerCalled) } @@ -349,7 +387,7 @@ func TestVerifyRejectsSymlinkInputs(t *testing.T) { } _, err := testVerifier(attestationRunnerFunc(func(context.Context, string, []string) ([]byte, []byte, error) { return nil, nil, errors.New("unexpected") - })).Verify(context.Background(), fixture.plan, link, fixture.checksums, fixture.gh) + })).Verify(context.Background(), fixture.plan, link, fixture.checksums, fixture.installed, fixture.gh) if err == nil || !strings.Contains(err.Error(), "regular non-symlink") { t.Fatalf("symlink Verify = %v", err) } @@ -359,6 +397,7 @@ type verificationFixture struct { plan Plan binary string checksums string + installed string gh string binaryBytes []byte digest string @@ -374,10 +413,12 @@ func newVerificationFixture(t *testing.T) verificationFixture { manifest := digest + " cb.exe\n" + strings.Repeat("a", 64) + " " + archiveName + "\n" binary := filepath.Join(dir, "cb.exe") checksums := filepath.Join(dir, "SHA256SUMS") + installed := filepath.Join(t.TempDir(), "cb.exe") gh := filepath.Join(t.TempDir(), "gh.exe") for path, data := range map[string][]byte{ binary: binaryBytes, checksums: []byte(manifest), + installed: []byte("installed ContainerBin test executable"), gh: []byte("trusted GitHub CLI test executable"), } { if err := os.WriteFile(path, data, 0o700); err != nil { @@ -400,6 +441,7 @@ func newVerificationFixture(t *testing.T) verificationFixture { }, binary: binary, checksums: checksums, + installed: installed, gh: gh, binaryBytes: binaryBytes, digest: digest, @@ -435,5 +477,18 @@ func testVerifier(runner attestationRunner) verifier { authenticate: func(context.Context, string) (string, error) { return "authenticated-test-gh", nil }, + bindInstalled: testInstalledBinder, + } +} + +func testInstalledBinder(_ context.Context, path, version string) (installedIdentity, error) { + clean, info, err := canonicalApplyFile(path, "installed management executable") + if err != nil { + return installedIdentity{}, err + } + digest, size, err := hashRegularFile(clean, info, "installed management executable") + if err != nil { + return installedIdentity{}, err } + return installedIdentity{path: clean, version: version, digest: digest, size: size}, nil } From 87e8a2409c343395db506a2cdd215f77a9358ffa Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:44:44 +0100 Subject: [PATCH 3/6] Canonicalize Windows update paths safely --- internal/selfupdate/apply.go | 10 +++++++--- internal/selfupdate/apply_test.go | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go index ba1a3f8..f459c36 100644 --- a/internal/selfupdate/apply.go +++ b/internal/selfupdate/apply.go @@ -298,10 +298,14 @@ func canonicalApplyFile(path, label string) (string, os.FileInfo, error) { if err != nil { return "", nil, fmt.Errorf("make resolved %s absolute: %w", label, err) } - if !strings.EqualFold(resolved, path) { - return "", nil, fmt.Errorf("%s path resolves to %q instead of itself", label, resolved) + resolvedInfo, err := os.Stat(resolved) + if err != nil { + return "", nil, fmt.Errorf("inspect resolved %s: %w", label, err) + } + if !resolvedInfo.Mode().IsRegular() || !os.SameFile(info, resolvedInfo) { + return "", nil, fmt.Errorf("%s changed while resolving its canonical path", label) } - return path, info, nil + return resolved, resolvedInfo, nil } func applyFileDigest(path string) (string, error) { diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index 9c3d73b..0374b02 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -211,6 +211,11 @@ type applyFixture struct { func newApplyFixture(t *testing.T) applyFixture { t.Helper() dir := t.TempDir() + var err error + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } oldBytes := []byte("old signed ContainerBin executable") newBytes := []byte("new attested ContainerBin executable") installed := filepath.Join(dir, "cb.exe") From ae2e4885d72e64c3445d24fff0d38470687e52a5 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:36:56 +0100 Subject: [PATCH 4/6] Preserve install ACLs during update rollback --- internal/selfupdate/apply.go | 7 +++++-- internal/selfupdate/apply_test.go | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go index f459c36..c99fec1 100644 --- a/internal/selfupdate/apply.go +++ b/internal/selfupdate/apply.go @@ -71,7 +71,7 @@ func (tx applyTransaction) apply(ctx context.Context, verified Verified, install } installDir := filepath.Dir(installedExecutable) stageDir := filepath.Dir(staged) - if filepath.Base(staged) != "cb.exe" || filepath.Dir(stageDir) != installDir || !strings.HasPrefix(filepath.Base(stageDir), stagingPrefix) { + if !strings.EqualFold(filepath.Base(staged), "cb.exe") || !strings.EqualFold(filepath.Dir(stageDir), installDir) || !strings.HasPrefix(filepath.Base(stageDir), stagingPrefix) { return errors.New("verified executable is outside the exact private same-volume staging layout") } if !strings.EqualFold(filepath.VolumeName(staged), filepath.VolumeName(installedExecutable)) { @@ -218,7 +218,10 @@ func (tx applyTransaction) rollback(installedExecutable, rollbackBinary string, result = errors.Join(result, fmt.Errorf("managed shim %s changed outside the update transaction; refusing to overwrite it during rollback", shim)) continue } - if err := tx.replace(rollbackBinary, shim, true); err != nil { + // The rollback copy has a protected owner-only DACL. Copy it through a + // fresh install-directory file so restored shims inherit the installation + // ACL instead of hardlinking that private security descriptor. + if err := tx.replace(rollbackBinary, shim, false); err != nil { result = errors.Join(result, fmt.Errorf("restore managed shim %s: %w", shim, err)) } } diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index 0374b02..eb8a3d8 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -79,6 +79,19 @@ func TestApplyTransactionRollsBackCompleteManagedSetAfterSmokeFailure(t *testing for _, path := range []string{fixture.installed, fixture.hardlinkShim, copyShim} { assertApplyBytes(t, path, fixture.oldBytes) } + installedInfo, err := os.Stat(fixture.installed) + if err != nil { + t.Fatal(err) + } + for _, shim := range []string{fixture.hardlinkShim, copyShim} { + shimInfo, err := os.Stat(shim) + if err != nil { + t.Fatal(err) + } + if os.SameFile(installedInfo, shimInfo) { + t.Errorf("rollback shim %s retained the private recovery-file hardlink identity", shim) + } + } assertNoApplyRecoveryArtifacts(t, fixture.dir) } From a100de0949025fad30015081139959e73644cdef Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:45:36 +0100 Subject: [PATCH 5/6] Harden self-update recovery outcomes --- README.md | 5 ++ docs/architecture.md | 16 +++--- docs/roadmap-decisions.md | 9 +-- docs/roadmap-implementation-requirements.md | 13 +++-- internal/selfupdate/apply.go | 54 ++++++++++++++---- internal/selfupdate/apply_test.go | 63 ++++++++++++++++++++- 6 files changed, 129 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index afdf439..45f99a7 100644 --- a/README.md +++ b/README.md @@ -890,6 +890,11 @@ and restores the prior set after any smoke-test or identity failure. The helper that waits for the invoking process to exit and the user-facing apply command are not wired yet, so `--check` remains the only accepted command mode. +A hard process or host crash can leave a private `.container-bin-update-*` +staging/rollback directory or `.cb.exe-update-*.tmp` file beside `cb.exe`. +ContainerBin does not wildcard-delete these names on a later run because a name +alone does not prove ownership; inspect the object before removing it manually. + 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 diff --git a/docs/architecture.md b/docs/architecture.md index 9528d66..4ecacf9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -343,11 +343,11 @@ dispatched before machine policy and registry loading. Release selection therefore remains available when either local configuration source is missing or invalid without allowing unsupported frontends to perform network work. `internal/selfupdate` has no project imports. The CLI currently performs only -bounded metadata queries and plan output. The package also has an unexposed -same-volume staging phase that requires the installed executable path, then -downloads the directly attested executable and checksum manifest into private, -exact-size temporary files through a narrowly allowed GitHub release redirect. -Windows staging replaces inherited permissions with a protected DACL granting -access only to the current user. Attestation/checksum verification and -installed-file replacement remain separate later phases, so no download path -can yet mutate the installed binary. +bounded metadata queries and plan output. The package also has unexposed phases +for private same-volume staging, exact checksum and GitHub build-provenance +verification, and rollback-safe replacement of the management executable plus +the complete proven shim set. Windows staging and recovery files use protected +current-user-only DACLs; installed replacements inherit installation-directory +ACLs. The temporary helper that waits for the invoking process to exit and the +user-facing apply command remain separate later work, so the exposed command +still cannot mutate the installed binary. diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 25ae242..5bd246b 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -21,7 +21,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | 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. | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two concrete integrations cannot be expressed safely by the declarative model. | -| RM-31 self-update | **Explicit transactional, attestation-verifying update** | Selection/check foundation shipped in PR #76. Staging, `gh attestation verify`, Windows apply, complete managed-set rollback and E2E remain. | +| RM-31 self-update | **Explicit transactional, attestation-verifying update** | Selection/check, staging and verification are implemented; the internal Windows transaction now replaces and rolls back the complete proven managed set. Helper/user-facing apply wiring, broader architecture support and E2E remain. | | RM-30 Authenticode | **Design accepted; externally blocked** | Implement only after a real code-signing certificate and protected signing mechanism exist. Stable and prerelease release artifacts are both signed. | | RM-29 Windows ARM64 | **Lowest priority** | Native GitHub Windows ARM64 CI shipped in PR #78 and reproducible release packaging in PR #86. ARM64 self-update selection and real Windows-on-Arm + Docker Desktop qualification remain. Do not delay other roadmap work. | | Standalone Linux/macOS | **Demand-gated** | No support claim yet. WSL should create reusable narrow Linux host abstractions, but standalone hosts require their own contract and real Docker qualification. | @@ -318,9 +318,10 @@ is not completion. 4. **Remaining RM-31 self-update** - selection/check/dry-run API is merged in PR #76; - - bounded canonical GitHub release download/staging; - - `gh attestation verify` policy integration; - - Windows helper transaction, managed-shim reconciliation and rollback; + - bounded canonical GitHub release download/staging and `gh attestation verify` policy integration are implemented; + - rollback-safe Windows transaction and managed-shim reconciliation are implemented internally; + - temporary wait helper and user-facing apply wiring; + - broader architecture support; - release/self-test E2E. 5. **Remaining WSL2** diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 87831d6..39fbce3 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -72,7 +72,7 @@ The minimum delivery gate for a code change is: | RM-26 Python global CLI exposure | **Completed in PR #74** | Stateful pipx + `cb expose pipx` shipped; plain pip `/venv/bin` remains intentionally unexposed | | RM-29 Windows ARM64 | **Native CI and release packaging shipped / update and hardware work remain** | PR #78 added native hosted ARM64 CI and PR #86 added reproducible release packaging; ARM64 self-update selection and real Windows-on-Arm + Docker Desktop E2E remain | | RM-30 Authenticode | **Design complete / externally blocked** | Provision real code-signing certificate and protected signing mechanism | -| RM-31 self-update | **Selection/check foundation shipped** | PR #76 shipped selection/check behavior; staging, verification, transactional apply and E2E remain | +| RM-31 self-update | **Selection/staging/verifier and transaction foundations implemented** | Temporary helper, user-facing apply wiring, broader architecture support and release E2E remain | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all/explicit selection are sufficient; reopen only for concrete unmet use case | | Linux/macOS hosts | **Demand-gated** | WSL may factor reusable Linux host code; standalone support needs its own demand and qualification | | Enterprise policy | **Foundation shipped / signed registry remains** | PR #75 shipped the machine-owned constraint layer; authenticated registry and image-trust slices remain | @@ -395,10 +395,11 @@ separate phases with explicit boundaries. GitHub documents both and [artifact-attestation verification](https://docs.github.com/en/actions/concepts/security/artifact-attestations). PR #76 shipped the command surface, release selection, check/dry-run behavior, -strict Windows/amd64 asset selection and bounded metadata rules. Unsupported -architectures still fail closed. Download staging, provenance verification, -transactional Windows apply, rollback, broader architecture support and release -E2E remain incomplete until their implementations merge. +strict Windows/amd64 asset selection and bounded metadata rules. Private +same-volume staging and provenance verification followed. This tree also +implements the unexposed rollback-safe Windows replacement transaction and +complete proven-shim reconciliation. The temporary helper, user-facing apply +wiring, broader architecture support and release E2E remain incomplete. ### Command and selection requirements @@ -659,7 +660,7 @@ reproducible ARM64 release packaging in PR #86. 1. Per-project overlay trust foundation. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. -4. Remaining RM-31 staging, verification, transactional apply and E2E. +4. Remaining RM-31 helper/user-facing apply wiring, broader architecture support and E2E. 5. Remaining WSL2 native layout, Docker Desktop integration and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 ARM64 self-update selection and real Windows-on-Arm + Docker Desktop diff --git a/internal/selfupdate/apply.go b/internal/selfupdate/apply.go index c99fec1..feaf2f8 100644 --- a/internal/selfupdate/apply.go +++ b/internal/selfupdate/apply.go @@ -161,12 +161,27 @@ func (tx applyTransaction) apply(ctx context.Context, verified Verified, install if err := requireApplyDigest(installedExecutable, oldDigest, "management executable immediately before replacement"); err != nil { return err } - if err := tx.replace(staged, installedExecutable, false); err != nil { - if digest, digestErr := applyFileDigest(installedExecutable); digestErr == nil && digest == stagedDigest { + if replaceErr := tx.replace(staged, installedExecutable, false); replaceErr != nil { + digest, digestErr := applyFileDigest(installedExecutable) + switch { + case digestErr == nil && digest == oldDigest: + return fmt.Errorf("replace installed management executable: %w", replaceErr) + case digestErr == nil && digest == stagedDigest: changed = true - return fail(fmt.Errorf("replace installed management executable reported failure after changing bytes: %w", err)) + return fail(fmt.Errorf("replace installed management executable reported failure after changing bytes: %w", replaceErr)) + default: + // The replacement call may have changed the destination, but its state + // cannot be proved. Enter rollback so an unverifiable restoration keeps + // the private recovery executable instead of deleting it. + changed = true + cause := fmt.Errorf("replace installed management executable left an inconclusive destination state: %w", replaceErr) + if digestErr != nil { + cause = errors.Join(cause, fmt.Errorf("inspect installed management executable after reported replacement failure: %w", digestErr)) + } else { + cause = errors.Join(cause, errors.New("installed management executable contains neither the previous nor verified update bytes")) + } + return fail(cause) } - return fmt.Errorf("replace installed management executable: %w", err) } changed = true if err := requireApplyDigest(installedExecutable, stagedDigest, "updated management executable"); err != nil { @@ -196,13 +211,24 @@ func (tx applyTransaction) apply(ctx context.Context, verified Verified, install func (tx applyTransaction) rollback(installedExecutable, rollbackBinary string, shims []string, oldDigest, newDigest string) error { var result error + managementRestored := false if digest, err := applyFileDigest(installedExecutable); err != nil { result = errors.Join(result, err) } else if digest == newDigest { - if err := tx.replace(rollbackBinary, installedExecutable, false); err != nil { - result = errors.Join(result, fmt.Errorf("restore management executable: %w", err)) + if replaceErr := tx.replace(rollbackBinary, installedExecutable, false); replaceErr != nil { + result = errors.Join(result, fmt.Errorf("restore management executable: %w", replaceErr)) } - } else if digest != oldDigest { + restoredDigest, verifyErr := applyFileDigest(installedExecutable) + if verifyErr != nil { + result = errors.Join(result, fmt.Errorf("verify restored management executable: %w", verifyErr)) + } else if restoredDigest != oldDigest { + result = errors.Join(result, errors.New("restored management executable digest changed")) + } else { + managementRestored = true + } + } else if digest == oldDigest { + managementRestored = true + } else { result = errors.Join(result, errors.New("management executable changed outside the update transaction; refusing to overwrite it during rollback")) } for _, shim := range shims { @@ -218,10 +244,16 @@ func (tx applyTransaction) rollback(installedExecutable, rollbackBinary string, result = errors.Join(result, fmt.Errorf("managed shim %s changed outside the update transaction; refusing to overwrite it during rollback", shim)) continue } - // The rollback copy has a protected owner-only DACL. Copy it through a - // fresh install-directory file so restored shims inherit the installation - // ACL instead of hardlinking that private security descriptor. - if err := tx.replace(rollbackBinary, shim, false); err != nil { + // Prefer the verified-restored management executable so the shim regains + // normal installation ACLs and hardlink identity. If that executable could + // not be proved restored, defensively copy the private recovery bytes. + source := rollbackBinary + preferHardlink := false + if managementRestored { + source = installedExecutable + preferHardlink = true + } + if err := tx.replace(source, shim, preferHardlink); err != nil { result = errors.Join(result, fmt.Errorf("restore managed shim %s: %w", shim, err)) } } diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index eb8a3d8..0233abb 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -88,8 +88,8 @@ func TestApplyTransactionRollsBackCompleteManagedSetAfterSmokeFailure(t *testing if err != nil { t.Fatal(err) } - if os.SameFile(installedInfo, shimInfo) { - t.Errorf("rollback shim %s retained the private recovery-file hardlink identity", shim) + if !os.SameFile(installedInfo, shimInfo) { + t.Errorf("rollback shim %s was not reconciled to the restored management executable", shim) } } assertNoApplyRecoveryArtifacts(t, fixture.dir) @@ -123,6 +123,65 @@ func TestApplyTransactionRollsBackReplacementThatReportedFailureAfterChangingByt assertNoApplyRecoveryArtifacts(t, fixture.dir) } +func TestApplyTransactionPreservesRecoveryAfterInconclusiveReplacementFailure(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, string) + want string + }{ + { + name: "destination unreadable", + mutate: func(t *testing.T, destination string) { + t.Helper() + if err := os.Remove(destination); err != nil { + t.Fatal(err) + } + }, + want: "inspect installed management executable after reported replacement failure", + }, + { + name: "destination has unknown bytes", + mutate: func(t *testing.T, destination string) { + t.Helper() + writeApplyFileReplace(t, destination, []byte("unknown post-replacement bytes")) + }, + want: "neither the previous nor verified update bytes", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := newApplyFixture(t) + first := true + tx := applyTransaction{ + replace: func(_ string, destination string, _ bool) error { + if first { + first = false + tc.mutate(t, destination) + return errors.New("ambiguous replacement result") + } + return errors.New("unexpected rollback replacement") + }, + smoke: func(context.Context, string, string) error { + t.Fatal("inconclusive replacement reached smoke test") + return nil + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "recovery executable preserved") { + t.Fatalf("apply error = %v", err) + } + matches, globErr := filepath.Glob(filepath.Join(fixture.dir, rollbackPrefix+"*", "cb.exe")) + if globErr != nil { + t.Fatal(globErr) + } + if len(matches) != 1 { + t.Fatalf("recovery executables = %v, want one", matches) + } + assertApplyBytes(t, matches[0], fixture.oldBytes) + }) + } +} + func TestApplyTransactionRejectsChangedVerifiedBytesBeforeInstalledMutation(t *testing.T) { fixture := newApplyFixture(t) writeApplyFileReplace(t, fixture.verified.binaryPath, []byte("changed after attestation")) From 4dc027f46f0d8937b3cdb3372f20abeed415d588 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:38:55 +0100 Subject: [PATCH 6/6] Reconcile self-update architecture and rollback tests --- docs/architecture.md | 24 +++++++----- internal/selfupdate/apply_test.go | 64 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 4d8dff3..080b294 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -304,7 +304,7 @@ internal/atomicio crash-safe write + .bak recovery (leaf) internal/mutationlock the registry mutation lock primitive (leaf) internal/hostenv host classification and gated WSL layout (leaf) internal/wslfs native WSL filesystem ownership/mode preflight -internal/selfupdate canonical release selection and read-only plan (leaf) +internal/selfupdate release selection, staging, verification and replacement ``` The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, @@ -312,7 +312,7 @@ project-internal imports only: ``` main -> cli, diag, dockerrun, hostenv, mutationlock, policy, projectconfig, registry, selfupdate, state -cli -> atomicio, diag, dockerrun, lockfile, pathmap, policy, registry, statearchive, toml +cli -> atomicio, diag, dockerrun, dockervol, lockfile, pathmap, policy, registry, statearchive, toml projectconfig -> atomicio, pathmap, policy, registry, toml diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry dockerrun -> dockervol, lockfile, pathmap, policy, registry @@ -323,15 +323,16 @@ pathmap -> registry registry -> atomicio, toml policy -> toml wslfs -> hostenv -atomicio, dockervol, hostenv, mutationlock, selfupdate, toml -> (leaves) +selfupdate -> mutationlock, registry +atomicio, dockervol, hostenv, mutationlock, toml -> (leaves) ``` Notably: `lockfile` and `pathmap` both depend on `registry` directly, not on each other; `dockervol` is a true leaf with no internal dependencies at all (not "beneath" `lockfile`/`pathmap` in any dependency sense — every one of `diag`/`dockerrun`/`state` reaches it independently); and `mutationlock` is -reached only from `main`, unrelated to the `registry`/`lockfile`/`pathmap` -chain. +reached from `main` and the self-update replacement transaction. The latter +also reaches `registry` to scope managed shim names without guessing. Two boundaries are load-bearing rather than cosmetic: @@ -361,11 +362,14 @@ After the host runtime boundary is enforced, `cb self-update --check` is dispatched before machine policy and registry loading. Release selection therefore remains available when either local configuration source is missing or invalid without allowing unsupported frontends to perform network work. -`internal/selfupdate` has no project imports. The CLI currently performs only -bounded metadata queries and plan output. The package also has unexposed phases -for private same-volume staging, exact checksum and GitHub build-provenance -verification, and rollback-safe replacement of the management executable plus -the complete proven shim set. Windows staging and recovery files use protected +The exposed CLI currently performs only bounded metadata queries and plan +output; that path does not load project or registry state. The package imports +`mutationlock` and `registry` only for its unexposed replacement transaction, +which serializes with registry/shim mutations and limits discovery to valid, +non-reserved managed shim names. Its other unexposed phases provide private +same-volume staging, exact checksum and GitHub build-provenance verification, +and rollback-safe replacement of the management executable plus the complete +proven shim set. Windows staging and recovery files use protected current-user-only DACLs; installed replacements inherit installation-directory ACLs. The temporary helper that waits for the invoking process to exit and the user-facing apply command remain separate later work, so the exposed command diff --git a/internal/selfupdate/apply_test.go b/internal/selfupdate/apply_test.go index 0233abb..def5c4e 100644 --- a/internal/selfupdate/apply_test.go +++ b/internal/selfupdate/apply_test.go @@ -271,6 +271,58 @@ func TestApplyTransactionPreservesRecoveryArtifactWhenRollbackFails(t *testing.T assertApplyBytes(t, matches[0], fixture.oldBytes) } +func TestApplyTransactionRefusesForeignShimChangeDuringRollback(t *testing.T) { + fixture := newApplyFixture(t) + foreignBytes := []byte("foreign concurrent shim replacement") + tx := applyTransaction{ + replace: replaceManagedFile, + smoke: func(context.Context, string, string) error { + writeApplyFileReplace(t, fixture.hardlinkShim, foreignBytes) + return errors.New("smoke failure after foreign shim change") + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "changed outside the update transaction") || !strings.Contains(err.Error(), "recovery executable preserved") { + t.Fatalf("apply error = %v", err) + } + assertApplyBytes(t, fixture.installed, fixture.oldBytes) + assertApplyBytes(t, fixture.hardlinkShim, foreignBytes) + assertOneApplyRecoveryExecutable(t, fixture.dir, fixture.oldBytes) +} + +func TestApplyTransactionUsesRecoveryCopyWhenManagementRestoreFails(t *testing.T) { + fixture := newApplyFixture(t) + fallbackUsed := false + tx := applyTransaction{ + replace: func(source, destination string, preferHardlink bool) error { + fromRecovery := strings.HasPrefix(filepath.Base(filepath.Dir(source)), rollbackPrefix) + if fromRecovery && destination == fixture.installed { + return errors.New("filesystem denied management restore") + } + if fromRecovery && destination == fixture.hardlinkShim { + fallbackUsed = true + if preferHardlink { + t.Fatal("defensive recovery fallback preferred a hardlink") + } + } + return replaceManagedFile(source, destination, preferHardlink) + }, + smoke: func(context.Context, string, string) error { + return errors.New("smoke failure") + }, + } + err := tx.apply(context.Background(), fixture.verified, fixture.installed) + if err == nil || !strings.Contains(err.Error(), "restore management executable") || !strings.Contains(err.Error(), "recovery executable preserved") { + t.Fatalf("apply error = %v", err) + } + if !fallbackUsed { + t.Fatal("rollback did not use the defensive recovery-copy fallback") + } + assertApplyBytes(t, fixture.installed, fixture.newBytes) + assertApplyBytes(t, fixture.hardlinkShim, fixture.oldBytes) + assertOneApplyRecoveryExecutable(t, fixture.dir, fixture.oldBytes) +} + type applyFixture struct { dir string installed string @@ -359,3 +411,15 @@ func assertNoApplyRecoveryArtifacts(t *testing.T, dir string) { t.Fatalf("unexpected rollback artifacts: %v", matches) } } + +func assertOneApplyRecoveryExecutable(t *testing.T, dir string, want []byte) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, rollbackPrefix+"*", "cb.exe")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 { + t.Fatalf("recovery executables = %v, want one", matches) + } + assertApplyBytes(t, matches[0], want) +}