mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -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]>
303 lines
9.2 KiB
Go
303 lines
9.2 KiB
Go
package update
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Gitlawb/zero/internal/release"
|
|
)
|
|
|
|
func TestApplyReturnsNoopWhenUpToDate(t *testing.T) {
|
|
result, err := Apply(context.Background(), Options{
|
|
CurrentVersion: "0.2.0",
|
|
GOOS: "linux",
|
|
GOARCH: "amd64",
|
|
Fetch: func(_ context.Context, endpoint string) (Release, error) {
|
|
return releaseForTarget(t, "v0.2.0", "linux", "amd64"), nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply returned error: %v", err)
|
|
}
|
|
if result.Applied {
|
|
t.Fatalf("expected Applied=false when already up to date, got %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestApplyStandaloneUpdateReplacesBinary(t *testing.T) {
|
|
binaryName := "zero"
|
|
// macOS ships no optional helper binaries (matching scripts/postinstall.mjs),
|
|
// so there's nothing to refresh there; only linux/windows have one to check.
|
|
optionalName := ""
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
binaryName = "zero.exe"
|
|
optionalName = "zero-windows-command-runner.exe"
|
|
case "linux":
|
|
optionalName = "zero-seccomp"
|
|
}
|
|
|
|
installDir := t.TempDir()
|
|
executablePath := filepath.Join(installDir, binaryName)
|
|
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile executable: %v", err)
|
|
}
|
|
// Only pre-existing optional helpers should be refreshed; an absent one
|
|
// (e.g. the platform's other optional helper) must not be introduced.
|
|
var existingHelperPath string
|
|
if optionalName != "" {
|
|
existingHelperPath = filepath.Join(installDir, optionalName)
|
|
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile helper: %v", err)
|
|
}
|
|
}
|
|
|
|
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
|
|
archiveDir := t.TempDir()
|
|
archivePath := filepath.Join(archiveDir, archiveName)
|
|
writeTestTarGz(t, archivePath, map[string]string{
|
|
"zero": "new-binary",
|
|
"zero.exe": "new-binary-exe",
|
|
"zero-seccomp": "new-helper",
|
|
"zero-windows-command-runner.exe": "new-helper-exe",
|
|
})
|
|
checksum, err := release.SHA256File(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("SHA256File: %v", err)
|
|
}
|
|
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
|
|
if err != nil {
|
|
t.Fatalf("FormatSHA256Checksum: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/" + archiveName:
|
|
http.ServeFile(w, r, archivePath)
|
|
case "/" + archiveName + ".sha256":
|
|
_, _ = w.Write([]byte(checksumText))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
result := Result{
|
|
LatestVersion: "0.2.0",
|
|
ReleaseAsset: AssetCheck{
|
|
Platform: "linux",
|
|
Arch: "x64",
|
|
ArchiveName: archiveName,
|
|
ArchiveURL: server.URL + "/" + archiveName,
|
|
ChecksumName: archiveName + ".sha256",
|
|
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
|
|
ArchiveFound: true,
|
|
ChecksumFound: true,
|
|
Verified: true,
|
|
},
|
|
}
|
|
|
|
if warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath); err != nil {
|
|
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
|
|
} else if len(warnings) != 0 {
|
|
t.Fatalf("unexpected warnings: %v", warnings)
|
|
}
|
|
|
|
data, err := os.ReadFile(executablePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile executable: %v", err)
|
|
}
|
|
wantBinary := "new-binary"
|
|
if runtime.GOOS == "windows" {
|
|
wantBinary = "new-binary-exe"
|
|
}
|
|
if string(data) != wantBinary {
|
|
t.Fatalf("executable content = %q, want %q", data, wantBinary)
|
|
}
|
|
|
|
if optionalName != "" {
|
|
helperData, err := os.ReadFile(existingHelperPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile helper: %v", err)
|
|
}
|
|
wantHelper := "new-helper"
|
|
if runtime.GOOS == "windows" {
|
|
wantHelper = "new-helper-exe"
|
|
}
|
|
if string(helperData) != wantHelper {
|
|
t.Fatalf("helper content = %q, want %q", helperData, wantHelper)
|
|
}
|
|
}
|
|
|
|
if entries, err := os.ReadDir(installDir); err == nil {
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
// On Windows, replaceBinary leaves "<name>.old" behind (the running
|
|
// binary is renamed aside, not deleted) for later best-effort cleanup.
|
|
if name == binaryName || (optionalName != "" && name == optionalName) || name == binaryName+".old" || (optionalName != "" && name == optionalName+".old") {
|
|
continue
|
|
}
|
|
t.Fatalf("unexpected extra file left in install dir: %s", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails(t *testing.T) {
|
|
binaryName := "zero"
|
|
optionalName := "zero-seccomp"
|
|
if runtime.GOOS == "windows" {
|
|
binaryName = "zero.exe"
|
|
optionalName = "zero-windows-command-runner.exe"
|
|
} else if runtime.GOOS == "darwin" {
|
|
t.Skip("macOS ships no optional helper binaries to refresh")
|
|
}
|
|
|
|
installDir := t.TempDir()
|
|
executablePath := filepath.Join(installDir, binaryName)
|
|
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile executable: %v", err)
|
|
}
|
|
// The helper must already exist for a refresh to be attempted at all.
|
|
existingHelperPath := filepath.Join(installDir, optionalName)
|
|
if err := os.WriteFile(existingHelperPath, []byte("old-helper"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile helper: %v", err)
|
|
}
|
|
// Force installBinary's staging copy to fail by occupying its staged
|
|
// "<helper>.new" path with a directory instead of a file.
|
|
if err := os.MkdirAll(existingHelperPath+".new", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll staged path: %v", err)
|
|
}
|
|
|
|
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
|
|
archiveDir := t.TempDir()
|
|
archivePath := filepath.Join(archiveDir, archiveName)
|
|
writeTestTarGz(t, archivePath, map[string]string{
|
|
"zero": "new-binary",
|
|
"zero.exe": "new-binary-exe",
|
|
"zero-seccomp": "new-helper",
|
|
"zero-windows-command-runner.exe": "new-helper-exe",
|
|
})
|
|
checksum, err := release.SHA256File(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("SHA256File: %v", err)
|
|
}
|
|
checksumText, err := release.FormatSHA256Checksum(checksum, archiveName)
|
|
if err != nil {
|
|
t.Fatalf("FormatSHA256Checksum: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/" + archiveName:
|
|
http.ServeFile(w, r, archivePath)
|
|
case "/" + archiveName + ".sha256":
|
|
_, _ = w.Write([]byte(checksumText))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
result := Result{
|
|
LatestVersion: "0.2.0",
|
|
ReleaseAsset: AssetCheck{
|
|
Platform: "linux",
|
|
Arch: "x64",
|
|
ArchiveName: archiveName,
|
|
ArchiveURL: server.URL + "/" + archiveName,
|
|
ChecksumName: archiveName + ".sha256",
|
|
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
|
|
ArchiveFound: true,
|
|
ChecksumFound: true,
|
|
Verified: true,
|
|
},
|
|
}
|
|
|
|
warnings, err := applyStandaloneUpdate(context.Background(), result, executablePath)
|
|
if err != nil {
|
|
t.Fatalf("applyStandaloneUpdate returned error: %v", err)
|
|
}
|
|
if len(warnings) != 1 || !strings.Contains(warnings[0], optionalName) {
|
|
t.Fatalf("expected one warning mentioning %s, got %v", optionalName, warnings)
|
|
}
|
|
|
|
data, err := os.ReadFile(executablePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile executable: %v", err)
|
|
}
|
|
wantBinary := "new-binary"
|
|
if runtime.GOOS == "windows" {
|
|
wantBinary = "new-binary-exe"
|
|
}
|
|
if string(data) != wantBinary {
|
|
t.Fatalf("main binary should still be updated despite helper failure: got %q, want %q", data, wantBinary)
|
|
}
|
|
}
|
|
|
|
func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
|
|
binaryName := "zero"
|
|
if runtime.GOOS == "windows" {
|
|
binaryName = "zero.exe"
|
|
}
|
|
|
|
installDir := t.TempDir()
|
|
executablePath := filepath.Join(installDir, binaryName)
|
|
if err := os.WriteFile(executablePath, []byte("old-binary"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile executable: %v", err)
|
|
}
|
|
|
|
archiveName := "zero-v0.2.0-linux-x64.tar.gz"
|
|
archiveDir := t.TempDir()
|
|
archivePath := filepath.Join(archiveDir, archiveName)
|
|
writeTestTarGz(t, archivePath, map[string]string{"zero": "new-binary", "zero.exe": "new-binary-exe"})
|
|
|
|
badChecksumText, err := release.FormatSHA256Checksum("0000000000000000000000000000000000000000000000000000000000000000"[:64], archiveName)
|
|
if err != nil {
|
|
t.Fatalf("FormatSHA256Checksum: %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/" + archiveName:
|
|
http.ServeFile(w, r, archivePath)
|
|
case "/" + archiveName + ".sha256":
|
|
_, _ = w.Write([]byte(badChecksumText))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
result := Result{
|
|
ReleaseAsset: AssetCheck{
|
|
Platform: "linux",
|
|
Arch: "x64",
|
|
ArchiveName: archiveName,
|
|
ArchiveURL: server.URL + "/" + archiveName,
|
|
ChecksumName: archiveName + ".sha256",
|
|
ChecksumURL: server.URL + "/" + archiveName + ".sha256",
|
|
ArchiveFound: true,
|
|
ChecksumFound: true,
|
|
Verified: true,
|
|
},
|
|
}
|
|
|
|
if _, err := applyStandaloneUpdate(context.Background(), result, executablePath); err == nil {
|
|
t.Fatal("expected checksum mismatch error")
|
|
}
|
|
|
|
data, err := os.ReadFile(executablePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile executable: %v", err)
|
|
}
|
|
if string(data) != "old-binary" {
|
|
t.Fatalf("executable should be untouched after checksum failure, got %q", data)
|
|
}
|
|
}
|