Files
zero/internal/update/extract.go
PierrunoYTandVasanthdev2004 5f36349c18 feat(update): add zero upgrade command to apply self-updates (#461)
* feat(update): add zero upgrade command to apply self-updates

Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:

- `zero update --apply` / `zero upgrade` download, sha256-verify, and
  install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
  checksum, extract it (zip-slip guarded), and atomically replace the
  running binary plus any pre-existing optional sandbox helpers. On
  Windows, the running exe is renamed aside (NTFS allows this even
  while it's executing) since it can't be overwritten directly; the
  leftover ".old" file is cleaned up on a later invocation.

Closes #459.

* fix(update): correct macOS expectations in apply test

The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.

* fix(update): address CodeRabbit review findings on zero upgrade

- Reject --target when combined with --apply/upgrade: applying installs
  onto the current machine, so a mismatched --target could replace the
  running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
  from the release-metadata check timeout, so a stalled connection
  can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
  of swallowing it, so a botched install is reported rather than
  silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
  so a leftover Windows ".old" file isn't stuck waiting for a future
  upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
  ApplyResult (surfaced in text and JSON output) instead of discarding
  them silently.

* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI

TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.

Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-04 08:52:07 +08:00

166 lines
4.0 KiB
Go

package update
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
// extractArchive extracts a .tar.gz or .zip release archive into destDir.
func extractArchive(archivePath string, destDir string) error {
if strings.HasSuffix(archivePath, ".zip") {
return extractZip(archivePath, destDir)
}
return extractTarGz(archivePath, destDir)
}
func extractTarGz(archivePath string, destDir string) error {
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer func() {
_ = file.Close()
}()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer func() {
_ = gzipReader.Close()
}()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := safeExtractPath(destDir, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := writeExtractedFile(target, tarReader, fs.FileMode(header.Mode)); err != nil {
return err
}
default:
// Release archives only ever contain regular files and directories;
// reject anything else (symlinks, devices) rather than silently skip it.
return fmt.Errorf("unsupported archive entry type for %s", header.Name)
}
}
}
func extractZip(archivePath string, destDir string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer func() {
_ = reader.Close()
}()
for _, entry := range reader.File {
target, err := safeExtractPath(destDir, entry.Name)
if err != nil {
return err
}
if entry.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := func() error {
entryReader, err := entry.Open()
if err != nil {
return err
}
defer func() {
_ = entryReader.Close()
}()
return writeExtractedFile(target, entryReader, entry.Mode())
}(); err != nil {
return err
}
}
return nil
}
func writeExtractedFile(target string, source io.Reader, mode fs.FileMode) error {
if mode == 0 {
mode = 0o644
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
_, copyErr := io.Copy(out, source)
closeErr := out.Close()
if copyErr != nil {
return copyErr
}
return closeErr
}
// safeExtractPath resolves an archive entry name against destDir, rejecting
// absolute paths or entries that would escape destDir via "..".
func safeExtractPath(destDir string, name string) (string, error) {
cleanName := filepath.Clean(strings.ReplaceAll(name, "\\", "/"))
if cleanName == "." {
return destDir, nil
}
if filepath.IsAbs(cleanName) || cleanName == ".." || strings.HasPrefix(cleanName, "../") {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
target := filepath.Join(destDir, cleanName)
destDirClean := filepath.Clean(destDir)
if target != destDirClean && !strings.HasPrefix(target, destDirClean+string(os.PathSeparator)) {
return "", fmt.Errorf("archive entry escapes destination: %s", name)
}
return target, nil
}
// findByBasename recursively searches root for the first regular file whose
// basename matches name, mirroring scripts/postinstall.mjs's lookup so
// helper binaries nested under archive subdirectories (e.g. helpers/) are
// still found.
func findByBasename(root string, name string) (string, error) {
var found string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if found != "" {
return fs.SkipAll
}
if !entry.IsDir() && entry.Name() == name {
found = path
}
return nil
})
if err != nil {
return "", err
}
return found, nil
}