diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f89fa..9144aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Security +- Disabled automatic `xgit update` binary replacement until release authenticity verification is implemented. + ## [0.1.5] - 2026-03-11 ### Fixed diff --git a/README.md b/README.md index 4366dbc..a6099cc 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ In short: **xgit is not a new VCS**. It is a practical AI layer over the Git you - `xgit test-fix`: propose and validate fixes for failing tests - `xgit doc`: generate/update project docs - `xgit auto`: state-aware automation (conflict/test/diff driven) - - `xgit update`: check the latest release and self-update on macOS/Linux + - `xgit update`: check the latest release; automatic self-update is disabled until releases are authenticity-verified - **Workflow scaling** - `xgit alias`: compose multiple xgit commands into one reusable command diff --git a/cmd/update.go b/cmd/update.go index 43f898f..f98e14a 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -15,7 +15,7 @@ func newUpdateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "update", - Short: "Check for updates and optionally self-update xgit", + Short: "Check for xgit release updates", RunE: func(cmd *cobra.Command, args []string) error { return workflow.RunUpdate(context.Background(), workflow.UpdateOptions{ Repo: repo, @@ -28,8 +28,8 @@ func newUpdateCommand() *cobra.Command { } cmd.Flags().StringVar(&repo, "repo", "hjun1052/xgit", "GitHub repository in owner/name format") - cmd.Flags().StringVar(&releaseVersion, "version", "latest", "Release tag to install (default: latest)") + cmd.Flags().StringVar(&releaseVersion, "version", "latest", "Release tag to check (default: latest)") cmd.Flags().BoolVar(&checkOnly, "check", false, "Only check whether an update is available") - cmd.Flags().BoolVar(&autoApply, "yes", false, "Install the update without prompting") + cmd.Flags().BoolVar(&autoApply, "yes", false, "Deprecated; automatic self-update is disabled") return cmd } diff --git a/internal/workflow/update.go b/internal/workflow/update.go index f542968..e914657 100644 --- a/internal/workflow/update.go +++ b/internal/workflow/update.go @@ -1,17 +1,11 @@ package workflow import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" - "os" - "path/filepath" "runtime" "strconv" "strings" @@ -36,12 +30,10 @@ type githubRelease struct { } var ( - updateHTTPClient = http.DefaultClient - updateAPIBaseURL = "https://api.github.com" - updateRuntimeGOOS = runtime.GOOS - updateRuntimeGOARCH = runtime.GOARCH - currentExecutablePath = os.Executable - applyDownloadedBinary = replaceCurrentExecutable + updateHTTPClient = http.DefaultClient + updateAPIBaseURL = "https://api.github.com" + updateRuntimeGOOS = runtime.GOOS + updateRuntimeGOARCH = runtime.GOARCH ) func RunUpdate(ctx context.Context, opts UpdateOptions) error { @@ -62,8 +54,7 @@ func RunUpdate(ctx context.Context, opts UpdateOptions) error { currentVersion := normalizeVersion(versionpkg.Version) targetVersion := normalizeVersion(release.TagName) assetName := expectedReleaseAssetName(targetVersion) - assetURL, err := findReleaseAssetURL(release, assetName) - if err != nil { + if err := ensureReleaseAssetExists(release, assetName); err != nil { return err } @@ -80,36 +71,7 @@ func RunUpdate(ctx context.Context, opts UpdateOptions) error { return nil } - if !opts.AutoApply { - ok, err := requestApplyApproval("Install update now? [y/N]: ") - if err != nil { - return err - } - if !ok { - fmt.Println("Update skipped.") - return nil - } - } - - binaryPath := opts.CurrentPath - if strings.TrimSpace(binaryPath) == "" { - binaryPath, err = currentExecutablePath() - if err != nil { - return fmt.Errorf("resolve current executable: %w", err) - } - } - - binaryBytes, err := downloadReleaseBinary(ctx, assetURL) - if err != nil { - return err - } - if err := applyDownloadedBinary(binaryPath, binaryBytes); err != nil { - return err - } - - fmt.Printf("Updated xgit from %s to %s\n", versionpkg.Version, release.TagName) - fmt.Printf("Installed binary: %s\n", binaryPath) - return nil + return fmt.Errorf("automatic self-update is disabled until release authenticity verification is implemented; download and verify %s manually from the project releases", assetName) } func fetchRelease(ctx context.Context, repo, releaseVersion string) (githubRelease, error) { @@ -155,111 +117,13 @@ func expectedReleaseAssetName(version string) string { return fmt.Sprintf("xgit_%s_%s_%s.%s", strings.TrimPrefix(version, "v"), updateRuntimeGOOS, updateRuntimeGOARCH, ext) } -func findReleaseAssetURL(release githubRelease, expectedName string) (string, error) { +func ensureReleaseAssetExists(release githubRelease, expectedName string) error { for _, asset := range release.Assets { if asset.Name == expectedName { - return asset.BrowserDownloadURL, nil - } - } - return "", fmt.Errorf("release %s does not contain asset %s", release.TagName, expectedName) -} - -func downloadReleaseBinary(ctx context.Context, assetURL string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil) - if err != nil { - return nil, err - } - req.Header.Set("User-Agent", "xgit/"+normalizeVersion(versionpkg.Version)) - - resp, err := updateHTTPClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("update download failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) - } - - archiveBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if updateRuntimeGOOS == "windows" { - return extractBinaryFromZip(archiveBytes) - } - return extractBinaryFromTarGz(archiveBytes) -} - -func extractBinaryFromTarGz(archiveBytes []byte) ([]byte, error) { - gzr, err := gzip.NewReader(bytes.NewReader(archiveBytes)) - if err != nil { - return nil, err - } - defer gzr.Close() - - tr := tar.NewReader(gzr) - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - if filepath.Base(hdr.Name) != "xgit" { - continue - } - return io.ReadAll(tr) - } - return nil, fmt.Errorf("archive did not contain xgit binary") -} - -func extractBinaryFromZip(archiveBytes []byte) ([]byte, error) { - zr, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) - if err != nil { - return nil, err - } - for _, file := range zr.File { - base := strings.ToLower(filepath.Base(file.Name)) - if base != "xgit.exe" && base != "xgit" { - continue - } - rc, err := file.Open() - if err != nil { - return nil, err + return nil } - defer rc.Close() - return io.ReadAll(rc) - } - return nil, fmt.Errorf("archive did not contain xgit.exe binary") -} - -func replaceCurrentExecutable(targetPath string, binaryBytes []byte) error { - if updateRuntimeGOOS == "windows" { - return fmt.Errorf("automatic self-update is not supported on windows yet; use scripts/install.ps1") - } - - dir := filepath.Dir(targetPath) - tmp, err := os.CreateTemp(dir, "xgit-update-*") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - - if _, err := tmp.Write(binaryBytes); err != nil { - _ = tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmpPath, 0o755); err != nil { - return err } - return os.Rename(tmpPath, targetPath) + return fmt.Errorf("release %s does not contain asset %s", release.TagName, expectedName) } func isUpdateAvailable(current, target string) bool { diff --git a/internal/workflow/update_test.go b/internal/workflow/update_test.go index 2987082..661eb7f 100644 --- a/internal/workflow/update_test.go +++ b/internal/workflow/update_test.go @@ -1,15 +1,9 @@ package workflow import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" "context" "net/http" "net/http/httptest" - "os" - "path/filepath" "strings" "testing" @@ -46,19 +40,18 @@ func TestRunUpdateCheckOnly(t *testing.T) { } } -func TestRunUpdateAppliesDownloadedBinary(t *testing.T) { +func TestRunUpdateSelfUpdateDisabled(t *testing.T) { restore := mockUpdateEnvironment(t) defer restore() assetName := expectedReleaseAssetName("v0.1.4") - archive := buildArchiveAsset(t, []byte("new-binary")) mux := http.NewServeMux() mux.HandleFunc("/repos/test/xgit/releases/latest", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"tag_name":"v0.1.4","assets":[{"name":"` + assetName + `","browser_download_url":"` + testServerURL(r) + `/downloads/xgit-archive"}]}`)) }) mux.HandleFunc("/downloads/xgit-archive", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(archive) + t.Fatalf("self-update should not download unverified release assets") }) srv := httptest.NewServer(mux) defer srv.Close() @@ -67,34 +60,14 @@ func TestRunUpdateAppliesDownloadedBinary(t *testing.T) { updateHTTPClient = srv.Client() versionpkg.Version = "0.1.3" - var ( - gotPath string - gotData []byte - ) - applyDownloadedBinary = func(path string, binaryBytes []byte) error { - gotPath = path - gotData = append([]byte(nil), binaryBytes...) - return nil - } - - out := captureStdout(t, func() { - if err := RunUpdate(context.Background(), UpdateOptions{ - Repo: "test/xgit", - Version: "latest", - AutoApply: true, - CurrentPath: "/tmp/xgit-test", - }); err != nil { - t.Fatalf("RunUpdate(apply): %v", err) - } + err := RunUpdate(context.Background(), UpdateOptions{ + Repo: "test/xgit", + Version: "latest", + AutoApply: true, + CurrentPath: "/tmp/xgit-test", }) - if gotPath != "/tmp/xgit-test" { - t.Fatalf("unexpected install path: %q", gotPath) - } - if string(gotData) != "new-binary" { - t.Fatalf("unexpected installed binary payload: %q", string(gotData)) - } - if !strings.Contains(out, "Updated xgit from 0.1.3 to v0.1.4") { - t.Fatalf("expected success output, got %q", out) + if err == nil || !strings.Contains(err.Error(), "automatic self-update is disabled") { + t.Fatalf("expected disabled self-update error, got %v", err) } } @@ -128,82 +101,6 @@ func TestRunUpdateAlreadyCurrent(t *testing.T) { } } -func TestRunUpdateSkippedWhenApprovalDeclined(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - assetName := expectedReleaseAssetName("v0.1.4") - mux := http.NewServeMux() - mux.HandleFunc("/repos/test/xgit/releases/latest", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"tag_name":"v0.1.4","assets":[{"name":"` + assetName + `","browser_download_url":"http://example.invalid/xgit-archive"}]}`)) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - updateAPIBaseURL = srv.URL - versionpkg.Version = "0.1.3" - interactiveInputCheck = func() (bool, error) { return false, nil } - - called := false - applyDownloadedBinary = func(path string, binaryBytes []byte) error { - called = true - return nil - } - - out := captureStdout(t, func() { - if err := RunUpdate(context.Background(), UpdateOptions{ - Repo: "test/xgit", - }); err != nil { - t.Fatalf("RunUpdate(skip): %v", err) - } - }) - if called { - t.Fatalf("did not expect updater to install when approval was declined") - } - if !strings.Contains(out, "Update skipped.") { - t.Fatalf("expected skipped output, got %q", out) - } -} - -func TestRunUpdateUsesExecutablePathWhenCurrentPathEmpty(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - assetName := expectedReleaseAssetName("v0.1.4") - archive := buildArchiveAsset(t, []byte("binary-from-server")) - - mux := http.NewServeMux() - mux.HandleFunc("/repos/test/xgit/releases/latest", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"tag_name":"v0.1.4","assets":[{"name":"` + assetName + `","browser_download_url":"` + testServerURL(r) + `/downloads/xgit-archive"}]}`)) - }) - mux.HandleFunc("/downloads/xgit-archive", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(archive) - }) - srv := httptest.NewServer(mux) - defer srv.Close() - - updateAPIBaseURL = srv.URL - updateHTTPClient = srv.Client() - versionpkg.Version = "0.1.3" - currentExecutablePath = func() (string, error) { return "/tmp/from-executable", nil } - - gotPath := "" - applyDownloadedBinary = func(path string, binaryBytes []byte) error { - gotPath = path - return nil - } - - if err := RunUpdate(context.Background(), UpdateOptions{ - Repo: "test/xgit", - AutoApply: true, - }); err != nil { - t.Fatalf("RunUpdate(exec path): %v", err) - } - if gotPath != "/tmp/from-executable" { - t.Fatalf("expected executable path to be used, got %q", gotPath) - } -} - func TestRunUpdateMissingAssetReturnsError(t *testing.T) { restore := mockUpdateEnvironment(t) defer restore() @@ -261,132 +158,6 @@ func TestFetchReleaseErrors(t *testing.T) { }) } -func TestDownloadReleaseBinaryZipPathAndErrors(t *testing.T) { - t.Run("zip path", func(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - updateRuntimeGOOS = "windows" - updateRuntimeGOARCH = "amd64" - archive := buildZipAsset(t, []byte("zip-binary")) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(archive) - })) - defer srv.Close() - - updateHTTPClient = srv.Client() - got, err := downloadReleaseBinary(context.Background(), srv.URL) - if err != nil { - t.Fatalf("downloadReleaseBinary(zip): %v", err) - } - if string(got) != "zip-binary" { - t.Fatalf("unexpected zip binary: %q", string(got)) - } - }) - - t.Run("http status", func(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "download failed", http.StatusNotFound) - })) - defer srv.Close() - - updateHTTPClient = srv.Client() - _, err := downloadReleaseBinary(context.Background(), srv.URL) - if err == nil || !strings.Contains(err.Error(), "update download failed") { - t.Fatalf("expected download status error, got %v", err) - } - }) -} - -func TestExtractBinaryHelpersAndReplaceCurrentExecutable(t *testing.T) { - t.Run("zip success and missing binary", func(t *testing.T) { - got, err := extractBinaryFromZip(buildZipAsset(t, []byte("zip-ok"))) - if err != nil { - t.Fatalf("extractBinaryFromZip success: %v", err) - } - if string(got) != "zip-ok" { - t.Fatalf("unexpected zip payload: %q", string(got)) - } - - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - w, err := zw.Create("README.txt") - if err != nil { - t.Fatalf("zip create readme: %v", err) - } - if _, err := w.Write([]byte("no binary")); err != nil { - t.Fatalf("zip write readme: %v", err) - } - if err := zw.Close(); err != nil { - t.Fatalf("zip close readme: %v", err) - } - _, err = extractBinaryFromZip(buf.Bytes()) - if err == nil || !strings.Contains(err.Error(), "did not contain xgit.exe") { - t.Fatalf("expected missing binary zip error, got %v", err) - } - }) - - t.Run("tar missing binary", func(t *testing.T) { - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - if err := tw.WriteHeader(&tar.Header{Name: "README.txt", Mode: 0o644, Size: int64(len("x"))}); err != nil { - t.Fatalf("tar header readme: %v", err) - } - if _, err := tw.Write([]byte("x")); err != nil { - t.Fatalf("tar write readme: %v", err) - } - if err := tw.Close(); err != nil { - t.Fatalf("tar close readme: %v", err) - } - if err := gz.Close(); err != nil { - t.Fatalf("gzip close readme: %v", err) - } - - _, err := extractBinaryFromTarGz(buf.Bytes()) - if err == nil || !strings.Contains(err.Error(), "did not contain xgit binary") { - t.Fatalf("expected missing binary tar error, got %v", err) - } - }) - - t.Run("replace executable", func(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - updateRuntimeGOOS = "linux" - updateRuntimeGOARCH = "amd64" - target := filepath.Join(t.TempDir(), "xgit") - if err := os.WriteFile(target, []byte("old"), 0o755); err != nil { - t.Fatalf("write old binary: %v", err) - } - if err := replaceCurrentExecutable(target, []byte("new-binary")); err != nil { - t.Fatalf("replaceCurrentExecutable: %v", err) - } - got, err := os.ReadFile(target) - if err != nil { - t.Fatalf("read replaced binary: %v", err) - } - if string(got) != "new-binary" { - t.Fatalf("unexpected replaced binary: %q", string(got)) - } - }) - - t.Run("replace executable windows unsupported", func(t *testing.T) { - restore := mockUpdateEnvironment(t) - defer restore() - - updateRuntimeGOOS = "windows" - err := replaceCurrentExecutable(filepath.Join(t.TempDir(), "xgit.exe"), []byte("x")) - if err == nil || !strings.Contains(err.Error(), "not supported on windows") { - t.Fatalf("expected windows unsupported error, got %v", err) - } - }) -} - func TestVersionHelpers(t *testing.T) { if got := normalizeVersion("1.2.3"); got != "v1.2.3" { t.Fatalf("normalizeVersion: got %q", got) @@ -414,8 +185,6 @@ func mockUpdateEnvironment(t *testing.T) func() { oldBase := updateAPIBaseURL oldGOOS := updateRuntimeGOOS oldGOARCH := updateRuntimeGOARCH - oldApply := applyDownloadedBinary - oldExec := currentExecutablePath oldInteractive := interactiveInputCheck oldVersion := versionpkg.Version return func() { @@ -423,61 +192,11 @@ func mockUpdateEnvironment(t *testing.T) func() { updateAPIBaseURL = oldBase updateRuntimeGOOS = oldGOOS updateRuntimeGOARCH = oldGOARCH - applyDownloadedBinary = oldApply - currentExecutablePath = oldExec interactiveInputCheck = oldInteractive versionpkg.Version = oldVersion } } -func buildArchiveAsset(t *testing.T, binary []byte) []byte { - if updateRuntimeGOOS == "windows" { - return buildZipAsset(t, binary) - } - return buildTarGzAsset(t, binary) -} - -func buildTarGzAsset(t *testing.T, binary []byte) []byte { - t.Helper() - var buf bytes.Buffer - gz := gzip.NewWriter(&buf) - tw := tar.NewWriter(gz) - if err := tw.WriteHeader(&tar.Header{ - Name: "xgit", - Mode: 0o755, - Size: int64(len(binary)), - }); err != nil { - t.Fatalf("tar header: %v", err) - } - if _, err := tw.Write(binary); err != nil { - t.Fatalf("tar write: %v", err) - } - if err := tw.Close(); err != nil { - t.Fatalf("tar close: %v", err) - } - if err := gz.Close(); err != nil { - t.Fatalf("gzip close: %v", err) - } - return buf.Bytes() -} - -func buildZipAsset(t *testing.T, binary []byte) []byte { - t.Helper() - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - w, err := zw.Create("xgit.exe") - if err != nil { - t.Fatalf("zip create: %v", err) - } - if _, err := w.Write(binary); err != nil { - t.Fatalf("zip write: %v", err) - } - if err := zw.Close(); err != nil { - t.Fatalf("zip close: %v", err) - } - return buf.Bytes() -} - func testServerURL(r *http.Request) string { scheme := "http" if r.TLS != nil {