Files
zero/internal/update/apply_test.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

338 lines
11 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)
}
}
// VerifySHA256Checksum hashes whichever file the checksum text names, which
// isn't necessarily the archive the caller asked to verify. A checksum file
// that correctly verifies against a DIFFERENT (but real, correctly-hashed)
// file must still be rejected, not treated as vouching for the requested
// archive.
func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
dir := t.TempDir()
decoyName := "decoy.tar.gz"
decoyPath := filepath.Join(dir, decoyName)
if err := os.WriteFile(decoyPath, []byte("decoy contents"), 0o644); err != nil {
t.Fatalf("WriteFile decoy: %v", err)
}
decoyChecksum, err := release.SHA256File(decoyPath)
if err != nil {
t.Fatalf("SHA256File: %v", err)
}
checksumText, err := release.FormatSHA256Checksum(decoyChecksum, decoyName)
if err != nil {
t.Fatalf("FormatSHA256Checksum: %v", err)
}
checksumPath := filepath.Join(dir, "real.tar.gz.sha256")
if err := os.WriteFile(checksumPath, []byte(checksumText), 0o644); err != nil {
t.Fatalf("WriteFile checksum: %v", err)
}
err = verifyArchiveChecksum(checksumPath, "real.tar.gz")
if err == nil {
t.Fatal("expected error when checksum file references a different archive")
}
if !strings.Contains(err.Error(), decoyName) || !strings.Contains(err.Error(), "real.tar.gz") {
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
}
}