diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f699b5f5..fca1c5df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,22 @@ jobs: run: make cover - name: Vet run: make lint + + test-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25.8' + cache: true + - name: Build + run: go build ./cmd/san/ + - name: Test (cmd/san only — integration tests are Unix-only) + run: go test -count=1 ./cmd/san/... + - name: Smoke test — zip package + run: | + go build -o san.exe ./cmd/san/ + Compress-Archive -Path san.exe -DestinationPath san_windows_amd64.zip -Force + Expand-Archive -Path san_windows_amd64.zip -DestinationPath extracted -Force + .\extracted\san.exe version diff --git a/cmd/san/main.go b/cmd/san/main.go index 5902dbcb..1b40c746 100644 --- a/cmd/san/main.go +++ b/cmd/san/main.go @@ -64,11 +64,17 @@ func init() { rootCmd.AddCommand(helpCmd) rootCmd.SetHelpCommand(helpCmd) rootCmd.AddCommand(mcpCmd) + rootCmd.AddCommand(updateCmd) } func main() { defer func() { _ = log.Sync() }() + // Clean up any stale backup file from a previous self-update. + // On Windows, os.Remove on a running executable's renamed backup + // fails, so we clean it on the next launch instead. + cleanupUpdateBackup() + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -116,6 +122,20 @@ Non-interactive mode: }, } +// cleanupUpdateBackup removes any stale .bak file from a previous self-update. +// On Windows, the running process cannot delete the renamed backup of itself, +// so we defer cleanup to the next launch. +func cleanupUpdateBackup() { + exe, err := os.Executable() + if err != nil { + return + } + backupPath := exe + ".bak" + if _, err := os.Stat(backupPath); err == nil { + _ = os.Remove(backupPath) + } +} + // readStdin returns piped stdin data, or empty string if stdin is a terminal. func readStdin() string { stat, _ := os.Stdin.Stat() @@ -137,6 +157,25 @@ var versionCmd = &cobra.Command{ }, } +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Check for san updates and install if available", + Long: `Check for available san version updates and install the latest version. + +Checks the latest release on GitHub and upgrades the san binary if a newer +version is available. + +The current installed version is read from the binary itself. If a newer +release is found, the binary is automatically downloaded and replaced. + +Example: + san update`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runSelfUpdate(cmd.Context()) + }, +} + var helpCmd = &cobra.Command{ Use: "help", Short: "Show help information", @@ -174,6 +213,7 @@ Session: Commands: version Print the version number agent run Run a headless agent + update Check for san updates and install if available help Show this help message Keybindings: diff --git a/cmd/san/update.go b/cmd/san/update.go new file mode 100644 index 00000000..6bc0975e --- /dev/null +++ b/cmd/san/update.go @@ -0,0 +1,424 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bufio" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" +) + +var ( + githubAPI = "https://api.github.com/repos/genai-io/san/releases/latest" + httpClient = http.DefaultClient +) + +// releaseInfo represents the GitHub API response for a release. +type releaseInfo struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +func runSelfUpdate(ctx context.Context) error { + currentVersion := strings.TrimPrefix(version, "v") + + fmt.Printf("Current version: v%s\n", currentVersion) + + // Fetch latest release info + latest, err := fetchLatestRelease(ctx) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + latestVersion := strings.TrimPrefix(latest.TagName, "v") + fmt.Printf("Latest version: v%s\n", latestVersion) + + // Compare versions + if latestVersion == currentVersion { + fmt.Println("Already up to date.") + return nil + } + + fmt.Printf("New version available: v%s -> v%s\n", currentVersion, latestVersion) + + if !confirm("Download and install?") { + fmt.Println("Update cancelled.") + return nil + } + + // Find the right asset + archiveExt := ".tar.gz" + if runtime.GOOS == "windows" { + archiveExt = ".zip" + } + assetName := fmt.Sprintf("san_%s_%s%s", runtime.GOOS, goArch(runtime.GOARCH), archiveExt) + var downloadURL string + for _, a := range latest.Assets { + if a.Name == assetName { + downloadURL = a.BrowserDownloadURL + break + } + } + if downloadURL == "" { + return fmt.Errorf("no release asset found for %s/%s", runtime.GOOS, runtime.GOARCH) + } + + // Find current binary path + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot determine binary path: %w", err) + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return fmt.Errorf("cannot resolve binary path: %w", err) + } + + // Download and extract to a temp directory on the same filesystem as + // the target binary so the final rename stays within one filesystem + // and doesn't hit EXDEV (cross-device link). + fmt.Printf("Downloading %s ...\n", assetName) + tmpDir, err := os.MkdirTemp(filepath.Dir(exe), ".san-update-*") + if err != nil { + return fmt.Errorf("cannot create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + archive := filepath.Join(tmpDir, assetName) + if err := downloadWithProgress(ctx, downloadURL, archive); err != nil { + return fmt.Errorf("download failed: %w", err) + } + + // Extract the archive + if runtime.GOOS == "windows" { + if err := extractZip(archive, tmpDir); err != nil { + return fmt.Errorf("extract failed: %w", err) + } + } else { + if err := extractTarGz(archive, tmpDir); err != nil { + return fmt.Errorf("extract failed: %w", err) + } + } + + // Determine binary name (san.exe on Windows, san on other platforms) + binName := "san" + if runtime.GOOS == "windows" { + binName = "san.exe" + } + binPath := filepath.Join(tmpDir, binName) + + // Verify the extracted binary exists + if _, err := os.Stat(binPath); err != nil { + return fmt.Errorf("extracted binary not found: %w", err) + } + + newFile, err := os.Stat(binPath) + if err != nil { + return fmt.Errorf("cannot stat new binary: %w", err) + } + + if err := os.Chmod(binPath, newFile.Mode()); err != nil { + return fmt.Errorf("cannot chmod new binary: %w", err) + } + + // Replace the current binary + backupPath := exe + ".bak" + if err := os.Rename(exe, backupPath); err != nil { + return fmt.Errorf("cannot backup current binary: %w", err) + } + + if err := os.Rename(binPath, exe); err != nil { + // On cross-device link (EXDEV), fall back to copy+delete + // instead of aborting — some setups have /tmp on a different + // filesystem than the target binary directory. + var linkErr *os.LinkError + if errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV) { + if err := copyFile(exe, binPath); err != nil { + _ = os.Rename(backupPath, exe) + return fmt.Errorf("cannot install update (copy fallback failed): %w", err) + } + _ = os.Remove(binPath) + } else { + // Restore backup + _ = os.Rename(backupPath, exe) + return fmt.Errorf("cannot install update: %w", err) + } + } + // Remove the backup. On Windows, the running process still has a handle + // to the renamed file, so this will fail silently. The .bak file is + // cleaned up on the next startup. + _ = os.Remove(backupPath) + + fmt.Printf("Updated to v%s\n", latestVersion) + fmt.Printf("Installed to: %s\n", exe) + fmt.Println("Restart san to use the new version.") + return nil +} + +// fetchLatestRelease fetches the latest release info from GitHub. +func fetchLatestRelease(ctx context.Context) (*releaseInfo, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned %s", resp.Status) + } + + var release releaseInfo + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + if release.TagName == "" { + return nil, fmt.Errorf("unexpected response from GitHub API") + } + return &release, nil +} + +// downloadWithProgress downloads a file from url to dest with a terminal progress bar. +func downloadWithProgress(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned %s", resp.Status) + } + + out, err := os.Create(dest) + if err != nil { + return err + } + defer out.Close() + + total := resp.ContentLength + if total > 0 { + pw := &progressWriter{ + w: out, + total: total, + done: make(chan struct{}), + } + go pw.spin() + _, err = io.Copy(pw, resp.Body) + close(pw.done) + fmt.Fprint(os.Stderr, "\r"+strings.Repeat(" ", 60)+"\r") + } else { + _, err = io.Copy(out, resp.Body) + } + return err +} + +// progressWriter wraps an io.Writer and prints a progress bar on stderr. +type progressWriter struct { + w io.Writer + total int64 + written int64 + done chan struct{} +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.w.Write(p) + pw.written += int64(n) + return n, err +} + +func (pw *progressWriter) spin() { + const barWidth = 30 + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-pw.done: + pw.printBar(barWidth) + return + case <-ticker.C: + pw.printBar(barWidth) + } + } +} + +func (pw *progressWriter) printBar(width int) { + pct := float64(pw.written) / float64(pw.total) + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + fmt.Fprintf(os.Stderr, "\r downloading [%s] %3.0f%%", bar, pct*100) +} + +// extractTarGz extracts a tar.gz archive to destDir. +func extractTarGz(tarball, destDir string) error { + f, err := os.Open(tarball) + if err != nil { + return err + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + target := filepath.Join(destDir, header.Name) + + // Prevent tar slip attacks + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path in archive: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return err + } + _, err = io.Copy(out, tr) + _ = out.Close() + if err != nil { + return err + } + } + } + return nil +} + +// extractZip extracts a zip archive to destDir. +func extractZip(zipPath, destDir string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + target := filepath.Join(destDir, f.Name) + + // Prevent zip slip attacks + if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path in zip: %s", f.Name) + } + + if f.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 + } + + rc, err := f.Open() + if err != nil { + return err + } + + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + + _, err = io.Copy(out, rc) + out.Close() + rc.Close() + if err != nil { + return err + } + } + return nil +} + +// copyFile copies src to dst (permissions preserved). +// Used as a fallback when os.Rename fails with EXDEV. +func copyFile(dst, src string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + // Preserve source file permissions + fi, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + return dstFile.Close() +} + +// goArch maps Go arch names to release asset arch names. +func goArch(arch string) string { + switch arch { + case "amd64": + return "amd64" + case "arm64": + return "arm64" + default: + return arch + } +} + +// confirm prompts the user for a yes/no answer and returns true for "yes". +func confirm(prompt string) bool { + fmt.Printf("%s [y/N] ", prompt) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return false + } + answer := strings.ToLower(strings.TrimSpace(scanner.Text())) + return answer == "y" || answer == "yes" +} diff --git a/cmd/san/update_test.go b/cmd/san/update_test.go new file mode 100644 index 00000000..abf7114d --- /dev/null +++ b/cmd/san/update_test.go @@ -0,0 +1,471 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func TestGoArch(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"amd64", "amd64"}, + {"arm64", "arm64"}, + {"x86_64", "x86_64"}, + {"", ""}, + } + for _, tc := range tests { + got := goArch(tc.input) + if got != tc.want { + t.Errorf("goArch(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestFetchLatestRelease(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("unexpected method: %s", r.Method) + } + resp := releaseInfo{ + TagName: "v1.21.0", + Assets: []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + }{ + {Name: "san_darwin_amd64.tar.gz", BrowserDownloadURL: "https://example.com/san_darwin_amd64.tar.gz"}, + {Name: "san_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/san_linux_amd64.tar.gz"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + oldURL := githubAPI + githubAPI = srv.URL + defer func() { githubAPI = oldURL }() + + release, err := fetchLatestRelease(context.Background()) + if err != nil { + t.Fatalf("fetchLatestRelease() error: %v", err) + } + if release.TagName != "v1.21.0" { + t.Errorf("TagName = %q, want %q", release.TagName, "v1.21.0") + } + if len(release.Assets) != 2 { + t.Errorf("len(Assets) = %d, want 2", len(release.Assets)) + } +} + +func TestFetchLatestRelease_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + oldURL := githubAPI + githubAPI = srv.URL + defer func() { githubAPI = oldURL }() + + _, err := fetchLatestRelease(context.Background()) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestFetchLatestRelease_EmptyTag(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := releaseInfo{TagName: ""} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + oldURL := githubAPI + githubAPI = srv.URL + defer func() { githubAPI = oldURL }() + + _, err := fetchLatestRelease(context.Background()) + if err == nil { + t.Fatal("expected error for empty tag, got nil") + } +} + +func TestDownloadWithProgress(t *testing.T) { + content := []byte("hello san binary content") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) + w.Write(content) + })) + defer srv.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "san.tar.gz") + + err := downloadWithProgress(context.Background(), srv.URL, dest) + if err != nil { + t.Fatalf("downloadWithProgress() error: %v", err) + } + + data, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, content) { + t.Errorf("downloaded content = %q, want %q", string(data), string(content)) + } +} + +func TestDownloadWithProgress_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dir := t.TempDir() + dest := filepath.Join(dir, "san.tar.gz") + + err := downloadWithProgress(context.Background(), srv.URL, dest) + if err == nil { + t.Fatal("expected error for 404, got nil") + } +} + +func TestExtractTarGz(t *testing.T) { + dir := t.TempDir() + + // Build a tar.gz in memory containing a single "san" file + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + content := []byte("#!/bin/bash\necho hello") + hdr := &tar.Header{ + Name: "san", + Mode: 0755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + tw.Close() + gzw.Close() + + tarball := filepath.Join(dir, "bundle.tar.gz") + if err := os.WriteFile(tarball, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + destDir := filepath.Join(dir, "extracted") + os.MkdirAll(destDir, 0755) + + if err := extractTarGz(tarball, destDir); err != nil { + t.Fatalf("extractTarGz() error: %v", err) + } + + extracted := filepath.Join(destDir, "san") + data, err := os.ReadFile(extracted) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, content) { + t.Errorf("extracted content = %q, want %q", string(data), string(content)) + } +} + +func TestExtractTarGz_InvalidFile(t *testing.T) { + dir := t.TempDir() + tarball := filepath.Join(dir, "invalid.tar.gz") + os.WriteFile(tarball, []byte("not-a-tar-gz"), 0644) + + err := extractTarGz(tarball, dir) + if err == nil { + t.Fatal("expected error for invalid archive, got nil") + } +} + +func TestExtractTarGz_PathTraversal(t *testing.T) { + dir := t.TempDir() + + // Build a tar.gz whose entry escapes destDir via "../" + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + content := []byte("evil") + hdr := &tar.Header{ + Name: "../escaped", + Mode: 0755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + tw.Close() + gzw.Close() + + tarball := filepath.Join(dir, "evil.tar.gz") + if err := os.WriteFile(tarball, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + destDir := filepath.Join(dir, "extracted") + os.MkdirAll(destDir, 0755) + + if err := extractTarGz(tarball, destDir); err == nil { + t.Fatal("expected error for path traversal entry, got nil") + } + if _, err := os.Stat(filepath.Join(dir, "escaped")); err == nil { + t.Fatal("path traversal escaped destDir") + } +} + +func TestExtractTarGz_WindowsBinary(t *testing.T) { + dir := t.TempDir() + + // Build a tar.gz containing a "san.exe" entry (as expected on Windows) + var buf bytes.Buffer + gzw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gzw) + + content := []byte("windows binary content") + hdr := &tar.Header{ + Name: "san.exe", + Mode: 0755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + tw.Close() + gzw.Close() + + tarball := filepath.Join(dir, "bundle.tar.gz") + if err := os.WriteFile(tarball, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + destDir := filepath.Join(dir, "extracted") + os.MkdirAll(destDir, 0755) + + if err := extractTarGz(tarball, destDir); err != nil { + t.Fatalf("extractTarGz() error: %v", err) + } + + extracted := filepath.Join(destDir, "san.exe") + data, err := os.ReadFile(extracted) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, content) { + t.Errorf("extracted content = %q, want %q", string(data), string(content)) + } +} + +func TestExtractZip(t *testing.T) { + dir := t.TempDir() + + // Build a zip in memory containing a single "san.exe" file + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + content := []byte("windows binary from zip") + fw, err := zw.Create("san.exe") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(content); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + zipPath := filepath.Join(dir, "san_windows_amd64.zip") + if err := os.WriteFile(zipPath, buf.Bytes(), 0644); err != nil { + t.Fatal(err) + } + + if err := extractZip(zipPath, dir); err != nil { + t.Fatalf("extractZip() error: %v", err) + } + + extracted := filepath.Join(dir, "san.exe") + data, err := os.ReadFile(extracted) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, content) { + t.Errorf("extracted content = %q, want %q", string(data), string(content)) + } +} + +func TestExtractZip_InvalidFile(t *testing.T) { + dir := t.TempDir() + zipPath := filepath.Join(dir, "invalid.zip") + os.WriteFile(zipPath, []byte("not-a-zip"), 0644) + + err := extractZip(zipPath, dir) + if err == nil { + t.Fatal("expected error for invalid zip, got nil") + } +} + +func TestCopyFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "source.bin") + dst := filepath.Join(dir, "dest.bin") + + content := []byte("hello copy fallback") + if err := os.WriteFile(src, content, 0755); err != nil { + t.Fatal(err) + } + + if err := copyFile(dst, src); err != nil { + t.Fatalf("copyFile() error: %v", err) + } + + data, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, content) { + t.Errorf("copied content = %q, want %q", string(data), string(content)) + } + + // Verify permissions were preserved + srcInfo, _ := os.Stat(src) + dstInfo, _ := os.Stat(dst) + if srcInfo.Mode() != dstInfo.Mode() { + t.Errorf("mode = %v, want %v", dstInfo.Mode(), srcInfo.Mode()) + } +} + +func TestCopyFile_SrcNotFound(t *testing.T) { + dir := t.TempDir() + err := copyFile(filepath.Join(dir, "dest"), filepath.Join(dir, "nonexistent")) + if err == nil { + t.Fatal("expected error for nonexistent source, got nil") + } +} + +func TestCleanupUpdateBackup(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + backupPath := exe + ".bak" + + // Clean up any pre-existing backup file first + os.Remove(backupPath) + defer os.Remove(backupPath) + + // Create a dummy backup file to simulate a stale .bak from a previous update + if err := os.WriteFile(backupPath, []byte("stale backup content"), 0644); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Fatal("backup file should exist before cleanup") + } + + // Call the startup cleanup function + cleanupUpdateBackup() + + // Verify the backup was removed + if _, err := os.Stat(backupPath); !os.IsNotExist(err) { + t.Error("backup file should have been removed by cleanupUpdateBackup") + } +} + +func TestCleanupUpdateBackup_NoFile(t *testing.T) { + // Ensure no leftover backup from a previous test run + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + backupPath := exe + ".bak" + os.Remove(backupPath) + + // Should not panic or error when no backup file exists + cleanupUpdateBackup() +} + +func TestCleanupUpdateBackup_ExecError(t *testing.T) { + // Smoke test: runs cleanupUpdateBackup and verifies it doesn't panic + cleanupUpdateBackup() +} + +func TestProgressWriter(t *testing.T) { + var buf bytes.Buffer + pw := &progressWriter{ + w: &buf, + total: 100, + done: make(chan struct{}), + } + + n, err := pw.Write([]byte("hello")) + if err != nil { + t.Fatal(err) + } + if n != 5 { + t.Errorf("wrote %d bytes, want 5", n) + } + if pw.written != 5 { + t.Errorf("written = %d, want 5", pw.written) + } + if buf.String() != "hello" { + t.Errorf("buf = %q, want %q", buf.String(), "hello") + } +} + +func TestConfirm(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"y\n", true}, + {"yes\n", true}, + {"Y\n", true}, + {"YES\n", true}, + {"n\n", false}, + {"no\n", false}, + {"\n", false}, + {"whatever\n", false}, + {"", false}, + } + for _, tc := range tests { + // Save and restore stdin + oldStdin := os.Stdin + r, w, _ := os.Pipe() + w.Write([]byte(tc.input)) + w.Close() + os.Stdin = r + + got := confirm("test?") + if got != tc.want { + t.Errorf("confirm(%q) = %v, want %v", tc.input, got, tc.want) + } + os.Stdin = oldStdin + } +}