mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
* 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]>
166 lines
4.0 KiB
Go
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
|
|
}
|