Skip to content

security(update): stage binary replacement at an unpredictable, exclusive path - #751

Open
PierrunoYT wants to merge 21 commits into
Gitlawb:mainfrom
PierrunoYT:fix/windows-updater-staging-path-overwrite
Open

security(update): stage binary replacement at an unpredictable, exclusive path#751
PierrunoYT wants to merge 21 commits into
Gitlawb:mainfrom
PierrunoYT:fix/windows-updater-staging-path-overwrite

Conversation

@PierrunoYT

@PierrunoYT PierrunoYT commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #742 — the Windows updater staged verified executable bytes at the predictable path <target>.new and opened it with truncating, link-following semantics, letting a lower-privileged process pre-create that path as a hard link or reparse point to another file the (possibly elevated) updater can write.

Root cause

installBinary (internal/update/apply.go) always staged at a fixed <target>.new name via copyFile, which opened the destination with O_CREATE|O_WRONLY|O_TRUNC. In an installation directory writable by a lower-privileged principal, that principal could pre-create <target>.new as a hard link or supported reparse-point link to another file writable by the elevated updater. The subsequent staging write then truncated and overwrote that unintended file with the verified Zero executable bytes.

The .old rename/removal sequence in replace_windows.go was not itself the vulnerable primitive (per the issue) and is unchanged.

Changes

  • stagingFilePath now derives the staging name from crypto/rand (128 bits), so it can no longer be predicted or pre-created before the update runs.
  • New createStagingFile (platform-specific) opens the staging path exclusively without following any pre-existing link:
    • internal/update/stage_other.go (POSIX): O_CREATE|O_EXCL, which POSIX guarantees fails on a pre-existing path — including a dangling symlink — without resolving it.
    • internal/update/stage_windows.go: CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT so a pre-existing reparse point fails creation instead of being resolved through, plus a post-open GetFileInformationByHandle check (not a reparse point, not a directory, single hard link) as defense in depth.
  • randomStagingSuffix is a swappable package var so the existing helper-refresh-failure test can pin a deterministic suffix instead of relying on the old guessable name.

Tests

  • internal/update/stage_other_test.go / stage_windows_test.go reproduce the reported primitive directly: pre-create the staging path as a hard link (and, where privileges allow, a symlink) to a "victim" file, then assert createStagingFile refuses it and the victim is untouched. Also covers the fresh-path success control and a concurrent-race case (exactly one winner, no silent truncation).
  • Updated TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails to pin the staging suffix via the new test hook instead of relying on the removed fixed .new name.

Verification

  • go build ./... — pass (windows, plus cross-compiled linux/darwin for internal/update)
  • go vet ./... — pass (same platforms)
  • go test ./internal/update/... -count=1 — pass, including all new regression tests (hard link, symlink, concurrent race, fresh path) on Windows
  • gofmt -l — clean

Summary by CodeRabbit

  • Security Improvements
    • Hardened standalone updates with handle-bound staging and stricter verification before replacing the executable (POSIX and Windows).
    • If tampering is suspected, updates now fail with a hard error instead of falling back; helper refresh failures can also be treated as tampering.
    • Improved safety of recovery/stale cleanup to avoid removing unverifiable artifacts and to better preserve recovery copies when restoration is incomplete.
  • Tests
    • Expanded POSIX and Windows regression suites for staging refusal, concurrency safety, promote/install correctness, tamper/restore handling, and cleanup behavior.

…sive path

Fixes Gitlawb#742. installBinary staged downloaded release bytes at the fixed
<target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an
installation directory writable by a lower-privileged process, that
process could pre-create <target>.new as a hard link or reparse point
to another file the (possibly elevated) updater can write; the
truncating open then overwrote that unintended file with verified Zero
executable bytes.

stagingFilePath now generates a cryptographically random suffix so the
path can't be targeted in advance, and platform-specific
createStagingFile opens it exclusively without following a pre-existing
link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a
pre-existing path, symlink or not, without resolving it), and
CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a
post-open GetFileInformationByHandle check on Windows (not a reparse
point, not a directory, single hard link).

