mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* security(update): stage binary replacement at an unpredictable, exclusive path Fixes #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 <[email protected]> * security(update): bind the binary swap to the staged object, not its pathname Addresses the two open findings on #751. [P1] The staged file is no longer promoted by pathname. Randomizing the name and creating it exclusively stops PRE-creation, but under #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. * security(update): close remaining updater staging races Address jatmn's review findings on PR #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 <[email protected]> * fix(update): address staging review findings * fix(update): verify promoted object identity safely Bind POSIX staging creation and both rename endpoints to directory descriptors, clean only identity-matched empty directories, and verify Windows promotion by file identity rather than path spelling. Preserve unverifiable crash leftovers and add adversarial cleanup, hard-link, and reparse-ancestor coverage. Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-27da-72ec-8e6d-5d43f127b6a1 Co-authored-by: Pierre Bruno <[email protected]> * fix(update): preserve promotion recovery signals Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno <[email protected]> * fix(update): keep the recovery copy a failed restore promised Two error-path follow-ups from the #742 staging review. When a promotion fails and restoring the original also fails, the error tells the operator their original is preserved at <binary>.old — and then the next Apply deleted it, because a present target read as proof the copy was stale. What occupies the target in exactly that case is the bytes the updater could not verify, so cleanup was erasing the one it could. A failed restore now marks the copy, cleanup honors the mark, and a successful promotion clears it. The POSIX staging flow could also leave an empty .zero-stage-* directory behind when the stat immediately after mkdirat failed. Nothing knows that name yet, so it would have stayed for good; it is now removed best-effort with rmdir semantics, which cannot touch anything but the empty directory just created. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(update): stop the recovery copy from being destroyed by the next attempt The marker added in d9523ba kept cleanup from deleting <target>.old, but four paths still undermined the promise it was meant to make. Promotion destroyed the copy it was preserving. Skipping the pre-rename cleanup was not enough: os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming the running binary aside overwrote the last verified copy with the unverified bytes the earlier failure left at the target — and a promotion that then failed could only move those unverified bytes back. Promotion now refuses while the tamper state is unresolved. Only the operator can say whether the file at the target is theirs, so the error names both moves that end it: restore the copy, or delete the marker to accept what is installed. The marker itself was a predictable link-following truncate write. Its path is fixed, so a writer in the install directory could pre-create it as a hard link or reparse point and have the elevated updater write through it. It is now created with the same CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT + fresh-regular- file check as a staging file, and never truncates; an existing name is treated as already-marked rather than an object to open. A marker that could not be written left the promise unqualified while the next run's cleanup deleted exactly the file it named. That failure now rides in the error, telling the operator to copy it somewhere safe now. Helper refreshes downgraded tampering to a warning. An ordinary helper failure still warns — a stale helper is better than a failed update — but a helper whose path may hold unverified content fails the apply, because helpers are resolved from the install directory and executed by the sandbox runner, and reporting Applied: true would hand the operator a success while a sibling executable is suspect. ErrTargetPossiblyTampered moved to apply.go so callers on every platform can test for it without build tags. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(update): keep the recovery copy when its marker cannot be established Surfacing a failed marker write told the operator to hurry; it did not stop the next run from deleting the file they were being told to save. When no marker can be established, the copy is now moved to an unpredictable sibling name that routine cleanup never touches — CleanupStaleBinary only ever removes the exact "<target>.old" — and the error names where it went. Telling the operator to act now remains the fallback for when even that move fails. The marker check is conservative in the same direction: only a definite "not there" allows the copy to be deleted. An Lstat that fails for any other reason leaves the question open, and deleting is irreversible while keeping costs one stale file. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> * fix(update): preserve Windows recovery state across retries Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef Co-authored-by: Amp <[email protected]> * fix(update): watch every recovery path and never orphan a partial marker 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. * fix(update): tolerate the fail-closed outcome of a fully-unlinked substitution 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. * fix(update): surface unverified recovery path, match .old case-insensitively 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 <[email protected]> * fix(update): bound Windows recovery cleanup Amp-Thread-ID: https://ampcode.com/threads/T-019fbdec-f8dc-71f9-abdd-ea044a902b9a Co-authored-by: Pierre Bruno <[email protected]> * fix(update): secure Windows recovery cleanup Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <[email protected]> * fix(update): terminate Windows rename paths Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <[email protected]> * test(update): allow namespaced recovery copies Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <[email protected]> * fix(update): bind Windows cleanup to handles, drop dead cleanup API 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) <[email protected]> * fix(update): bind Windows recovery to handles and bound its state Addresses the 2026-08-02 review round on #751. Keep the recovery restore bound to the object promote moved aside instead of its pathname: promote now opens the target with delete access and no delete sharing before renaming it aside, and restoreOriginalBinary renames that same handle back (retrying through the handle, never re-resolving the source name), so a directory writer cannot swap the aside entry during the promotion gap and have the substitute installed. Run the recovery-state checks as a standalone-install precondition, under the same lock promotion uses, so "already up to date" can no longer report success while a .keep/.recovery state is unresolved. The in-promotion check stays to close the race. Make the cleanup record a queue: entries are only retired once their object is actually gone, so a copy held open by a scanner is retried by a later update rather than leaking, while records for paths that vanished or that this updater could never have created are retired instead of accumulating. Also from review of the above: - delete the pathname restore chain that lost its production callers (restoreOriginalBinary's pathname form, openIdentityFile, recordRecoveryCleanup) and point their tests at the live handle path - name the relocation path in the error when the post-move verification fails, since oldPath is already vacated by then - scope the recovery docs to Windows and describe POSIX replace semantics Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --------- Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Amp <[email protected]>
530 lines
20 KiB
Go
530 lines
20 KiB
Go
//go:build windows
|
|
|
|
package update
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
const (
|
|
restoreRenameRetryAttempts = 10
|
|
restoreRenameRetryDelay = 100 * time.Millisecond
|
|
)
|
|
|
|
// renameOpenFileWithRetry renames the object file refers to onto newPath,
|
|
// retrying while something transient (an on-access scanner, a stale handle from
|
|
// the previous process image) still occupies the destination. The retry is on
|
|
// the handle-bound rename rather than a pathname rename: the destination is in a
|
|
// directory the threat principal can write, so re-resolving the SOURCE pathname
|
|
// between attempts would let a substitute be restored instead.
|
|
func renameOpenFileWithRetry(file *os.File, newPath string) error {
|
|
var lastErr error
|
|
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
|
|
if err := renameRecoveryFileByHandle(file, newPath); err == nil {
|
|
return nil
|
|
} else {
|
|
lastErr = err
|
|
}
|
|
time.Sleep(restoreRenameRetryDelay)
|
|
}
|
|
return lastErr
|
|
}
|
|
|
|
// This file is where ErrTargetPossiblyTampered (declared in apply.go, so
|
|
// cross-platform callers can test for it) is produced. It is wrapped into the
|
|
// error promote returns when a promotion attempt fails AND restoring the
|
|
// original binary to targetPath also fails. That combination means a principal
|
|
// who can write in the installation directory occupied targetPath in the gap
|
|
// the updater opened by renaming the running binary aside, and Windows would
|
|
// not let anything — including the restore — replace it:
|
|
// MOVEFILE_REPLACE_EXISTING cannot force past another handle's share-mode lock.
|
|
// This is not an ordinary failed update (the previous version simply staying in
|
|
// place); the executable path may now hold attacker-controlled bytes, so a
|
|
// caller must surface it as a security-relevant condition, not the same "try
|
|
// again later" failure as a stalled download.
|
|
//
|
|
// It is also returned by promote when a PREVIOUS run left that state behind and
|
|
// nobody has resolved it yet — see the refusal there.
|
|
|
|
// restoreOriginalBinary moves the object file refers to — the binary promote
|
|
// moved aside from targetPath, held open since before that rename — back onto
|
|
// targetPath after a failed promotion. Everything here is bound to that handle:
|
|
// oldPath is only ever used to NAME the object for the operator and to site the
|
|
// recovery marker, never re-resolved to decide what gets restored. A failed
|
|
// immediate restore is surfaced; the aside copy must not be queued as a reboot
|
|
// source because its pathname can be replaced before reboot under the
|
|
// writable-directory threat model.
|
|
func restoreOriginalBinary(file *os.File, oldPath string, targetPath string) error {
|
|
err := renameOpenFileWithRetry(file, targetPath)
|
|
if err == nil {
|
|
err = verifyPromotedTarget(file, targetPath)
|
|
}
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
// The error this produces tells the operator their original is preserved at
|
|
// oldPath. Record that so a later promotion refuses to treat the recovery
|
|
// copy as the ordinary destination for another aside rename.
|
|
if markErr := markOldBinaryPreserved(oldPath); markErr != nil {
|
|
// The marker could not be established, so nothing on disk identifies
|
|
// oldPath as the recovery copy. Move it to a distinct name and report that
|
|
// authoritative location to the operator.
|
|
if kept, keepErr := keepUnmarkedRecoveryCopy(file, oldPath); keepErr == nil {
|
|
return fmt.Errorf(
|
|
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to the distinct recovery path %s)",
|
|
ErrTargetPossiblyTampered, err, markErr, kept,
|
|
)
|
|
} else if kept != "" {
|
|
// The move succeeded but the post-move verification did not, so
|
|
// oldPath is already vacated — point at kept, the path the
|
|
// bytes actually landed at, not the path that no longer holds them.
|
|
return fmt.Errorf(
|
|
"%w: %v (the recovery marker could not be written: %v; the last binary this updater verified was moved to %s but could not be verified there: %v)",
|
|
ErrTargetPossiblyTampered, err, markErr, kept, keepErr,
|
|
)
|
|
} else {
|
|
return fmt.Errorf(
|
|
"%w: %v (the recovery marker could not be written: %v — manually copy %s somewhere safe now)",
|
|
ErrTargetPossiblyTampered, err, markErr, oldPath,
|
|
)
|
|
}
|
|
}
|
|
return fmt.Errorf("%w: %v", ErrTargetPossiblyTampered, err)
|
|
}
|
|
|
|
// oldBinaryPreservedSuffix names the marker written beside a "<path>.old" that
|
|
// a failed restore left as the last known-good binary.
|
|
const oldBinaryPreservedSuffix = ".keep"
|
|
|
|
// markOldBinaryPreserved records that oldPath must survive routine cleanup.
|
|
//
|
|
// The marker is created with the same exclusive, no-follow semantics as a
|
|
// staging file, and never truncates. Its pathname is predictable, so under the
|
|
// writable-install-directory threat model a lower-privileged writer can
|
|
// pre-create it as a hard link or reparse point; opening that with O_TRUNC
|
|
// would let the elevated updater write through it into a file of the attacker's
|
|
// choosing. CREATE_NEW plus FILE_FLAG_OPEN_REPARSE_POINT plus the same
|
|
// fresh-regular-file check refuses that object instead of writing to it.
|
|
//
|
|
// An existing marker is success, not a rewrite: its contents carry no state
|
|
// beyond "this .old is the last verified binary", so there is nothing to update
|
|
// and nothing worth opening a pre-existing object for. That also means an entry
|
|
// planted at the name by someone else reads as marked, which is the fail-safe
|
|
// direction: it makes the next run PRESERVE the recovery copy.
|
|
//
|
|
// A var so a test can force the failure branch. Every way to make the real
|
|
// CreateFile fail here (an unwritable directory, an over-length name) either
|
|
// takes the recovery copy down with it or is too platform-fragile to assert on.
|
|
var markOldBinaryPreserved = func(oldPath string) error {
|
|
markerPath := oldPath + oldBinaryPreservedSuffix
|
|
if _, err := os.Lstat(markerPath); err == nil {
|
|
return nil
|
|
}
|
|
pathPtr, err := windows.UTF16PtrFromString(markerPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
handle, err := windows.CreateFile(
|
|
pathPtr,
|
|
windows.GENERIC_WRITE|windows.DELETE,
|
|
0,
|
|
nil,
|
|
windows.CREATE_NEW,
|
|
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
|
0,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, windows.ERROR_FILE_EXISTS) || errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
|
|
// Something already occupies the marker name. Whatever it is, this
|
|
// function's only job is to make the next run preserve oldPath, and a
|
|
// present name does that — without this process writing through an
|
|
// object it did not create.
|
|
return nil
|
|
}
|
|
return fmt.Errorf("create recovery marker %s: %w", markerPath, err)
|
|
}
|
|
marker := os.NewFile(uintptr(handle), markerPath)
|
|
if err := verifyFreshRegularFile(handle, markerPath); err != nil {
|
|
_ = deleteFileByHandle(marker)
|
|
_ = marker.Close()
|
|
return err
|
|
}
|
|
writeErr := writeRecoveryMarker(marker)
|
|
// A partial marker must not be left beside a recovery copy that the caller
|
|
// then relocates. Delete the object this process created through its own
|
|
// handle — never by pathname, which the threat principal can point at
|
|
// something else once the handle is released.
|
|
var deleteErr error
|
|
if writeErr != nil {
|
|
deleteErr = deleteFileByHandle(marker)
|
|
}
|
|
closeErr := marker.Close()
|
|
if writeErr == nil {
|
|
// A close failure still leaves the fully written marker in place, and
|
|
// its presence is the entire state this function records.
|
|
_ = closeErr
|
|
return nil
|
|
}
|
|
// Below, the write failed. Each remaining branch decides between reporting
|
|
// that failure and conservatively claiming success — and only a confirmed
|
|
// absence of the marker earns the report, because claiming success keeps
|
|
// oldPath as the authoritative recovery location, which is the safe answer.
|
|
if deleteErr != nil {
|
|
// The partial marker could not be removed, so it is still there.
|
|
return nil
|
|
}
|
|
if _, statErr := os.Lstat(markerPath); statErr == nil || !errors.Is(statErr, os.ErrNotExist) {
|
|
// Something occupies the marker name, or its state cannot be established.
|
|
return nil
|
|
}
|
|
return fmt.Errorf("write recovery marker %s: %w", markerPath, writeErr)
|
|
}
|
|
|
|
var writeRecoveryMarker = func(marker *os.File) error {
|
|
_, err := marker.WriteString("The update at this path failed and could not restore the original binary.\n" +
|
|
"The file without this marker's .keep suffix is the last binary this updater verified.\n")
|
|
return err
|
|
}
|
|
|
|
// keepUnmarkedRecoveryCopy moves oldPath out from under routine cleanup when its
|
|
// marker could not be established, returning the path it now lives at.
|
|
//
|
|
// The name is unpredictable for the same reason the staging name is: this runs
|
|
// in a directory a lower-privileged principal may be able to write, and a fixed
|
|
// recovery name could be pre-created there to make this rename fail or land
|
|
// somewhere chosen by someone else. Being unpredictable also means being opaque,
|
|
// which is why the caller's error names the path.
|
|
func keepUnmarkedRecoveryCopy(file *os.File, oldPath string) (string, error) {
|
|
suffix, err := randomStagingSuffix()
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: choose recovery path: %v", ErrTargetPossiblyTampered, err)
|
|
}
|
|
kept := filepath.Join(filepath.Dir(oldPath), filepath.Base(oldPath)+"."+suffix+".recovery")
|
|
if _, statErr := os.Lstat(kept); statErr == nil {
|
|
return "", fmt.Errorf("%w: recovery path %s already exists", ErrTargetPossiblyTampered, kept)
|
|
} else if !errors.Is(statErr, os.ErrNotExist) {
|
|
return "", fmt.Errorf("%w: inspect recovery path %s: %v", ErrTargetPossiblyTampered, kept, statErr)
|
|
}
|
|
if err := renameRecoveryFileByHandle(file, kept); err != nil {
|
|
return "", fmt.Errorf("%w: move verified recovery copy to %s: %v", ErrTargetPossiblyTampered, kept, err)
|
|
}
|
|
if err := verifyPromotedTarget(file, kept); err != nil {
|
|
// The rename already reported success, so oldPath is no longer a
|
|
// reliable location for the caller to fall back to — surface kept
|
|
// anyway so the operator is pointed at the actual (if unverified)
|
|
// destination instead of a path the move already vacated.
|
|
return kept, fmt.Errorf("%w: verify recovery copy at %s: %v", ErrTargetPossiblyTampered, kept, err)
|
|
}
|
|
return kept, nil
|
|
}
|
|
|
|
func openRecoveryCopy(path string) (*os.File, error) {
|
|
pathPtr, err := windows.UTF16PtrFromString(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
handle, err := windows.CreateFile(
|
|
pathPtr,
|
|
windows.DELETE,
|
|
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
|
nil,
|
|
windows.OPEN_EXISTING,
|
|
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT,
|
|
0,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := verifyFreshRegularFile(handle, path); err != nil {
|
|
_ = windows.CloseHandle(handle)
|
|
return nil, err
|
|
}
|
|
return os.NewFile(uintptr(handle), path), nil
|
|
}
|
|
|
|
var renameRecoveryFileByHandle = renameOpenFile
|
|
|
|
// clearOldBinaryPreserved models the operator accepting the installed binary
|
|
// by deleting the recovery marker. The recovery copy itself remains preserved.
|
|
func clearOldBinaryPreserved(oldPath string) {
|
|
_ = os.Remove(oldPath + oldBinaryPreservedSuffix)
|
|
}
|
|
|
|
// oldBinaryPreserved reports whether a failed restore marked oldPath as the last
|
|
// known-good binary.
|
|
//
|
|
// Only a definite "the marker is not there" answers false. An Lstat that fails
|
|
// for any other reason (permissions, a transient sharing error) leaves the
|
|
// question open, and the conservative answer to an open question here is to keep
|
|
// the copy: deleting it is irreversible, while keeping it costs one stale file.
|
|
func oldBinaryPreserved(oldPath string) bool {
|
|
_, err := os.Lstat(oldPath + oldBinaryPreservedSuffix)
|
|
return err == nil || !errors.Is(err, os.ErrNotExist)
|
|
}
|
|
|
|
type recoveryCleanupRecord struct {
|
|
Path string `json:"path"`
|
|
VolumeSerial uint32 `json:"volumeSerial"`
|
|
FileIndexHigh uint32 `json:"fileIndexHigh"`
|
|
FileIndexLow uint32 `json:"fileIndexLow"`
|
|
}
|
|
|
|
type recoveryCleanupQueue struct {
|
|
Records []recoveryCleanupRecord `json:"records"`
|
|
}
|
|
|
|
var recoveryCleanupStateDir = func() (string, error) {
|
|
root, err := config.UserConfigDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(root, "zero", "update-recovery"), nil
|
|
}
|
|
|
|
func recoveryCleanupRecordPath(targetPath string) (string, error) {
|
|
dir, err := recoveryCleanupStateDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
absolute, err := filepath.Abs(targetPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest := sha256.Sum256([]byte(strings.ToLower(filepath.Clean(absolute))))
|
|
return filepath.Join(dir, fmt.Sprintf("%x.json", digest)), nil
|
|
}
|
|
|
|
func validUpdaterRecoveryPath(targetPath string, recoveryPath string) bool {
|
|
if !strings.EqualFold(filepath.Clean(filepath.Dir(targetPath)), filepath.Clean(filepath.Dir(recoveryPath))) {
|
|
return false
|
|
}
|
|
name := strings.ToLower(filepath.Base(recoveryPath))
|
|
prefix := strings.ToLower(filepath.Base(targetPath)) + ".zero-update-"
|
|
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".old") {
|
|
return false
|
|
}
|
|
suffix := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".old")
|
|
if len(suffix) != 32 {
|
|
return false
|
|
}
|
|
for _, character := range suffix {
|
|
if !strings.ContainsRune("0123456789abcdef", character) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// prepareRecoveryCleanup opens only the exact object vouched for by trusted
|
|
// per-user state from the previous successful promotion. Recovery discovery is
|
|
// intentionally broad and filename-based so suspicious state fails closed, but
|
|
// destructive cleanup never treats an install-directory name as provenance.
|
|
type recoveryCleanupCandidate struct {
|
|
file *os.File
|
|
record recoveryCleanupRecord
|
|
}
|
|
|
|
func loadRecoveryCleanupQueue(targetPath string) recoveryCleanupQueue {
|
|
recordPath, err := recoveryCleanupRecordPath(targetPath)
|
|
if err != nil {
|
|
return recoveryCleanupQueue{}
|
|
}
|
|
data, err := os.ReadFile(recordPath)
|
|
if err != nil {
|
|
return recoveryCleanupQueue{}
|
|
}
|
|
var queue recoveryCleanupQueue
|
|
if json.Unmarshal(data, &queue) != nil || queue.Records == nil {
|
|
var legacy recoveryCleanupRecord
|
|
if json.Unmarshal(data, &legacy) == nil && legacy.Path != "" {
|
|
queue.Records = []recoveryCleanupRecord{legacy}
|
|
}
|
|
}
|
|
return queue
|
|
}
|
|
|
|
func prepareRecoveryCleanup(targetPath string) []recoveryCleanupCandidate {
|
|
queue := loadRecoveryCleanupQueue(targetPath)
|
|
var candidates []recoveryCleanupCandidate
|
|
// Records outlive a single attempt so a temporarily locked copy is still
|
|
// deleted on a later run, but a record that can never become actionable has
|
|
// to be retired here or the backlog grows without bound.
|
|
retained := queue.Records[:0]
|
|
for _, record := range queue.Records {
|
|
// Not a name this updater could have produced: unusable as provenance
|
|
// no matter what appears at it later.
|
|
if !validUpdaterRecoveryPath(targetPath, record.Path) {
|
|
continue
|
|
}
|
|
// Definitively gone — an operator removed it, or a delete this updater
|
|
// requested completed once the last handle closed.
|
|
if _, err := os.Lstat(record.Path); errors.Is(err, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
retained = append(retained, record)
|
|
if oldBinaryPreserved(record.Path) {
|
|
continue
|
|
}
|
|
file, err := openRecoveryCopy(record.Path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
identity, err := recoveryFileIdentity(file)
|
|
if err != nil || identity.VolumeSerial != record.VolumeSerial ||
|
|
identity.FileIndexHigh != record.FileIndexHigh || identity.FileIndexLow != record.FileIndexLow {
|
|
_ = file.Close()
|
|
continue
|
|
}
|
|
candidates = append(candidates, recoveryCleanupCandidate{file: file, record: record})
|
|
}
|
|
if len(retained) != len(queue.Records) {
|
|
queue.Records = retained
|
|
_ = writeRecoveryCleanupQueue(targetPath, queue)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
type recoveryIdentity struct {
|
|
VolumeSerial uint32
|
|
FileIndexHigh uint32
|
|
FileIndexLow uint32
|
|
}
|
|
|
|
func recoveryFileIdentity(file *os.File) (recoveryIdentity, error) {
|
|
var info windows.ByHandleFileInformation
|
|
if err := windows.GetFileInformationByHandle(windows.Handle(file.Fd()), &info); err != nil {
|
|
return recoveryIdentity{}, err
|
|
}
|
|
return recoveryIdentity{
|
|
VolumeSerial: info.VolumeSerialNumber,
|
|
FileIndexHigh: info.FileIndexHigh,
|
|
FileIndexLow: info.FileIndexLow,
|
|
}, nil
|
|
}
|
|
|
|
// appendRecoveryCleanupRecord adds the copy this promotion moved aside to the
|
|
// per-user cleanup backlog. The identity comes from the handle the caller held
|
|
// across the aside rename, so a pathname substituted afterwards can never be
|
|
// recorded as updater-owned: the next run reopens recoveryPath and only deletes
|
|
// it if it is still that same object.
|
|
func appendRecoveryCleanupRecord(targetPath string, recoveryPath string, identity recoveryIdentity) error {
|
|
if !validUpdaterRecoveryPath(targetPath, recoveryPath) {
|
|
return fmt.Errorf("invalid updater recovery path %s", recoveryPath)
|
|
}
|
|
record := recoveryCleanupRecord{
|
|
Path: recoveryPath,
|
|
VolumeSerial: identity.VolumeSerial,
|
|
FileIndexHigh: identity.FileIndexHigh,
|
|
FileIndexLow: identity.FileIndexLow,
|
|
}
|
|
queue := loadRecoveryCleanupQueue(targetPath)
|
|
queue.Records = append(queue.Records, record)
|
|
return writeRecoveryCleanupQueue(targetPath, queue)
|
|
}
|
|
|
|
func writeRecoveryCleanupQueue(targetPath string, queue recoveryCleanupQueue) error {
|
|
data, err := json.Marshal(queue)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
recordPath, err := recoveryCleanupRecordPath(targetPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(recordPath)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
temporary, err := os.CreateTemp(dir, filepath.Base(recordPath)+".*.tmp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if _, err := temporary.Write(data); err != nil {
|
|
_ = temporary.Close()
|
|
return err
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(temporaryPath, recordPath)
|
|
}
|
|
|
|
type fileDispositionInfo struct {
|
|
DeleteFile byte
|
|
}
|
|
|
|
// deleteFileByHandle marks the object file refers to for deletion, which takes
|
|
// effect when its last handle closes. It never resolves a pathname, so it can
|
|
// only ever delete the object this process already holds open.
|
|
func deleteFileByHandle(file *os.File) error {
|
|
info := fileDispositionInfo{DeleteFile: 1}
|
|
return windows.SetFileInformationByHandle(
|
|
windows.Handle(file.Fd()),
|
|
windows.FileDispositionInfo,
|
|
(*byte)(unsafe.Pointer(&info)),
|
|
uint32(unsafe.Sizeof(info)),
|
|
)
|
|
}
|
|
|
|
func closeRecoveryCleanupCandidates(candidates []recoveryCleanupCandidate) {
|
|
for index := range candidates {
|
|
if candidates[index].file == nil {
|
|
continue
|
|
}
|
|
_ = candidates[index].file.Close()
|
|
candidates[index].file = nil
|
|
}
|
|
}
|
|
|
|
// cleanupSupersededRecoveryCopies marks the exact pre-promotion objects for
|
|
// deletion only after the replacement has been verified. The handles deny
|
|
// delete sharing, so their entries cannot be substituted in the meantime.
|
|
//
|
|
// A record is only retired once its object is actually gone. A copy another
|
|
// process holds open (an on-access scanner, an operator's editor) stays in the
|
|
// backlog and is retried by a later promotion, so transient locks cannot leak
|
|
// full binaries.
|
|
func cleanupSupersededRecoveryCopies(targetPath string, candidates []recoveryCleanupCandidate) {
|
|
defer closeRecoveryCleanupCandidates(candidates)
|
|
queue := loadRecoveryCleanupQueue(targetPath)
|
|
deleted := make(map[recoveryCleanupRecord]bool)
|
|
for index := range candidates {
|
|
candidate := &candidates[index]
|
|
if err := deleteFileByHandle(candidate.file); err != nil {
|
|
continue
|
|
}
|
|
// FileDispositionInfo removes the entry when the last handle closes, so
|
|
// releasing ours is what makes the deletion observable.
|
|
_ = candidate.file.Close()
|
|
candidate.file = nil
|
|
if _, err := os.Lstat(candidate.record.Path); errors.Is(err, os.ErrNotExist) {
|
|
deleted[candidate.record] = true
|
|
}
|
|
}
|
|
remaining := queue.Records[:0]
|
|
for _, record := range queue.Records {
|
|
if !deleted[record] {
|
|
remaining = append(remaining, record)
|
|
}
|
|
}
|
|
queue.Records = remaining
|
|
_ = writeRecoveryCleanupQueue(targetPath, queue)
|
|
}
|