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]>
178 lines
4.9 KiB
Go
178 lines
4.9 KiB
Go
package update
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func writeTestTarGz(t *testing.T, archivePath string, entries map[string]string) {
|
|
t.Helper()
|
|
file, err := os.Create(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("Create archive: %v", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
gzipWriter := gzip.NewWriter(file)
|
|
tarWriter := tar.NewWriter(gzipWriter)
|
|
for name, content := range entries {
|
|
header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content))}
|
|
if err := tarWriter.WriteHeader(header); err != nil {
|
|
t.Fatalf("WriteHeader %s: %v", name, err)
|
|
}
|
|
if _, err := tarWriter.Write([]byte(content)); err != nil {
|
|
t.Fatalf("Write %s: %v", name, err)
|
|
}
|
|
}
|
|
if err := tarWriter.Close(); err != nil {
|
|
t.Fatalf("close tar writer: %v", err)
|
|
}
|
|
if err := gzipWriter.Close(); err != nil {
|
|
t.Fatalf("close gzip writer: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeTestZip(t *testing.T, archivePath string, entries map[string]string) {
|
|
t.Helper()
|
|
file, err := os.Create(archivePath)
|
|
if err != nil {
|
|
t.Fatalf("Create archive: %v", err)
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
zipWriter := zip.NewWriter(file)
|
|
for name, content := range entries {
|
|
writer, err := zipWriter.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("Create entry %s: %v", name, err)
|
|
}
|
|
if _, err := writer.Write([]byte(content)); err != nil {
|
|
t.Fatalf("Write %s: %v", name, err)
|
|
}
|
|
}
|
|
if err := zipWriter.Close(); err != nil {
|
|
t.Fatalf("close zip writer: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGzRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
archivePath := filepath.Join(dir, "archive.tar.gz")
|
|
writeTestTarGz(t, archivePath, map[string]string{
|
|
"zero": "main-binary",
|
|
"helpers/zero-seccomp": "helper-binary",
|
|
})
|
|
|
|
destDir := filepath.Join(dir, "extracted")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := extractArchive(archivePath, destDir); err != nil {
|
|
t.Fatalf("extractArchive: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(destDir, "zero"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile zero: %v", err)
|
|
}
|
|
if string(data) != "main-binary" {
|
|
t.Fatalf("zero content = %q", data)
|
|
}
|
|
data, err = os.ReadFile(filepath.Join(destDir, "helpers", "zero-seccomp"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile helpers/zero-seccomp: %v", err)
|
|
}
|
|
if string(data) != "helper-binary" {
|
|
t.Fatalf("helpers/zero-seccomp content = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestExtractZipRoundTrip(t *testing.T) {
|
|
dir := t.TempDir()
|
|
archivePath := filepath.Join(dir, "archive.zip")
|
|
writeTestZip(t, archivePath, map[string]string{
|
|
"zero.exe": "main-binary",
|
|
})
|
|
|
|
destDir := filepath.Join(dir, "extracted")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := extractArchive(archivePath, destDir); err != nil {
|
|
t.Fatalf("extractArchive: %v", err)
|
|
}
|
|
|
|
data, err := os.ReadFile(filepath.Join(destDir, "zero.exe"))
|
|
if err != nil {
|
|
t.Fatalf("ReadFile zero.exe: %v", err)
|
|
}
|
|
if string(data) != "main-binary" {
|
|
t.Fatalf("zero.exe content = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
archivePath := filepath.Join(dir, "archive.tar.gz")
|
|
writeTestTarGz(t, archivePath, map[string]string{
|
|
"../escape": "malicious",
|
|
})
|
|
|
|
destDir := filepath.Join(dir, "extracted")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := extractArchive(archivePath, destDir); err == nil {
|
|
t.Fatal("expected extractArchive to reject a path-traversal entry")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "escape")); err == nil {
|
|
t.Fatal("path traversal entry was written outside the destination directory")
|
|
}
|
|
}
|
|
|
|
func TestExtractZipRejectsPathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
archivePath := filepath.Join(dir, "archive.zip")
|
|
writeTestZip(t, archivePath, map[string]string{
|
|
"../../escape.exe": "malicious",
|
|
})
|
|
|
|
destDir := filepath.Join(dir, "extracted")
|
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := extractArchive(archivePath, destDir); err == nil {
|
|
t.Fatal("expected extractArchive to reject a path-traversal entry")
|
|
}
|
|
}
|
|
|
|
func TestFindByBasenameSearchesRecursively(t *testing.T) {
|
|
dir := t.TempDir()
|
|
nested := filepath.Join(dir, "helpers")
|
|
if err := os.MkdirAll(nested, 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
wantPath := filepath.Join(nested, "zero-seccomp")
|
|
if err := os.WriteFile(wantPath, []byte("helper"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
found, err := findByBasename(dir, "zero-seccomp")
|
|
if err != nil {
|
|
t.Fatalf("findByBasename: %v", err)
|
|
}
|
|
if found != wantPath {
|
|
t.Fatalf("findByBasename = %q, want %q", found, wantPath)
|
|
}
|
|
|
|
notFound, err := findByBasename(dir, "does-not-exist")
|
|
if err != nil {
|
|
t.Fatalf("findByBasename: %v", err)
|
|
}
|
|
if notFound != "" {
|
|
t.Fatalf("findByBasename = %q, want empty", notFound)
|
|
}
|
|
}
|