Files
zero/internal/update/apply.go
PierrunoYTandCopilot Autofix powered by AI 008bc9b3f3 fix: address bugs found in a multi-agent codebase audit (#481)
* fix: address bugs found in a multi-agent codebase audit

Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:

- sandbox: grant_scope path comparisons were case-sensitive, letting a
  persisted deny grant be bypassed by spelling a Windows path with
  different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
  handling was only applied to the oauth token store, not the shared
  credstore backend; both also masked the real lock-contention error
  behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
  fallback provider, forcing unnecessary re-onboarding for an
  already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
  which could split a multi-byte UTF-8 rune and embed invalid UTF-8
  into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
  remapped chord could silently make a hardcoded shortcut (or another
  configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
  and could block harmless commands whose quoted arguments merely
  contained operator-like text
- update: checksum verification didn't cross-check the archive
  filename it verified against the one being extracted; extractZip
  didn't reject symlink-mode entries like extractTarGz already did;
  replaceBinary's restore-on-failure rename had no retry, risking a
  permanently missing binary on a transient Windows file lock

* fix: close remaining PR 481 review findings

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix(tools): drop stale ; test case after rebasing onto main's MSYS check

Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 18:48:28 +08:00

287 lines
8.9 KiB
Go

package update
import (
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Gitlawb/zero/internal/release"
)
// DefaultDownloadTimeout bounds the archive/checksum download phase of a
// standalone Apply, separately from Options.Timeout (which only covers the
// small release-metadata check), so a stalled connection can't hang forever.
const DefaultDownloadTimeout = 5 * time.Minute
// ApplyResult reports the outcome of Apply.
type ApplyResult struct {
Result
Applied bool `json:"applied"`
InstallMethod InstallMethod `json:"installMethod,omitempty"`
BinaryPath string `json:"binaryPath,omitempty"`
Message string `json:"message,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// windowsOptionalBinaries/linuxOptionalBinaries mirror the helper binary
// names scripts/postinstall.mjs copies alongside the main binary when
// present, so `zero upgrade` refreshes them too instead of leaving them stale.
var (
windowsOptionalBinaries = []string{"zero-windows-command-runner.exe", "zero-windows-sandbox-setup.exe"}
linuxOptionalBinaries = []string{"zero-linux-sandbox", "zero-seccomp"}
)
// Apply checks for an update and, if one is available, installs it: via
// `npm install -g` for npm-managed installs, or by downloading, verifying,
// and atomically replacing the binary for standalone installs.
func Apply(ctx context.Context, options Options) (ApplyResult, error) {
checkResult, err := Check(ctx, options)
if err != nil {
return ApplyResult{}, err
}
executablePath, err := os.Executable()
if err != nil {
return ApplyResult{}, fmt.Errorf("resolve current executable: %w", err)
}
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
// Best-effort: remove a "<binary>.old" left behind by a previous Windows
// replaceBinary call now that enough time (a whole separate invocation)
// has passed for the old process to have released the file. Runs
// regardless of whether an update is available, so it isn't stuck waiting
// on a future upgrade that may never come.
CleanupStaleBinary(executablePath)
if !checkResult.UpdateAvailable {
return ApplyResult{Result: checkResult, Message: "already up to date"}, nil
}
method := DetectInstallMethod(executablePath)
switch method {
case InstallMethodNpm:
if err := applyNpmUpdate(ctx); err != nil {
return ApplyResult{}, err
}
return ApplyResult{
Result: checkResult,
Applied: true,
InstallMethod: method,
BinaryPath: executablePath,
Message: fmt.Sprintf("updated via npm to %s", checkResult.LatestVersion),
}, nil
default:
warnings, err := applyStandaloneUpdate(ctx, checkResult, executablePath)
if err != nil {
return ApplyResult{}, err
}
return ApplyResult{
Result: checkResult,
Applied: true,
InstallMethod: method,
BinaryPath: executablePath,
Message: fmt.Sprintf("updated to %s", checkResult.LatestVersion),
Warnings: warnings,
}, nil
}
}
// FormatApply renders an ApplyResult as human-readable text.
func FormatApply(result ApplyResult) string {
if !result.Applied {
return Format(result.Result)
}
lines := []string{
fmt.Sprintf("[zero] %s (%s -> %s)", result.Message, result.CurrentVersion, result.LatestVersion),
"Binary: " + result.BinaryPath,
}
for _, warning := range result.Warnings {
lines = append(lines, "Warning: "+warning)
}
return strings.Join(lines, "\n")
}
func applyNpmUpdate(ctx context.Context) error {
npmPath, err := exec.LookPath("npm")
if err != nil {
return fmt.Errorf("npm not found on PATH: reinstall with `npm install -g %s@latest`", npmPackageName)
}
command := exec.CommandContext(ctx, npmPath, "install", "-g", npmPackageName+"@latest")
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("npm install -g %s@latest: %w", npmPackageName, err)
}
return nil
}
func applyStandaloneUpdate(ctx context.Context, result Result, executablePath string) ([]string, error) {
asset := result.ReleaseAsset
if !asset.Verified {
return nil, fmt.Errorf("release asset for %s-%s could not be verified", asset.Platform, asset.Arch)
}
tempDir, err := os.MkdirTemp("", "zero-update-*")
if err != nil {
return nil, err
}
defer func() {
_ = os.RemoveAll(tempDir)
}()
downloadCtx, cancel := context.WithTimeout(ctx, DefaultDownloadTimeout)
defer cancel()
archivePath := filepath.Join(tempDir, asset.ArchiveName)
if err := downloadFile(downloadCtx, asset.ArchiveURL, archivePath); err != nil {
return nil, fmt.Errorf("download release archive: %w", err)
}
checksumPath := filepath.Join(tempDir, asset.ChecksumName)
if err := downloadFile(downloadCtx, asset.ChecksumURL, checksumPath); err != nil {
return nil, fmt.Errorf("download release checksum: %w", err)
}
if err := verifyArchiveChecksum(checksumPath, asset.ArchiveName); err != nil {
return nil, err
}
extractDir := filepath.Join(tempDir, "extracted")
if err := os.MkdirAll(extractDir, 0o755); err != nil {
return nil, err
}
if err := extractArchive(archivePath, extractDir); err != nil {
return nil, fmt.Errorf("extract release archive: %w", err)
}
binaryName := "zero"
optionalBinaries := linuxOptionalBinaries
if runtime.GOOS == "windows" {
binaryName = "zero.exe"
optionalBinaries = windowsOptionalBinaries
} else if runtime.GOOS == "darwin" {
optionalBinaries = nil
}
newBinaryPath, err := findByBasename(extractDir, binaryName)
if err != nil {
return nil, err
}
if newBinaryPath == "" {
return nil, fmt.Errorf("release archive did not contain %s", binaryName)
}
targetDir := filepath.Dir(executablePath)
if err := installBinary(newBinaryPath, executablePath); err != nil {
return nil, err
}
var warnings []string
for _, name := range optionalBinaries {
source, err := findByBasename(extractDir, name)
if err != nil || source == "" {
continue // optional: the sandbox degrades gracefully without it
}
destPath := filepath.Join(targetDir, name)
if _, err := os.Stat(destPath); err != nil {
continue // only refresh helpers this install already has
}
if err := installBinary(source, destPath); err != nil {
warnings = append(warnings, fmt.Sprintf("failed to update helper %s: %v", name, err))
}
}
return warnings, nil
}
// verifyArchiveChecksum verifies the checksum file at checksumPath and
// cross-checks it names expectedArchiveName. VerifySHA256Checksum hashes
// whichever file the checksum text names, not necessarily the archive we
// downloaded — this mirrors the cross-check VerifyReleaseChecksums already
// does, so a checksum file that names a different file can't be used to
// verify (and thereby vouch for) the wrong bytes before extraction.
func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) error {
verified, err := release.VerifySHA256Checksum(checksumPath)
if err != nil {
return fmt.Errorf("verify release checksum: %w", err)
}
if verified.ArchiveName != expectedArchiveName {
return fmt.Errorf("checksum file references %s, expected %s", verified.ArchiveName, expectedArchiveName)
}
return nil
}
// installBinary stages sourcePath next to targetPath (same directory, so the
// final rename is atomic/same-filesystem) and then swaps it into place.
func installBinary(sourcePath string, targetPath string) error {
stagedPath := targetPath + ".new"
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)
}
return nil
}
func copyFile(sourcePath string, destPath string) (retErr error) {
source, err := os.Open(sourcePath)
if err != nil {
return err
}
defer func() {
_ = source.Close()
}()
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer func() {
if closeErr := dest.Close(); closeErr != nil && retErr == nil {
retErr = closeErr
}
}()
_, retErr = io.Copy(dest, source)
return retErr
}
func downloadFile(ctx context.Context, url string, destPath string) error {
if url == "" {
return fmt.Errorf("missing download URL")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero/update")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("download failed (%s): %s", response.Status, url)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
_, copyErr := io.Copy(out, response.Body)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}