replace_windows.go's rename-swap sequence is unchanged: the dangerous
operation was always the truncating open of the staged path, not the
.old rename/removal that follows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the standalone updater’s staging step to prevent an arbitrary-file-overwrite primitive (Issue #742) by removing the predictable <target>.new staging path and ensuring staging files are created exclusively without link/reparse-point traversal.

Changes:

  • Generate an unpredictable staging filename (crypto-random suffix) instead of using a fixed .new name.
  • Introduce platform-specific createStagingFile implementations to guarantee exclusive creation and avoid link/reparse-point following (with defense-in-depth verification on Windows).
  • Add regression tests covering pre-created hard links/symlinks and a concurrent creation race, and update existing tests to use a deterministic staging suffix hook.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/update/apply.go Randomizes staging filename and switches staging writes to createStagingFile.
internal/update/apply_test.go Pins staging suffix in tests to deterministically occupy the computed staging path.
internal/update/stage_other.go Adds POSIX `O_CREAT
internal/update/stage_other_test.go Adds non-Windows regression tests for hard link/symlink pre-creation and a concurrency race.
internal/update/stage_windows.go Adds Windows `CreateFile(CREATE_NEW
internal/update/stage_windows_test.go Adds Windows regression tests for hard link/symlink pre-creation and a concurrency race.
internal/update/stage_test_helpers_test.go Adds test helper to stub the random staging suffix deterministically.
Comments suppressed due to low confidence (1)

internal/update/apply.go:233

  • In installBinary, the staged file is only scheduled for removal after copyFile succeeds. If copyFile creates the staging file and then fails (e.g., disk full / short write), the partially-written random staging file will be left behind in the install directory. Consider deferring the cleanup immediately after stagingFilePath succeeds so failures during copyFile are also cleaned up.
	if err := copyFile(sourcePath, stagedPath); err != nil {
		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
	}
	defer func() {
		_ = os.Remove(stagedPath)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/update/stage_other_test.go
Comment thread internal/update/stage_windows_test.go
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The updater replaces predictable pathname-based staging with exclusive, handle-bound staging objects. POSIX and Windows promotion paths verify object identity, preserve recovery state, classify possible tampering, and add coverage for link, race, substitution, and failure scenarios.

Changes

Secure updater staging

Layer / File(s) Summary
Handle-bound binary installation
internal/update/apply.go, internal/update/apply_test.go, internal/update/stage_test_seam_test.go
installBinary stages and synchronizes verified bytes through an open handle, promotes the staged object, cleans up failures, and treats tampering errors during helper refresh as fatal.
POSIX descriptor-anchored promotion and cleanup
internal/update/stage_other.go, internal/update/stage_other_test.go, internal/update/stage_promote_other_test.go, internal/update/stage_test_helpers_test.go
POSIX staging uses private directories, exclusive files, descriptor-bound creation and renames, identity checks, and conditional cleanup, with link, race, substitution, and cleanup tests.
Windows handle promotion
internal/update/stage_windows.go, internal/update/stage_windows_test.go, internal/update/stage_promote_windows_test.go
Windows validates fresh staging handles, promotes by handle, checks recovery ambiguity and final target identity, and tests reparse-point, hard-link, substitution, and cleanup cases.
Windows restoration and recovery preservation
internal/update/replace_windows.go, internal/update/replace_windows_test.go
Failed restoration marks or relocates the preserved .old copy, reports ErrTargetPossiblyTampered, and retains recovery artifacts when their state cannot be verified.

Estimated code review effort: 5 (Critical) | ~90 minutes

Sequence Diagram(s)

sequenceDiagram
  participant installBinary
  participant PlatformStaging
  participant TargetBinary
  participant Recovery
  installBinary->>PlatformStaging: Create and populate verified staged object
  PlatformStaging->>TargetBinary: Promote using handle-bound operation
  PlatformStaging->>TargetBinary: Verify promoted object identity
  PlatformStaging->>Recovery: Restore or preserve original after failure
  Recovery-->>installBinary: Return recovery or tampering status
Loading

Suggested reviewers: gnanam1990, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main security change to unpredictable, exclusive staging paths.
Linked Issues check ✅ Passed The changes address #742 by removing predictable .new staging, using exclusive no-follow/handle-bound creation, and adding regression coverage.
Out of Scope Changes check ✅ Passed The added POSIX/Windows staging, cleanup, recovery, and test updates all support the linked updater vulnerability fix.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/update/apply.go (1)

225-234: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Move the defer block before copyFile to prevent leaking temporary files.

If copyFile fails (e.g., due to a full disk or an interrupted read), installBinary returns early and the partially written staging file is never removed, causing a permanent resource leak on every failed update.

Moving the defer block immediately after stagedPath generation guarantees cleanup across all error paths. This is entirely safe: if replaceBinary successfully renames the file later, os.Remove will return a silent os.ErrNotExist that safely gets ignored by the blank identifier.

🛠 Proposed fix
 	stagedPath, err := stagingFilePath(targetPath)
 	if err != nil {
 		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
 	}
+	defer func() {
+		_ = os.Remove(stagedPath)
+	}()
 	if err := copyFile(sourcePath, stagedPath); err != nil {
 		return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
 	}
-	defer func() {
-		_ = os.Remove(stagedPath)
-	}()
 	if err := replaceBinary(targetPath, stagedPath); err != nil {
 		return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err)
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/apply.go` around lines 225 - 234, Move the cleanup defer in
installBinary to immediately after successful stagingFilePath generation and
before copyFile, so partially created staging files are removed when copying
fails. Keep the existing os.Remove callback and ignored error behavior
unchanged.
🧹 Nitpick comments (1)
internal/update/stage_other_test.go (1)

17-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate duplicated test files into a single cross-platform stage_test.go. Both test files contain virtually identical test suites. As per coding guidelines, prefer one cross-platform function with small conditional checks over duplicated helpers when behavior can remain unified.

  • internal/update/stage_other_test.go#L17-L129: merge this test suite into a new unified stage_test.go.
  • internal/update/stage_windows_test.go#L17-L130: merge this identical logic into the unified file, retaining the single conditional if runtime.GOOS == "windows" for the t.Skipf("symlink unavailable...") skip logic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/stage_other_test.go` around lines 17 - 129, Merge the
duplicated test suites from internal/update/stage_other_test.go lines 17-129 and
internal/update/stage_windows_test.go lines 17-130 into a single cross-platform
internal/update/stage_test.go. Preserve the existing tests for
createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race
coverage; retain one runtime.GOOS == "windows" conditional for the
symlink-unavailable t.Skipf behavior, and remove the duplicated
platform-specific test files.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/update/apply.go`:
- Around line 225-234: Move the cleanup defer in installBinary to immediately
after successful stagingFilePath generation and before copyFile, so partially
created staging files are removed when copying fails. Keep the existing
os.Remove callback and ignored error behavior unchanged.

---

Nitpick comments:
In `@internal/update/stage_other_test.go`:
- Around line 17-129: Merge the duplicated test suites from
internal/update/stage_other_test.go lines 17-129 and
internal/update/stage_windows_test.go lines 17-130 into a single cross-platform
internal/update/stage_test.go. Preserve the existing tests for
createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race
coverage; retain one runtime.GOOS == "windows" conditional for the
symlink-unavailable t.Skipf behavior, and remove the duplicated
platform-specific test files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 754ad950-6559-4c37-bbdf-f873fe80c35e

📥 Commits

Reviewing files that changed from the base of the PR and between ce4a996 and f62c4fc.

📒 Files selected for processing (7)
  • internal/update/apply.go
  • internal/update/apply_test.go
  • internal/update/stage_other.go
  • internal/update/stage_other_test.go
  • internal/update/stage_test_helpers_test.go
  • internal/update/stage_windows.go
  • internal/update/stage_windows_test.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 19, 2026
Vasanthdev2004
Vasanthdev2004 previously approved these changes Jul 21, 2026

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. I went through this on the security question and could not find a bypass: the verified binary reaches disk only through an exclusively-created, no-follow handle at an unpredictable path, then gets renamed into place.

Checked on head f62c4fc:

  • POSIX (stage_other.go): createStagingFile is O_CREATE|O_EXCL|O_WRONLY, so it fails on any pre-existing name including a dangling symlink, without following it. The suffix is 16 bytes from crypto/rand, not math/rand or time-derived.
  • Windows (stage_windows.go): CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT operates on the reparse point itself and fails on a planted symlink or junction, and verifyFreshRegularFile then rejects reparse point / directory / NumberOfLinks>1 via GetFileInformationByHandle before any bytes are written. Belt and suspenders.
  • Ordering is right: the checksum is verified before the binary is moved into the live target, and copyFile writes through the exact handle from createStagingFile with no reopen-by-path in between. No residual predictable .new name remains.
  • Built and ran internal/update locally: gofmt, vet and build clean, tests pass including the hardlink/symlink refusal and checksum-mismatch cases. CI green.

One non-blocking nit worth a quick follow-up: installBinary registers defer os.Remove(stagedPath) after the copyFile error check (apply.go ~229-234), and copyFile only closes dest on an io.Copy failure, never removes it. So a mid-write copy failure (short write, full disk) leaks the partial staging file in the install directory. Not a security issue (128-bit random O_EXCL name, contents are a partial copy of the already-public binary), but easy to close by moving the removal defer into copyFile right after createStagingFile succeeds, plus a test for the mid-write path. Both bots flagged the same thing.

LGTM.

gnanam1990
gnanam1990 previously approved these changes Jul 21, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — the core hardening is correct and well-tested; the only residue is orphaned staging files, which is a pre-existing cleanup gap the randomization mildly amplifies (minor), plus a test-coverage nit.

The substantive fix for #742 is solid: createStagingFile opens with O_CREATE|O_EXCL|O_WRONLY (stage_other.go), so a pre-created hard link or symlink at the staging path fails EEXIST instead of being truncated through, and the Windows path adds CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus the verifyFreshRegularFile link-count/reparse checks. I reproduced the base arbitrary-file-truncate primitive (fixed <target>.new + O_TRUNC opened through a pre-created hard link) and confirmed the new code refuses it. The regression tests in stage_other_test.go genuinely exercise both the hard-link and symlink variants and assert the victim is untouched. Good, well-commented work.


[Minor] Orphaned <binary>.<hex>.new staging files are never reclaimed and now accumulate — PR-introduced amplification of a pre-existing gap
internal/update/apply.go:229-234

Two paths leave a staged file behind, and no code ever removes .new files (CleanupStaleBinary only handles <binary>.old, and on non-Windows it is a no-op — replace_other.go:19 / replace_windows.go:56):

  • Copy failure (pre-existing ordering, PR-introduced amplification): the defer os.Remove(stagedPath) is registered at line 232, after copyFile at line 229. If copyFile fails (ENOSPC on the install volume, unreadable source), installBinary returns at line 230 before the defer is ever registered, so the partial file survives. On base this ordering existed too, but the fixed <target>.new name meant the next attempt truncated and reused the same orphan (self-healing). With the PR's random suffix, each retry on a persistently full disk writes a fresh <binary>.<hex>.new, so partial-binary-sized files pile up in the install directory.
  • Hard crash mid-window (PR-introduced): if the process is SIGKILLed / loses power after copyFile succeeds (line 229) but before replaceBinary renames (line 235), the deferred remove never runs. On base the single fixed orphan was reused by the next upgrade; now each crashed upgrade leaves a uniquely-named orphan that nothing collects.

Neither is a security or correctness bug — the swap still works and disk pressure is the only cost — hence Minor. Two independent fixes close it: (a) register the staged-file cleanup immediately after stagingFilePath succeeds (or have copyFile/createStagingFile remove its own dest on a non-nil return) so the copy-failure exit path is covered; and (b) on startup, best-effort glob and remove stale <binary>.*.new files in the target directory, analogous to CleanupStaleBinary's .old handling, to sweep crash leftovers.


[Nit] The "unpredictable path" half of the fix has no mutation-sensitive test — PR-introduced
internal/update/apply.go:260

I reverted stagingFilePath to a fixed filepath.Base(targetPath) + ".new" (dropping the random suffix) while keeping O_EXCL, and go test -count=1 ./internal/update/ still passed. The new tests call createStagingFile directly with hand-built paths, and TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails computes the occupied path via stagingFilePath itself, so both pass regardless of whether the suffix is random. The randomness (the title's stated defense-in-depth against pre-creation/guessing) can therefore be removed with zero test signal. Not a security hole — removing O_EXCL instead fails 3 tests, confirming that check is the real guard — but a small unit test asserting stagingFilePath(target) places the file in filepath.Dir(target), contains the base name, and returns two different paths on successive calls with the real randomStagingSuffix would lock in the property.


Tests: gofmt, go vet (incl. GOOS=windows), and go test -race -count=1 ./internal/update/... all pass on the PR head; no PR-attributable failures, no environmental interference in this package.

Merge is kevin's call per the program gate.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep the staged file bound to its verified handle through replacement
    internal/update/apply.go:229-235
    copyFile closes the exclusively-created staging handle before replaceBinary consumes stagedPath by name. Under #742's documented model—where the lower-privileged principal can create or replace sibling entries—it can observe the new randomized filename, replace that entry after the close, and have the updater promote its replacement instead of the verified bytes. On POSIX, replaceBinary first follows the substituted path in os.Chmod and then renames it into the executable path; Windows likewise performs the final rename by pathname after the handle has been closed. Randomizing the name and using O_EXCL only prevent pre-creation, not this live handoff race, so the updater can still install attacker-controlled code. Retain and validate object identity through the final swap (or use a handle-bound replacement primitive), and add an end-to-end substitution-race regression test.

  • [P2] Clean up a staged file when copying it fails
    internal/update/apply.go:229-234
    The removal defer is registered only after copyFile returns successfully. If io.Copy or Close fails after createStagingFile has created the destination (for example, on ENOSPC), that partial file is left behind. This change makes each retry use a fresh randomized *.new name, whereas the prior fixed name was reused, and startup cleanup only removes .old files. Repeated failed upgrades can therefore accumulate release-sized artifacts until the installation volume is exhausted. Register cleanup before copying (and clean crash leftovers) so an unsuccessful update does not consume space permanently.

…pathname

Addresses the two open findings on Gitlawb#751.

[P1] The staged file is no longer promoted by pathname. Randomizing the name
and creating it exclusively stops PRE-creation, but under Gitlawb#742's model — a
lower-privileged principal that can write in the installation directory — the
name can be observed after the fact and the entry replaced between the write and
the swap, so the updater would install the substituted file. installBinary now
keeps the creating handle open and promotes through it:

- Windows renames through the handle itself
  (SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so
  there is no second pathname lookup to win. replaceBinary is gone; promote owns
  the aside-rename of the running binary and its restore-on-failure retry.
- POSIX has no rename-by-descriptor, so staging moves into a private directory
  created next to the target by os.MkdirTemp (mode 0700, random name, created
  exclusively). An attacker who can write in the installation directory cannot
  create, replace, or list entries inside it. promote additionally sets the
  executable bit through the handle (os.Chmod would re-resolve the path) and
  fails closed if the entry ever stops naming the object it wrote.

[P2] Staging cleanup now covers every failure path: the removal defer is
registered before the copy, and stageBinary discards the object if the copy
fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a
release-sized file that the next attempt will not reuse. CleanupStaleBinary also
sweeps leftovers from a hard crash — staging files on Windows, staging
directories on POSIX, where it used to be a no-op — skipping anything younger
than an hour so a concurrent update is never disturbed.

Tests: substitution-race regression per platform (Windows asserts the verified
bytes are installed despite a replaced staging entry; POSIX asserts the private
0700 directory and that promote refuses a substituted entry), success controls,
cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test
for helper refresh now uses a stageBinary seam, because the staging location can
no longer be occupied from outside — which is the point of the fix.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed fa02593 (on top of a merge of current main). Both open findings are addressed.

[P1] Keep the staged file bound to its verified handle through replacement — this was a real hole and the fix is structural: installBinary now keeps the creating handle open and promotes the object, never the pathname.

  • Windows uses the handle-bound primitive you suggested: SetFileInformationByHandle with FILE_RENAME_INFO renames the object the staging handle already refers to, so there is no second pathname lookup for an attacker to win. createStagingFile requests DELETE alongside GENERIC_WRITE for it. replaceBinary is gone — promote owns the aside-rename of the running binary plus the existing restore-with-retry on failure. The header offset is derived with unsafe.Offsetof rather than hardcoded, so the 32/64-bit layout difference cannot bite.
  • POSIX has no rename-by-descriptor, so the entry is put somewhere the attacker cannot reach instead: staging moves into a private directory created next to the target with os.MkdirTemp (mode 0700, random name, created exclusively). A principal who can write in the installation directory can neither pre-create it nor create, replace, or even list entries inside it. On top of that, promote sets the executable bit through the handleos.Chmod was the other path-based step you flagged — and verifies with os.SameFile that the entry still names the object it wrote, failing closed otherwise.

[P2] Clean up a staged file when copying it fails (also raised by @Vasanthdev2004, @gnanam1990, CodeRabbit and Copilot) — the removal defer is registered before the copy, and stageBinary discards the object itself if the copy fails, so an ENOSPC or short-write no longer leaks a release-sized file that the next attempt will not reuse. CleanupStaleBinary now also sweeps crash leftovers — staging files on Windows, staging directories on POSIX, where it was previously a no-op — ignoring anything younger than an hour so a concurrently running update is never pulled out from under. That closes @gnanam1990's second path (SIGKILL between copy and rename) too.

Tests

  • stage_promote_windows_test.go: the staging entry is deleted and replaced with attacker bytes between write and swap; the promoted binary is still the verified one. Plus a success control asserting <target>.old preservation, and cleanup-on-failure.
  • stage_promote_other_test.go: asserts the staging directory is 0700, then rehearses the substitution anyway (a test runs as the owner, so it can do what an attacker cannot) and requires promote to refuse and leave the original binary in place; success control checks the executable bit; plus cleanup-on-failure and the crash-leftover sweep including the "young directory belongs to a concurrent update" case.
  • The forced-staging-failure test for helper refresh now goes through a stageBinary seam instead of pre-occupying <target>.new: the staging location can no longer be occupied from outside, which is exactly the property being added.

Validation — honest about the platform limits of this host (Windows, Go 1.26.5):

  • go build ./..., go vet ./..., go test ./... -count=1 pass; gofmt clean on every changed file.
  • GOOS=linux and GOOS=darwin go vet ./internal/update (which type-checks the test files too) are clean, but I could not execute the POSIX tests on this host — the private-directory staging, handle Chmod, SameFile check, and leftover sweep are compile-verified here and rely on the ubuntu/macOS CI jobs for execution. Flagging that explicitly rather than implying I ran them.
  • The Windows path — including the handle-bound rename and the substitution race — runs natively here and passes.

@jatmn @Vasanthdev2004 @gnanam1990 — this is a bigger change than the previous round (the promotion primitive itself changed per platform), so a fresh look at stage_windows.go / stage_other.go would be worthwhile. I can't use the reviewer-request button on this repo, hence the mention.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

@PierrunoYT Thanks for the detailed remediation summary. I’ll re-review the updated staging, promotion, cleanup, and platform-specific regression coverage with emphasis on the replacement race and failure-path safety.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Fix the Windows handle-promotion path before merging
    internal/update/stage_windows.go:106-118
    The required Windows job is red on the current head: TestPromoteInstallsTheStagedObjectNotTheStagedPath gets a nil result from staged.promote, but after closing the handle zero.exe does not exist. At that point the old executable has already been moved to .old and promoted suppresses deferred removal, so an update can report success while stranding the user without the executable. Correct the FILE_RENAME_INFO construction/handle rename behavior and keep an end-to-end Windows assertion that the verified binary is reachable at targetPath.

  • [P1] Keep the POSIX staging directory bound through the final rename
    internal/update/stage_other.go:65-69
    A 0700 directory protects its contents, but not its directory entry: under the issue's writable-install-directory threat model, an attacker can rename the .zero-stage-* directory from its writable parent after verifyStagedIdentity returns, recreate that directory with an attacker-controlled file at the same basename, and win the gap before os.Rename(staged.path, targetPath). The final path-based rename then installs attacker bytes. The current test only substitutes the child entry before the check, so it misses the ancestor replacement race; use a promotion primitive that remains safe when the parent namespace can change and cover this race end to end.

  • [P1] Do not leave a raced target executable in place on Windows
    internal/update/stage_windows.go:101-114
    After the updater renames the running binary to .old, a writer of the installation directory can create a malicious targetPath. renameFileByHandle deliberately uses ReplaceIfExists=false, so promotion fails; the restore also fails because that attacker file occupies targetPath. The function returns an error but leaves the attacker-controlled file at the executable path for the next launch, with the original only at .old. Restore/replace must safely handle this namespace race rather than treating it as a harmless failed update.

  • [P2] Restrict stale cleanup to artifacts the updater can prove it owns
    internal/update/stage_other.go:114-122
    internal/update/replace_windows.go:55-64
    Cleanup runs on every Apply, including the no-update path, but recognizes arbitrary old sibling entries by loose names: every .zero-stage-* directory is recursively removed on POSIX and every <binary>.*.new file is removed on Windows. Those are broader than the generated artifact formats and can match legitimate user data such as .zero-stage-backup or zero.exe.release-notes.new. The pathname is also re-resolved after the age check, allowing a writable-parent actor to swap a checked directory before RemoveAll. Validate exact owned artifacts and avoid recursive deletion of untrusted pathnames before deleting anything.

  • [P3] Do not age out an update that is still copying
    internal/update/stage_other.go:118-122
    The POSIX cleanup uses the staging directory's mtime as its liveness signal, but copyFrom only writes the child file and never refreshes that timestamp. If a copy is slow or stalled for over an hour, a second zero upgrade removes the live staging directory; the first updater then fails identity verification or promotion even though it is still active. Track live staging independently, refresh a liveness marker, or avoid age-based removal of active directories.

Address jatmn's review findings on PR Gitlawb#751:

- Windows promote() now verifies targetPath is actually reachable after a
  reported-successful handle rename before trusting it. Some Windows versions
  have been observed accepting SetFileInformationByHandle against a handle
  whose directory entry was substituted out from under it without the object
  actually moving, which let promote report success while targetPath was
  left missing entirely. renameFileByHandle is now a package var so this is
  covered by a deterministic regression test rather than relying on
  reproducing the exact trigger condition.

- When a promotion failure's restore-to-.old also fails (a writable-parent
  attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't
  get past), that combination is now wrapped in ErrTargetPossiblyTampered
  instead of reading like an ordinary failed update, with a best-effort
  MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still
  recover the original at next boot.

- POSIX promote() now binds its final rename to a directory descriptor
  opened when the staging directory is created (unix.Renameat), not a
  pathname. The 0700 staging directory protects its contents from a
  writable-parent principal, but not its own directory entry — that
  principal could rename the staging directory aside and recreate a
  look-alike with an attacker file at the same basename in the gap between
  the identity check and the rename, which a plain os.Rename would silently
  follow.

- Stale-cleanup on both platforms now matches the exact generated artifact
  shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix +
  32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a
  user's own similarly-named file or directory is never swept up. Both also
  re-check identity immediately before deleting to shrink the window a
  writable-parent principal could swap the checked path in.

- copyFrom now copies in chunks and refreshes the POSIX staging directory's
  mtime between them, since writing into an already-created file never
  touches the parent directory's own mtime — a large or slow copy could
  previously look abandoned to a concurrent update's cleanup sweep while
  still in progress.

Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet
checks for darwin/linux-arm64.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
internal/update/replace_windows.go (1)

98-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cleanup-sweep logic duplicates the POSIX implementation's structure.

Same scan→age-filter→identity-recheck→delete skeleton as removeStaleStagingLeftovers in internal/update/stage_other.go, only the name predicate (isGeneratedStagingFileName vs. isGeneratedStagingDirName) and the deletion call (os.Remove vs. os.RemoveAll) differ. See the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/replace_windows.go` around lines 98 - 129, Consolidate the
duplicated cleanup-sweep logic shared by removeStaleStagingLeftovers and the
POSIX implementation in stage_other.go into a reusable helper. Parameterize the
name predicate and deletion operation so file cleanup uses
isGeneratedStagingFileName with os.Remove, while directory cleanup uses
isGeneratedStagingDirName with os.RemoveAll; preserve the existing age and
identity-recheck behavior.

Source: Coding guidelines

internal/update/stage_other.go (1)

152-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cleanup-sweep logic is nearly identical to the Windows counterpart.

This scan→age-filter→identity-recheck→delete loop mirrors removeStaleStagingLeftovers in internal/update/replace_windows.go almost line for line, differing only in the name-matching predicate and whether the target is a directory (RemoveAll) or a file (Remove). See the consolidated comment for a suggested shared helper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/stage_other.go` around lines 152 - 184, Consolidate the
duplicated scan, age-filter, identity-recheck, and deletion logic shared by
removeStaleStagingLeftovers and its Windows counterpart into a reusable helper.
Parameterize the helper for the existing name predicate and directory/file
deletion behavior, then update both callers while preserving their current
cleanup semantics.

Source: Coding guidelines

internal/update/stage_promote_windows_test.go (1)

172-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate assertNoStagingLeftovers helper vs. the POSIX test file.

This Windows-only helper (matching .new suffix) duplicates the intent of assertNoStagingLeftovers in stage_promote_other_test.go (which also matches the generated staging-directory prefix). See consolidated comment for the proposed unification. As per coding guidelines, "Prefer one cross-platform function with small conditional checks over duplicated platform-specific helpers when behavior can remain unified."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/stage_promote_windows_test.go` around lines 172 - 184, Unify
the Windows-only assertNoStagingLeftovers helper with the cross-platform helper
in stage_promote_other_test.go, removing the duplicate definition from the
Windows test file. Preserve checks for both the generated staging-directory
prefix and the Windows “.new” suffix using small platform-appropriate conditions
within the shared helper.

Source: Coding guidelines

internal/update/stage_promote_other_test.go (2)

134-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test only checks the end-state, not "during copy" behavior it claims to regress-test.

The test forces small chunks and asserts the staging directory's mtime is fresh after copyFrom returns. An implementation that only refreshes the mtime once at the very end of the copy (rather than periodically "as it goes," per the doc comment's stated intent) would pass this assertion too — yet that implementation would not actually fix the concurrent-sweep-sees-stale-mtime-mid-copy race this test is meant to guard against. Consider asserting freshness partway through the copy (e.g., wrap the source reader to pause/check after the first chunk) to actually exercise the "during copy" guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/stage_promote_other_test.go` around lines 134 - 176,
Strengthen TestCopyFromRefreshesStagingLivenessDuringCopy to verify the staging
directory mtime during copy, not only after staged.copyFrom returns. Wrap or
control the source read so the test pauses after the first chunk, checks that
staged.dir has a mtime newer than stale, then allows copying to finish; retain
the final completion assertion and cleanup behavior.

282-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate assertNoStagingLeftovers helper vs. the Windows test file.

This helper (matching generated staging prefix or .new suffix) duplicates assertNoStagingLeftovers in stage_promote_windows_test.go (which matches .new suffix only). Since checking both patterns is harmless on either platform (the POSIX-only prefix pattern will simply never match on Windows and vice versa), this looks like a good candidate to unify into a single helper in the shared stage_test_helpers_test.go rather than maintaining two near-identical, platform-forked copies that can drift. See consolidated comment for details. As per coding guidelines, "Prefer one cross-platform function with small conditional checks over duplicated platform-specific helpers when behavior can remain unified."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/stage_promote_other_test.go` around lines 282 - 294, Move the
shared assertNoStagingLeftovers helper into stage_test_helpers_test.go and
remove the duplicate definitions from the POSIX and Windows test files. Preserve
both checks in the unified helper: stagingDirPrefix matches and the .new suffix
matches, so leftover detection remains consistent across platforms.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/update/replace_windows.go`:
- Around line 98-129: Consolidate the duplicated cleanup-sweep logic shared by
removeStaleStagingLeftovers and the POSIX implementation in stage_other.go into
a reusable helper. Parameterize the name predicate and deletion operation so
file cleanup uses isGeneratedStagingFileName with os.Remove, while directory
cleanup uses isGeneratedStagingDirName with os.RemoveAll; preserve the existing
age and identity-recheck behavior.

In `@internal/update/stage_other.go`:
- Around line 152-184: Consolidate the duplicated scan, age-filter,
identity-recheck, and deletion logic shared by removeStaleStagingLeftovers and
its Windows counterpart into a reusable helper. Parameterize the helper for the
existing name predicate and directory/file deletion behavior, then update both
callers while preserving their current cleanup semantics.

In `@internal/update/stage_promote_other_test.go`:
- Around line 134-176: Strengthen TestCopyFromRefreshesStagingLivenessDuringCopy
to verify the staging directory mtime during copy, not only after
staged.copyFrom returns. Wrap or control the source read so the test pauses
after the first chunk, checks that staged.dir has a mtime newer than stale, then
allows copying to finish; retain the final completion assertion and cleanup
behavior.
- Around line 282-294: Move the shared assertNoStagingLeftovers helper into
stage_test_helpers_test.go and remove the duplicate definitions from the POSIX
and Windows test files. Preserve both checks in the unified helper:
stagingDirPrefix matches and the .new suffix matches, so leftover detection
remains consistent across platforms.

In `@internal/update/stage_promote_windows_test.go`:
- Around line 172-184: Unify the Windows-only assertNoStagingLeftovers helper
with the cross-platform helper in stage_promote_other_test.go, removing the
duplicate definition from the Windows test file. Preserve checks for both the
generated staging-directory prefix and the Windows “.new” suffix using small
platform-appropriate conditions within the shared helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1c330dc9-d61a-49c6-9d09-7c211cf38cc5

📥 Commits

Reviewing files that changed from the base of the PR and between fa02593 and b2a115e.

📒 Files selected for processing (7)
  • internal/update/apply.go
  • internal/update/replace_windows.go
  • internal/update/replace_windows_test.go
  • internal/update/stage_other.go
  • internal/update/stage_promote_other_test.go
  • internal/update/stage_promote_windows_test.go
  • internal/update/stage_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/update/apply.go

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 25, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Restore gofmt cleanliness before merging
    internal/update/replace_windows_test.go:90
    The required Ubuntu smoke job currently fails its formatting step on this file, so vet, tests, build, and smoke are all skipped. Run gofmt and commit the resulting alignment change.

  • [P1] Bind the POSIX staging directory before an attacker can replace it
    internal/update/stage_other.go:41
    MkdirTemp creates the private directory, but the code subsequently opens it again by pathname. A principal that can write the installation directory can rename that entry and recreate it before os.Open, making dirHandle refer to an attacker-writable directory. The attacker can then replace the child after verifyStagedIdentity and before Renameat, causing the verified updater to install attacker bytes. Acquire/validate the created directory without this pathname handoff and cover that interleaving.

  • [P1] Do not recursively remove an attacker-substituted staging directory
    internal/update/apply.go:337
    The POSIX promotion deliberately tolerates the staging directory being renamed aside, but deferred discard later calls RemoveAll(staged.dir) through the old pathname. An attacker can replace that pathname with a chosen directory; the elevated updater then recursively deletes the replacement. The stale-cleanup path in stage_other.go:177-182 has the same Lstat-to-RemoveAll race. Keep deletion bound to the original object or fail closed rather than recursively deleting a mutable pathname; the ancestor-replacement test should assert the impostor survives deferred cleanup.

  • [P1] Verify that the promoted Windows target is the staged object
    internal/update/stage_windows.go:149
    This check accepts any non-directory at targetPath. In the anomalous success case it is meant to handle—handle rename reports success but does not link the staged file—an attacker with write access to the installation directory can create a regular malicious target before os.Stat. The check then passes and the update is reported successful with attacker bytes. Verify object identity, not only target existence.

  • [P1] Do not schedule reboot recovery from a mutable source pathname
    internal/update/replace_windows.go:66
    After an immediate restore failure, this queues <target>.old to replace the executable at reboot. Under the stated writable-directory threat model, an attacker can replace that source entry before reboot, turning the recovery action into a delayed installation of attacker-selected bytes. Surface the recovery failure or use a recovery source outside the attacker-controlled directory.

  • [P2] Do not infer cleanup ownership from a public filename shape
    internal/update/stage_other.go:163
    Any old directory named .zero-stage-<1..10 digits> is treated as an updater artifact and recursively removed; no provenance distinguishes it from user data. Windows makes the same assumption for any exact <binary>.<32 hex>.new file in replace_windows.go:109. Exact pattern matching reduces accidental collisions but does not establish ownership, so preserve non-verifiable entries or introduce durable, unforgeable ownership metadata with an appropriate migration story.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the requested changes in 33ab509:

  • restored gofmt cleanliness for replace_windows_test.go;
  • bound POSIX staging creation, file creation, promotion, and cleanup to validated directory descriptors;
  • made POSIX cleanup fail closed and verified an attacker-substituted directory survives cleanup;
  • changed Windows promotion verification to confirm the staged handle resolves to the target path, rather than accepting any regular file there;
  • removed reboot recovery from the mutable <target>.old pathname;
  • stopped deleting unverifiable random staging artifacts based only on their public filename shape;
  • added regression coverage for the POSIX creation-to-open replacement race, substituted-directory cleanup, Windows target identity, and preservation of unverifiable artifacts.

Validation completed:

  • go test ./internal/update/...
  • go vet ./...
  • Linux and Darwin updater vet/cross-compilation
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • govulncheck ./... — no vulnerabilities found
  • git diff HEAD --check

Environment limitations: the repository-wide test run reaches an unrelated CLI completion test that requires an installed WSL distribution, and -race is unavailable because this Go environment has CGO disabled. The pinned advisory linter still reports the existing repository-wide backlog; it reports no new finding in these changed Windows files.

@PierrunoYT
PierrunoYT requested a review from jatmn July 30, 2026 07:09
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [P1] Recovery markers on random aside paths are not consulted before promotion retries
    internal/update/stage_windows.go:115-121, internal/update/replace_windows.go:61
    After the first successful Windows upgrade, <target>.old is always left behind (CleanupStaleBinary is now a no-op), so every later promote uses a random aside path when .old already exists (stage_windows.go:134-140). If that promotion fails and restoreOriginalBinary cannot put the aside copy back, markOldBinaryPreserved writes <target>.<suffix>.old.keep. The next promote only calls oldBinaryPreserved on the canonical <target>.old path, so it never sees the marker on the aside file. Because the stale canonical .old has no .keep, a retry proceeds over an unverified targetPath while the marked recovery copy sits on the aside path the refusal logic does not watch. This is systematic on second and later failed updates, not an edge case. Please gate promotion on markers for whichever aside path was actually used (or scan for any *.keep beside a sibling *.old), and add a Windows regression that leaves a stale <target>.old, fails promotion with aside at <target>.<suffix>.old marked, then asserts the next installBinary refuses instead of proceeding.

  • [P2] Missing-target recovery guidance can point at the wrong .old file
    internal/update/stage_windows.go:123-128, internal/update/stage_windows.go:134-140
    After an interrupted aside-rename on a second or later upgrade, disk state can be: targetPath missing, a stale <target>.old from an earlier successful upgrade, and the actual last running binary at <target>.<suffix>.old from the interrupted attempt. The refusal at lines 123-128 only names <target>.old as the recoverable copy. An operator following that message can restore the wrong (older) binary. TestPromoteRefusesRetryAfterInterruptedAside covers only the single-.old layout. Please name the aside path that actually holds the last moved-aside binary, or refuse until the operator resolves ambiguous multi-.old layout, and add a Windows test with both a stale canonical .old and a fresh random aside present while targetPath is absent.

  • [P2] A partial marker write can orphan .keep and cite the wrong recovery path
    internal/update/replace_windows.go:135-145, internal/update/replace_windows.go:61-69
    markOldBinaryPreserved creates the marker exclusively, but if WriteString or Close fails it returns an error without removing the newly created file. When keepUnmarkedRecoveryCopy then succeeds, the recovery bytes move to *.<suffix>.recovery while a .keep marker remains at the old path. oldBinaryPreserved still returns true for that path, so later promote refuses fail-closed (good) but names the wrong location as the verified copy. This needs a disk-full-style failure and is narrower than the P1 aside-path gap, but the wrong-path error text is still misleading during incident response. Please roll back or finalize the marker on write/close failure before relocating the copy, or consult the relocated path in oldBinaryPreserved, and add regression coverage for marker-write failure followed by relocation.

  • [P3] Operator error text still promises cleanup that no longer exists
    internal/update/replace_windows.go:71-73
    When marker creation and recovery relocation both fail, the error tells the operator that a later update will otherwise remove <target>.old. CleanupStaleBinary is now a no-op on all platforms and is no longer called from Apply, so that removal never happens. This is stale wording left over from the old cleanup contract, not a logic bug, but it can mislead incident response. Please update the message to say manual copy is required.

A failed second-or-later update marks the randomized aside it used, but
promotion only consulted the canonical <target>.old marker, so a retry
proceeded over an unverified target while the verified copy sat marked on a
path the refusal logic did not watch. Promotion now enumerates every recovery
candidate — canonical and randomized asides — and refuses while any of them
is marked, naming each recovery path and its marker.

The missing-target refusal had the same single-path blind spot: with a stale
canonical .old from an earlier successful update plus the aside from an
interrupted attempt, it named only the canonical path, which can be the wrong
binary. It now names the single actual candidate or refuses the ambiguous
layout outright until the operator resolves it.

A failed marker write could also leave a partial .keep behind while the
recovery copy was relocated elsewhere; later refusals then named a location
that no longer held the verified bytes. markOldBinaryPreserved now removes
the entry it created before reporting a write/close failure, and
conservatively reports success when that removal (or the state check) fails
so oldPath stays authoritative. keepUnmarkedRecoveryCopy is handle-bound end
to end: it pins the recovery copy with a no-delete-sharing, no-reparse open,
verifies it is a single-link regular file, renames through the handle with
ReplaceIfExists false, and confirms identity at the destination, failing
closed with ErrTargetPossiblyTampered on any race.

Finally, the error for an unestablishable marker still promised that a later
update would otherwise remove the copy, but CleanupStaleBinary is now a
no-op; the operator is told that a manual copy is required.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all four findings are addressed in c137ff4.

[P1] Recovery markers on random aside paths not consulted: promote now enumerates every recovery candidate — canonical <target>.old plus randomized <target>.<suffix>.old — via existingRecoveryPaths/markedRecoveryPaths and refuses while any of them carries a .keep marker, naming each recovery path and its marker in the refusal. Regression: TestPromoteRefusesMarkedRandomAsideRecovery leaves a stale canonical .old, marks the random aside, and asserts the next installBinary refuses and the marked copy survives.

[P2] Missing-target guidance could name the wrong .old: the missing-target branch now names the single actual recovery path (whichever aside holds it) or, with multiple candidates, refuses with an explicit ambiguous-recovery message listing all of them until the operator resolves the layout. Regression: TestPromoteRefusesAmbiguousRecoveryWhenTargetIsMissing covers the stale-canonical-plus-fresh-aside layout.

[P2] Partial marker write can orphan .keep: markOldBinaryPreserved now removes the marker entry it created before returning a write/close error, so relocation never leaves a marker pointing at a path that no longer holds the verified copy. If the removal or the state check fails, it conservatively reports success so oldPath stays the authoritative recovery location. Regression: TestMarkOldBinaryPreservedRemovesPartialMarkerBeforeRelocation.

[P3] Stale cleanup promise: the error now tells the operator a manual copy is required; TestRestoreOriginalBinarySurfacesMarkerWriteFailure asserts the old "a later update will otherwise remove it" wording is gone.

Both open CodeRabbit comments are covered by the same commit: the partial-marker rollback above, and keepUnmarkedRecoveryCopy is now handle-bound end to end — no-delete-sharing/no-reparse open, single-link regular-file verification, rename through the handle with ReplaceIfExists=false, and a post-rename identity check — failing closed with ErrTargetPossiblyTampered on any substitution race. Regression: TestKeepUnmarkedRecoveryCopyMovesTheOpenedObject.

Validation: go vet ./..., full go test ./... (all packages, including the four new Windows regression tests), zero-release build + smoke, golangci-lint (unused/ineffassign/staticcheck) clean, govulncheck clean, and linux/darwin/windows cross-compilation.

@PierrunoYT
PierrunoYT requested a review from jatmn July 30, 2026 19:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/update/replace_windows.go (1)

144-153: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Boolean logic is correct but fragile — mixing &&/|| across three sub-conditions on one line.

I traced all realistic (removeErr, statErr) combinations and the current precedence does resolve correctly (returns nil/"conservatively preserved" exactly when removal/absence can't be positively confirmed), but it's an easy trap for a future edit to silently break.

♻️ Suggested clarification (no behavior change)
 		removeErr := os.Remove(markerPath)
 		_, statErr := os.Lstat(markerPath)
-		if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) ||
-			statErr == nil || !errors.Is(statErr, os.ErrNotExist) {
+		removed := (removeErr == nil || errors.Is(removeErr, os.ErrNotExist)) &&
+			errors.Is(statErr, os.ErrNotExist)
+		if !removed {
+			// Could not positively confirm the partial marker was cleaned up;
+			// conservatively keep treating oldPath as marked/preserved.
 			return nil
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/update/replace_windows.go` around lines 144 - 153, Clarify the
compound condition in the recovery-marker cleanup flow around removeErr and
statErr by adding explicit grouping or splitting it into named boolean checks,
without changing its current behavior. Preserve the existing conservative
return-nil cases and the subsequent writeErr/closeErr reporting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/update/replace_windows.go`:
- Around line 172-196: Update keepUnmarkedRecoveryCopy to return the attempted
kept path alongside the verification error when renameRecoveryFileByHandle
succeeds but verifyPromotedTarget fails; retain the empty path for failures
before the rename. Update restoreOriginalBinary’s caller handling to check kept
!= "" and report that unverified recovery path, falling back to oldPath only
when no move was attempted.

In `@internal/update/stage_windows.go`:
- Around line 197-215: Update existingRecoveryPaths to match recovery filenames
case-insensitively, including the base-name prefix and .old suffix checks, so
files such as zero.exe.OLD are included. Preserve the existing minimum-length
guard and path construction behavior.

---

Nitpick comments:
In `@internal/update/replace_windows.go`:
- Around line 144-153: Clarify the compound condition in the recovery-marker
cleanup flow around removeErr and statErr by adding explicit grouping or
splitting it into named boolean checks, without changing its current behavior.
Preserve the existing conservative return-nil cases and the subsequent
writeErr/closeErr reporting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4118db2f-21db-4e83-8689-13ae6fefb3d6

📥 Commits

Reviewing files that changed from the base of the PR and between 7555200 and c137ff4.

📒 Files selected for processing (4)
  • internal/update/replace_windows.go
  • internal/update/replace_windows_test.go
  • internal/update/stage_promote_windows_test.go
  • internal/update/stage_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/update/replace_windows_test.go

Comment thread internal/update/replace_windows.go
Comment thread internal/update/stage_windows.go
…stitution

TestPromoteInstallsTheStagedObjectNotTheStagedPath deletes the staging
file's only directory entry and recreates it as an "attacker" file, then
required promote to succeed with the verified bytes installed. CI's
windows-latest runner permits that delete (this workstation's exclusive
share mode blocks it, so the path went unexercised here); on that runner,
renaming the now fully-unlinked staging handle back into existence is not
something every Windows build honors, so verifyPromotedTarget's post-
rename identity check finds nothing at targetPath, promote fails, and
restoreOriginalBinary moves the pre-update binary back — a safe, fail-
closed outcome, not a security regression.

The test now accepts either outcome after a real substitution: promote
succeeding with the verified bytes, or promote failing as long as the
attacker's substituted bytes were never installed. Any promote failure
that occurs without substitution having actually happened still fails
the test outright.
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

CI failure on c137ff4's Smoke (windows-latest) job fixed in eeb44e2.

Root cause: TestPromoteInstallsTheStagedObjectNotTheStagedPath tries to rehearse the strongest form of staging-entry substitution by deleting the exclusively-locked staging file's directory entry and recreating an "attacker" file at that same path, then asserting promote always succeeds with the verified bytes installed.

That delete is supposed to fail everywhere (share mode 0 on the staging handle should block it) — and it does on my workstation — but windows-latest permitted it. Once the staging file is fully unlinked (zero directory entries) and a new file recreated at its old name, renaming the original handle back into existence via SetFileInformationByHandle/FileRenameInfo is not something every Windows build honors. verifyPromotedTarget's post-rename identity check correctly finds nothing at targetPath, promote fails, and restoreOriginalBinary moves the pre-update binary back — a safe, fail-closed outcome. It is not a security regression: the attacker's substituted bytes are never installed either way, just via the fail-closed path instead of the handle-rename defeating the substitution outright.

The test asserted "promote must always succeed" and didn't allow for that legitimate failure mode. It now accepts either outcome once real substitution occurred: promote succeeding with the verified bytes, or promote failing as long as the attacker's bytes were never installed. A promote failure that happens without substitution having actually occurred still fails the test outright, so this doesn't mask a real regression.

Validation: go build ./..., go vet ./..., gofmt -l, full go test ./internal/update/... -count=1 — all pass locally (Windows).

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

The #742 staging hardening on head eeb44e2 looks sound: unpredictable exclusive staging, handle-bound Windows promotion with post-rename identity verification, descriptor-bound POSIX promotion/cleanup, and the adversarial regression coverage address the reported arbitrary-file-overwrite primitive. Your commits since fd383fd3 also resolve my earlier blockers around marked-recovery overwrite on retry, startup cleanup deleting preserved .old copies, helper ErrTargetPossiblyTampered propagation, and relocating the recovery copy when the marker cannot be written.

The remaining blockers below are real gaps in the Windows recovery/cleanup contract this PR added after that core staging work. They are not regressions in the #742 fix itself, and they are not documentation drift — findings 1 and 2 are omissions in new recovery-path logic; finding 3 is incomplete follow-through on the same writable-install-directory threat model on a failure-path cleanup that still uses pathname removal after the exclusive handle is released.

Findings

  • [P2] Propagate the authoritative recovery path when relocation verification fails
    internal/update/replace_windows.go:65-74
    internal/update/replace_windows.go:189-194
    When markOldBinaryPreserved fails, keepUnmarkedRecoveryCopy handle-renames the verified recovery bytes away from <target>.old and then calls verifyPromotedTarget. The success branch at lines 65–69 correctly names the new *.recovery path in the outer error. The keepErr != nil branch at lines 71–74 always tells the operator to manually copy <target>.old somewhere safe now and drops keepErr, even when the handle-bound rename already succeeded and oldPath is empty. In that case the only verified copy lives at the *.recovery path named inside keepErr, not at oldPath. The bytes are not lost, but the surfaced guidance points at a path that no longer exists. Please propagate keepErr (or extract and include the kept path) in the fallback branch, and add a Windows regression that stubs a failing verifyPromotedTarget after a successful renameRecoveryFileByHandleTestRestoreOriginalBinaryKeepsRecoveryCopyWhenMarkingFails only covers the success path today.

  • [P2] Include *.recovery copies in the recovery state machine
    internal/update/stage_windows.go:197-232
    internal/update/replace_windows.go:183-195
    keepUnmarkedRecoveryCopy relocates the last verified binary to <base>.<suffix>.recovery when marker creation fails. existingRecoveryPaths and markedRecoveryPaths only scan *.old and *.<suffix>.old (lines 207–211), so a surviving *.recovery sibling is invisible to the pre-promotion refusal logic. After a successful relocation, a retry can call promote without failing closed on that orphaned copy — unlike marked .old recovery, which TestPromoteRefusesWhileRecoveryCopyIsMarked already guards. Please extend recovery inspection to treat *.<suffix>.recovery as authoritative recovery state (or otherwise refuse promotion while one exists) and add coverage for retry after a successful relocation.

  • [P2] Bind Windows staging cleanup to the staged object, not its pathname
    internal/update/apply.go:328-335
    internal/update/stage_windows.go:290-297
    This PR hardened staging creation and promotion under the writable-install-directory threat model but left failure-path cleanup on a pathname re-resolve. installBinary’s deferred discard closes the exclusive staging handle and then discardPaths calls os.Remove(staged.path) (POSIX counterpart unlinks through the bound directory descriptor after an identity check in stage_other.go). A principal who can write the install directory and observes the random *.new path can substitute that directory entry in the gap between Close and Remove, so cleanup may unlink an attacker-chosen entry instead of the staging object this updater created. This is not the original #742 overwrite primitive — it affects failed-attempt cleanup, not verified-byte installation — but it is inconsistent with the handle-bound hardening applied elsewhere in this change. Please delete the staging object through the handle (for example FileDispositionInfo) before releasing it, or otherwise avoid pathname-based removal after the exclusive handle is dropped, and add a regression patterned after the POSIX impostor-survival tests.

…itively

keepUnmarkedRecoveryCopy discarded the destination path when the rename
to it succeeded but the post-rename identity verification failed,
leaving restoreOriginalBinary's error telling the operator to save
oldPath after it had already been vacated. Return the attempted kept
path alongside the verification error so the caller can still name it.

existingRecoveryPaths compared "<target>.old" filenames case-sensitively,
but NTFS is case-insensitive/case-preserving, so a recovery or marker
file spelled e.g. "zero.exe.OLD" could be silently skipped by every
caller's fail-closed check. Fold both sides before matching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 30, 2026
@PierrunoYT
PierrunoYT requested a review from jatmn July 30, 2026 20:45
jatmn
jatmn previously approved these changes Jul 31, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes — two things, both narrow, then this is good to go.

First, credit where it's due: the core #742 fix is solid. I mutation-tested the three guards it rests on and all three are genuinely pinned, not just asserted:

  • swapping CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT for CREATE_ALWAYS in createStagingFile makes TestCreateStagingFileRefusesPrecreatedHardLink fail with victim content = "" (the truncate lands), and the concurrent test drops to 9 winners
  • deleting the verifyPromotedTarget call in promote makes TestPromoteRejectsALyingRenameByHandle fail
  • disabling the marked-recovery refusal makes both TestPromoteRefusesWhileRecoveryCopyIsMarked and TestPromoteRefusesMarkedRandomAsideRecovery fail

go build ./..., go vet (windows/linux/darwin), gofmt -l and go test ./internal/update/... -count=1 are clean on 76735375.

1. CleanupStaleBinary became a no-op with no production callers, so every upgrade leaks a full binary forever

internal/update/replace_windows.go:260, internal/update/stage_other.go:197, internal/update/apply.go:60-71

On main, Apply called CleanupStaleBinary(executablePath) (apply.go:62) and the Windows implementation removed <target>.old. This PR drops the call from Apply and empties both implementations — grep -rn CleanupStaleBinary finds no non-test caller left anywhere in the tree. The exported symbol is dead API whose only remaining "coverage" is four tests that pass vacuously against an empty function.

I drove the real installBinary four times against one target. The install directory afterwards:

zero.exe
zero.exe.old
zero.exe.453803e71c0448d5e801e488b92c5b55.old
zero.exe.b3e041ea951fed2707d8c1b21012e2ab.old
zero.exe.cd68e3e86d1d42ddba78947a8c418a2d.old

bin/zero.exe is 23.9 MB, and the two Windows helpers leak their own .old copies through the same path. Nothing in the product ever removes any of them, and there's no zero doctor-style cleanup to point an operator at.

There's a second-order effect too. Because <target>.old now survives forever, existingRecoveryPaths returns ≥2 entries on every install past its second upgrade. I removed the target after two upgrades and retried:

install zero.exe: target executable path may hold unverified content after a failed update:
  ...\zero.exe is missing and multiple recovery binaries exist
  (...\zero.exe.4c20eb97db6e3ffc8fed777a97760aa5.old, ...\zero.exe.old);
  resolve the ambiguous recovery state before updating again

So the case 1: branch at stage_windows.go:141-145 — the one that names the single recoverable binary, which is the good guidance — is unreachable on any real install, and TestPromoteRefusesRetryAfterInterruptedAside only exercises the first-ever-upgrade layout that produces it.

I understand why you removed the cleanup: a public pathname can't prove an .old is obsolete when the directory is attacker-writable, and I don't want that reasoning reverted. But "never clean up anything" isn't the only fail-safe answer. The updater does know, at promotion time, exactly which path it just renamed aside and what was in it. Recording that in updater-owned state outside the install directory (config dir), and deleting only paths that state vouches for on the next successful promotion, gets you bounded growth without trusting a filename. Deleting the aside copy in-process isn't an option for the main binary since it's the running image, but it is for the helpers, which aren't running.

Whatever shape you pick, please leave the install directory bounded and add a regression that runs installBinary three or four times and asserts the leftover count doesn't grow without limit.

2. A relocated *.recovery copy is invisible to the refusal logic, so the next update walks over an unverified target

internal/update/stage_windows.go:197-220, internal/update/replace_windows.go:181-209

This is the "Include *.recovery copies in the recovery state machine" item from jatmn's 2026-07-30 review; 76735375 fixed the sibling finding but not this one.

existingRecoveryPaths matches <base>.old and <base>.<suffix>.old only. keepUnmarkedRecoveryCopy relocates the verified copy to <base>.old.<suffix>.recovery (replace_windows.go:192), which nothing scans — so after a successful relocation, promote's pre-flight sees a clean directory.

Proven through the real entry point. First installBinary with renameFileByHandle injected to fail after creating an exclusively-held file at the target, and markOldBinaryPreserved stubbed to fail:

  • returns ErrTargetPossiblyTampered — correct
  • zero.exe holds "attacker" (unverified)
  • verified bytes relocated to zero.exe.old.deadbeef.recovery
  • zero.exe.old vacated

Then, with every seam restored, the operator just runs the upgrade again — nothing has acknowledged the tampering. Second installBinary returned nil. It renamed the unverified "attacker" bytes aside into zero.exe.old and installed over them, so existingRecoveryPaths now reports [zero.exe.old] — the unverified bytes — as the recovery copy, while the actual last-verified binary sits at a .recovery name nothing looks at.

That's the exact loss the .keep refusal exists to prevent, reached through the relocation path instead. Please treat <base>.<suffix>.recovery as authoritative recovery state in existingRecoveryPaths/markedRecoveryPaths (or refuse promotion outright while one exists), with a regression for retry-after-successful-relocation.

Smaller things, not blocking

  • stage_windows.go:301discardPaths still does os.Remove(staged.path) after discard() closes the exclusive handle. I confirmed it deletes whatever entry occupies that path: close the handle, plant a different file at the staging name, call discard(), and the planted file is gone. It's the one place left that re-resolves a pathname in a change whose whole thesis is handle-binding. jatmn raised this on 2026-07-30 as well.
  • stage_other.go:138createStagingFile has no production caller on POSIX; createStagedBinary goes through createStagingFileAt. The four tests in stage_other_test.go that the PR body describes as reproducing the reported primitive only call the dead one, so they'd stay green if createStagingFileAt lost O_EXCL|O_NOFOLLOW. The real POSIX path is stronger, so nothing is broken — but either delete the dead helper and point those tests at createStagingFileAt, or say in the comment that they cover the primitive rather than the production path.
  • replace_windows_test.go:88-196 and stage_promote_other_test.go:237 — the TestCleanupStaleBinaryPreserves* tests can't fail against an empty function. If cleanup comes back in some form, they'll need real assertions; if it doesn't, they're noise.
  • stage_promote_windows_test.go:448 — "the replaced binary must be preserved for later cleanup" no longer describes anything.
  • Worth a line in the upgrade docs: under this PR's own threat model, someone who can write the install directory can plant <target>.old + <target>.old.keep and make every future update refuse. Fail-closed is the right call and the error text tells the operator how to clear it, but it's a behaviour change from main (which would have removed the .old and proceeded) and operators should hear about it before they hit it.

@PierrunoYT
PierrunoYT dismissed stale reviews from jatmn and coderabbitai[bot] via e1abb74 August 1, 2026 16:23
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

PierrunoYT addressed the two requested Windows recovery changes in e1abb74:

  • Recovery growth is now bounded after a verified promotion. Older unmarked .old copies are bound to no-follow handles before the promotion gap and disposition-deleted only after the staged object is verified at the target; the newly-created aside remains available. A target-scoped cross-process Windows mutex serializes the complete inspect/promote/restore/cleanup transaction so concurrent updater recovery state cannot be captured or removed.
  • Relocated *.recovery copies now fail closed before promotion, including copies derived from both canonical and randomized aside paths.
  • Added regressions for four repeated upgrades (one current previous-version recovery remains), canonical/randomized relocated-recovery refusal, and target-lock serialization.

Validation:

  • make fmt-check
  • go vet ./...
  • go test ./...
  • go test ./internal/update/... -count=1
  • Windows go vet ./internal/update/...
  • Windows go build ./...
  • Windows test package compilation including the new focused tests
  • go run ./cmd/zero-release build
  • go run ./cmd/zero-release smoke
  • make lint-static
  • make vulncheck
  • git diff HEAD --check

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Fix the normal Windows handle-rename path
    internal/update/stage_windows.go:432
    The normal (non-racing) promotion path is failing in the current required Windows smoke job: TestPromoteInstallsTheStagedObjectNotTheStagedPath gets The system cannot find the path specified from SetFileInformationByHandle. That means a standalone Windows update moves the current executable to its aside path, cannot promote the verified staged object, and rolls back instead of installing. Please correct the FILE_RENAME_INFO/rename invocation and add coverage for the ordinary promotion path on the supported Windows runner.

  • [P2] Do not delete arbitrary *.old files during recovery cleanup
    internal/update/replace_windows.go:260
    prepareRecoveryCleanup passes every unmarked name accepted by existingRecoveryPaths to the destructive handle-based cleanup. That matcher accepts any <target>.<anything>.old, so a user backup such as zero.exe.before-manual-patch.old is silently deleted on the next successful update. The broad pattern is appropriate for failing closed on possible recovery state, but cleanup needs exact updater-owned provenance before deleting a file.

@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Addressed the latest Windows review blockers in c043afe, a3b6034, and aaadeab (head aaadeab):

  • Fixed the ordinary SetFileInformationByHandle(FileRenameInfo) path by retaining the terminating UTF-16 NUL in the FILE_RENAME_INFO buffer while excluding it from FileNameLength. The normal promotion path now passes on windows-latest.
  • Kept broad *.old discovery for fail-closed recovery checks, but removed filename-pattern authorization from destructive cleanup.
  • New asides use unpredictable namespaced paths. Cleanup provenance is stored atomically in per-user Zero state with the exact path and Windows volume/file identity, bound to a handle opened before the original is renamed aside. A later promotion deletes only a no-follow/single-link handle whose identity matches that trusted record.
  • Added regressions for manual *.old backups, lookalikes, correctly shaped planted names, aside substitution before recording, bounded repeated upgrades, and the ordinary Windows promotion path. Updated the standalone apply assertion for namespaced recovery copies.

Validation on final head:

  • GitHub CI: Smoke (windows-latest) passed (including go test ./..., build, and smoke); Ubuntu and macOS smoke passed; Security & code health, Performance Smoke, Zero Review, and CodeRabbit passed.
  • Local required suite: make fmt-check, go vet ./..., go test ./..., go run ./cmd/zero-release build, go run ./cmd/zero-release smoke, make lint-static (0 issues), make vulncheck (no vulnerabilities), and git diff HEAD --check.
  • Focused: go test -race ./internal/update/... -count=1, final-head go test ./internal/update/... -count=1, Windows amd64/386 go test -c ./internal/update, and Windows amd64 go vet ./internal/update/... plus go build ./....

@jatmn @Vasanthdev2004, please re-review when convenient.

Failure-path staging cleanup on Windows re-resolved the staging pathname
after the exclusive handle was closed, so a principal who can write in the
installation directory could substitute that entry in the gap and have the
updater delete a file of their choosing. Removal is now requested through the
handle (FileDispositionInfo) before it is released, and nothing is removed by
name afterwards; the same change covers the staging-file verification failure
path and the partial recovery marker.

CleanupStaleBinary has had no production caller since bounded, identity-bound
cleanup moved into promote, so the exported no-op and the tests that could only
pass vacuously against it are removed. The POSIX link-regression tests now
drive createStagingFileAt, the primitive createStagedBinary actually uses,
instead of a path-taking helper kept alive only for them.

Also documents the fail-closed Windows recovery states in docs/UPDATE.md,
including the refusal a planted .old/.keep pair can cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@PierrunoYT

Copy link
Copy Markdown
Contributor Author

Pushed 263ce9e7. Status of every open item:

@jatmn (2026-08-01 19:13)

  • P1 — Windows handle rename fails on the normal path. Fixed in a3b60342: FILE_RENAME_INFO now keeps the NUL terminator in the buffer while FileNameLength still excludes it. TestPromoteInstallsTheStagedObjectNotTheStagedPath passes, and the required Smoke (windows-latest) job is green on the head this comment covers.
  • P2 — arbitrary *.old deleted during cleanup. Fixed in e1abb74b/c043afe8: discovery stays deliberately broad so suspicious state fails closed, but deletion now requires updater-owned provenance. Each promotion moves the running binary to <target>.zero-update-<128-bit hex>.old and records that path plus its volume/file index in per-user state outside the install directory; the next successful promotion opens only that recorded path, re-checks the identity, and deletes it through the handle. zero.exe.before-manual-patch.old (and a .zero-update-not-owned.old lookalike, and a planted namespaced name whose identity does not match) survive — TestInstallBinaryPreservesArbitraryOldFilesDuringCleanup, TestRecordRecoveryCleanupRejectsSubstitutedAside.

@Vasanthdev2004

  • 1 — CleanupStaleBinary no-op / unbounded leak. The leak is fixed by the identity-bound cleanup above: leftovers stay at one recovery copy across repeated upgrades (TestInstallBinaryBoundsRecoveryCopiesAcrossRepeatedUpgrades runs four installs). The dead exported symbol is now gone on both platforms in this commit, along with the tests that could only pass vacuously against it.
  • 2 — relocated *.recovery invisible to the refusal logic. Fixed in c137ff48: relocatedRecoveryPaths matches both <target>.old.<suffix>.recovery and the randomized-aside form <target>.<aside>.old.<suffix>.recovery, and promote refuses outright while one exists, naming it. Covered by TestInstallBinaryRefusesRelocatedRecoveryCopy and TestInstallBinaryRefusesRecoveryRelocatedFromRandomizedAside.
  • Nit — discardPaths still os.Removes after the handle closes (also jatmn's 07-30 P2). Fixed here. discard now calls a platform hook that marks the staged object for deletion via FileDispositionInfo before the handle is released; Windows discardPaths removes nothing by name (POSIX keeps unlinking through the private staging-directory descriptor). If the disposition request fails the file is leaked rather than removed by name — a leaked file costs disk, deleting an attacker-chosen entry costs the operator a file they own. Same treatment for createStagingFile's verification-failure path and the partial recovery marker in markOldBinaryPreserved. New TestDiscardLeavesASubstitutedStagingEntryAlone plants a file at the staging name after the handle drops and asserts it survives; it fails against the old os.Remove version.
  • Nit — dead POSIX createStagingFile. Removed. The four link/race regressions now go through createStagingFileAt, the primitive createStagedBinary uses, so weakening its O_EXCL|O_NOFOLLOW fails them.
  • Nit — stale "preserved for later cleanup" comment. Reworded.
  • Nit — document the fail-closed behaviour. Added a "Recovery state (standalone installs)" section to docs/UPDATE.md: what each leftover means, the two moves that clear it, and explicitly that a planted <binary>.old + .keep pair can make updates refuse until an operator clears them, plus why that direction is the right trade.

CodeRabbit — the .old case-insensitive match and the unverified-recovery-path propagation landed in 76735375; the removeErr/statErr compound condition in markOldBinaryPreserved is now split into named branches with the reasoning for each.

Verification on this head: go build ./..., go vet ./internal/update/... and go test ./internal/update/... -count=1 pass on windows, go build/go vet cross-compiled for linux and darwin, gofmt -l clean on the changed files.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Keep recovery restore bound to the original object
    internal/update/stage_windows.go:194
    This correctly captures the pre-update file's identity, but it does not preserve that identity across the recovery path: openIdentityFile permits delete sharing, the value is used only for successful-cleanup bookkeeping, and restoreOriginalBinary later moves asidePath by pathname. After os.Rename(targetPath, asidePath), a directory writer can rename the genuine aside entry away, place attacker bytes at that name, then temporarily occupy targetPath to make renameFileByHandle fail. If it releases that target handle before the retrying restore, os.Rename(asidePath, targetPath) installs the substitute and returns success; the update reports only an ordinary install failure, while the next Zero invocation executes attacker-controlled bytes. The root cause is treating a writable-directory pathname as recovery provenance after the promotion gap. Keep a recovery handle that denies substitution and rename that exact object back by handle, or verify its identity immediately before restore and fail closed on any mismatch. Add a regression that replaces the aside entry and releases a target squatting lock between promotion failure and restore.

  • [P1] Check unresolved recovery state before returning “already up to date”
    internal/update/apply.go:67
    The fail-closed checks for marked/relocated recovery copies and a missing target are embedded in stagedBinary.promote, so they are reached only after a new release has been found and staged. When a failed promotion was already attempting the current latest release, the next zero upgrade (which defaults to apply) takes this early success return instead. It tells the operator “already up to date” while a .keep/.recovery state can remain and targetPath may still hold unverified bytes—the opposite of the recovery contract documented by this PR. The root cause is coupling recovery-state validation to the replacement operation rather than treating it as a standalone-install precondition. Extract a Windows recovery preflight and run it before the no-update return for standalone installs, under the same target lock used by promotion; retain the in-promotion check to close the race. Add an Apply-level regression for an already-current release with each unresolved recovery-state form.

  • [P3] Retain cleanup provenance when an old recovery copy is temporarily locked
    internal/update/replace_windows.go:322
    The design intentionally deletes only an identity-recorded recovery object, but prepareRecoveryCleanup silently drops the previous record when that object cannot be opened for deletion (for example, during a transient AV or user lock), and cleanupSupersededRecoveryCopies also ignores a failed delete. A later successful promotion nevertheless atomically replaces the single per-target record with the new aside, permanently losing trusted provenance for the old updater-owned binary once the temporary lock clears. Repeated transient locks therefore leak full recovery binaries indefinitely. The root cause is representing a potentially multi-item cleanup backlog with one record and advancing it without a successful deletion acknowledgement. Preserve pending identity records until their objects are actually deleted (or make the record an atomic queue), and test a blocked old copy, a succeeding next promotion, and a later cleanup retry.

  • [P2] Scope the recovery documentation to Windows
    docs/UPDATE.md:46
    The heading and text present .zero-update-*.old, .keep, and .recovery behavior as a property of standalone installs. It is Windows-only: stage_other.go promotes with Renameat directly over the target and never creates an aside copy, marker, or per-user recovery record. Linux and macOS users are therefore told to inspect and restore paths that cannot be produced. The root cause is documenting the shared installBinary abstraction rather than the platform-specific promotion implementations. Mark this recovery workflow explicitly Windows-only, and either describe POSIX's replace semantics separately or omit recovery guidance for platforms that do not expose it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(windows): predictable updater staging path can overwrite another file

6 participants