From 6cc77fd8fb8bc4726ca9507c5288ca74be628ad5 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 3 Dec 2025 14:19:43 +1300 Subject: [PATCH 1/3] Add version check and update subcommands for CLI self-update Adds `apppack version check` to check for available updates and `apppack version update` to download and install the latest release from GitHub. Features: - Cross-platform support (Linux, macOS Intel/ARM, Windows) - SHA256 checksum verification before binary replacement - Blocks Homebrew installs with helpful upgrade message - Safe binary replacement with atomic rename and fallbacks - --force flag to update even when already on latest version --- cmd/root.go | 6 +- cmd/version.go | 86 ++++++ selfupdate/selfupdate.go | 490 ++++++++++++++++++++++++++++++++++ selfupdate/selfupdate_test.go | 181 +++++++++++++ version/version.go | 10 +- 5 files changed, 766 insertions(+), 7 deletions(-) create mode 100644 selfupdate/selfupdate.go create mode 100644 selfupdate/selfupdate_test.go diff --git a/cmd/root.go b/cmd/root.go index 92d13f8..4ac01fd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -156,7 +156,7 @@ func printUpdateMessage(newRelease *version.ReleaseInfo) { appPath, err := exec.LookPath(os.Args[0]) checkErr(err) - isHomebrew := isUnderHomebrew(appPath) + isHomebrew := IsUnderHomebrew(appPath) fmt.Fprintf(os.Stderr, "\n\n%s %s → %s\n", aurora.Yellow("A new release of apppack is available:"), @@ -184,8 +184,8 @@ func confirmAction(message, text string) { } } -// Check whether the apppack binary was found under the Homebrew prefix -func isUnderHomebrew(apppackBinary string) bool { +// IsUnderHomebrew checks whether the apppack binary was found under the Homebrew prefix. +func IsUnderHomebrew(apppackBinary string) bool { brewExe, err := safeexec.LookPath("brew") if err != nil { return false diff --git a/cmd/version.go b/cmd/version.go index e4f9d87..0debf4e 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -16,12 +16,22 @@ limitations under the License. package cmd import ( + "context" "fmt" + "net/http" + "os" + "strings" + "github.com/apppackio/apppack/selfupdate" + "github.com/apppackio/apppack/ui" "github.com/apppackio/apppack/version" + "github.com/cli/safeexec" + "github.com/logrusorgru/aurora" "github.com/spf13/cobra" ) +var forceUpdate bool + // versionCmd represents the version command var versionCmd = &cobra.Command{ Use: "version", @@ -36,6 +46,82 @@ var versionCmd = &cobra.Command{ }, } +// versionCheckCmd checks if a newer version is available +var versionCheckCmd = &cobra.Command{ + Use: "check", + Short: "check if a newer version is available", + DisableFlagsInUseLine: true, + Run: func(_ *cobra.Command, _ []string) { + ctx := context.Background() + ui.StartSpinner() + ui.Spinner.Suffix = " checking for updates..." + + release, err := version.GetLatestReleaseInfo(ctx, http.DefaultClient, repo) + checkErr(err) + + ui.Spinner.Stop() + + if version.VersionGreaterThan(release.Version, version.Version) { + fmt.Printf("%s %s → %s\n", + aurora.Yellow("Update available:"), + aurora.Cyan(strings.TrimPrefix(version.Version, "v")), + aurora.Cyan(strings.TrimPrefix(release.Version, "v")), + ) + fmt.Printf("Run %s to update\n", aurora.White("apppack version update")) + } else { + printSuccess(fmt.Sprintf("Already up to date (version %s)", strings.TrimPrefix(version.Version, "v"))) + } + }, +} + +// versionUpdateCmd updates apppack to the latest version +var versionUpdateCmd = &cobra.Command{ + Use: "update", + Short: "update apppack to the latest version", + Long: "Downloads and installs the latest version of apppack from GitHub releases.", + DisableFlagsInUseLine: true, + Run: func(_ *cobra.Command, _ []string) { + ctx := context.Background() + + // Get current binary path + appPath, err := safeexec.LookPath(os.Args[0]) + checkErr(err) + + // Block Homebrew installs + if IsUnderHomebrew(appPath) { + printWarning("AppPack was installed via Homebrew") + fmt.Printf("To update, run: %s\n", aurora.White("brew upgrade apppack")) + + return + } + + ui.StartSpinner() + ui.Spinner.Suffix = " checking for updates..." + + release, err := version.GetLatestReleaseInfo(ctx, http.DefaultClient, repo) + checkErr(err) + + // Check if update is needed + if !forceUpdate && !version.VersionGreaterThan(release.Version, version.Version) { + ui.Spinner.Stop() + printSuccess(fmt.Sprintf("Already up to date (version %s)", strings.TrimPrefix(version.Version, "v"))) + + return + } + + ui.Spinner.Suffix = fmt.Sprintf(" downloading %s...", release.Version) + + err = selfupdate.Update(ctx, http.DefaultClient, release, appPath) + checkErr(err) + + ui.Spinner.Stop() + printSuccess(fmt.Sprintf("Updated to version %s", strings.TrimPrefix(release.Version, "v"))) + }, +} + func init() { rootCmd.AddCommand(versionCmd) + versionCmd.AddCommand(versionCheckCmd) + versionCmd.AddCommand(versionUpdateCmd) + versionUpdateCmd.Flags().BoolVarP(&forceUpdate, "force", "f", false, "force update even if already on latest version") } diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go new file mode 100644 index 0000000..bd757f8 --- /dev/null +++ b/selfupdate/selfupdate.go @@ -0,0 +1,490 @@ +package selfupdate + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + + "github.com/apppackio/apppack/state" + "github.com/apppackio/apppack/version" + "github.com/google/uuid" +) + +const ( + repoOwner = "apppackio" + repoName = "apppack" +) + +// PlatformInfo represents the current platform for download purposes. +type PlatformInfo struct { + OS string // Darwin, Linux, Windows + Arch string // x86_64, arm64, i386 +} + +// GetPlatformInfo detects the current OS and architecture, +// mapping to the naming convention used in GoReleaser archives. +func GetPlatformInfo() (*PlatformInfo, error) { + osName := mapOS(runtime.GOOS) + if osName == "" { + return nil, fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } + + arch := mapArch(runtime.GOARCH) + if arch == "" { + return nil, fmt.Errorf("unsupported architecture: %s", runtime.GOARCH) + } + + return &PlatformInfo{OS: osName, Arch: arch}, nil +} + +func mapOS(goos string) string { + switch goos { + case "darwin": + return "Darwin" + case "linux": + return "Linux" + case "windows": + return "Windows" + default: + return "" + } +} + +func mapArch(goarch string) string { + switch goarch { + case "amd64": + return "x86_64" + case "386": + return "i386" + case "arm64": + return "arm64" + default: + return "" + } +} + +// GetArchiveName constructs the release archive filename. +// Version should be the tag name (e.g., "v4.6.7") - the 'v' prefix is stripped. +func GetArchiveName(ver string, platform *PlatformInfo) string { + // Strip leading 'v' from version if present + ver = strings.TrimPrefix(ver, "v") + + ext := ".tar.gz" + if platform.OS == "Windows" { + ext = ".zip" + } + + return fmt.Sprintf("apppack_%s_%s_%s%s", ver, platform.OS, platform.Arch, ext) +} + +// GetDownloadURL returns the full download URL for a release archive. +func GetDownloadURL(ver, archiveName string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", + repoOwner, repoName, ver, archiveName) +} + +// GetChecksumURL returns the URL to checksums.txt for a release version. +func GetChecksumURL(ver string) string { + return fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/checksums.txt", + repoOwner, repoName, ver) +} + +// DownloadFile downloads a URL to a local file path. +func DownloadFile(ctx context.Context, client *http.Client, url, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("downloading file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + out, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("writing file: %w", err) + } + + return nil +} + +// DownloadChecksums downloads and parses checksums.txt for a release version. +// Returns a map of filename -> SHA256 hash. +func DownloadChecksums(ctx context.Context, client *http.Client, ver string) (map[string]string, error) { + url := GetChecksumURL(ver) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("downloading checksums: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading checksums: %w", err) + } + + return ParseChecksums(content) +} + +// ParseChecksums parses checksums.txt content into a filename -> hash map. +// Format: "sha256hash filename\n" (two spaces between hash and name). +func ParseChecksums(content []byte) (map[string]string, error) { + checksums := make(map[string]string) + lines := strings.Split(string(content), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Format: "hash filename" (two spaces) + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + continue + } + + hash := strings.TrimSpace(parts[0]) + filename := strings.TrimSpace(parts[1]) + + if hash != "" && filename != "" { + checksums[filename] = hash + } + } + + return checksums, nil +} + +// VerifyChecksum computes SHA256 of a file and compares to expected hash. +func VerifyChecksum(filepath, expected string) error { + f, err := os.Open(filepath) + if err != nil { + return fmt.Errorf("opening file: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("computing hash: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if actual != expected { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expected, actual) + } + + return nil +} + +// ExtractBinary extracts the apppack binary from an archive. +// Returns the path to the extracted binary. +func ExtractBinary(archivePath, destDir string, platform *PlatformInfo) (string, error) { + if platform.OS == "Windows" { + return extractZip(archivePath, destDir) + } + + return extractTarGz(archivePath, destDir) +} + +func extractTarGz(archivePath, destDir string) (string, error) { + f, err := os.Open(archivePath) + if err != nil { + return "", fmt.Errorf("opening archive: %w", err) + } + defer f.Close() + + gzr, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("creating gzip reader: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + var binaryPath string + + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return "", fmt.Errorf("reading tar: %w", err) + } + + // Look for the apppack binary + if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "apppack" { + binaryPath = filepath.Join(destDir, "apppack") + + outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return "", fmt.Errorf("creating binary file: %w", err) + } + + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + + return "", fmt.Errorf("extracting binary: %w", err) + } + + outFile.Close() + + break + } + } + + if binaryPath == "" { + return "", errors.New("apppack binary not found in archive") + } + + return binaryPath, nil +} + +func extractZip(archivePath, destDir string) (string, error) { + r, err := zip.OpenReader(archivePath) + if err != nil { + return "", fmt.Errorf("opening zip: %w", err) + } + defer r.Close() + + var binaryPath string + + for _, f := range r.File { + // Look for the apppack.exe binary + if filepath.Base(f.Name) == "apppack.exe" { + binaryPath = filepath.Join(destDir, "apppack.exe") + + rc, err := f.Open() + if err != nil { + return "", fmt.Errorf("opening zip entry: %w", err) + } + + outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + rc.Close() + + return "", fmt.Errorf("creating binary file: %w", err) + } + + _, err = io.Copy(outFile, rc) + rc.Close() + outFile.Close() + + if err != nil { + return "", fmt.Errorf("extracting binary: %w", err) + } + + break + } + } + + if binaryPath == "" { + return "", errors.New("apppack.exe binary not found in archive") + } + + return binaryPath, nil +} + +// ReplaceBinary atomically (when possible) replaces the current binary with a new one. +func ReplaceBinary(currentPath, newPath string) error { + if runtime.GOOS == "windows" { + return replaceBinaryWindows(currentPath, newPath) + } + + return replaceBinaryUnix(currentPath, newPath) +} + +func replaceBinaryUnix(currentPath, newPath string) error { + // Get original file info for permissions + info, err := os.Stat(currentPath) + if err != nil { + return fmt.Errorf("getting file info: %w", err) + } + + // Try atomic rename first + err = os.Rename(newPath, currentPath) + if err == nil { + return os.Chmod(currentPath, info.Mode()) + } + + // Cross-device fallback: copy content + if errors.Is(err, syscall.EXDEV) { + return copyAndReplace(newPath, currentPath, info.Mode()) + } + + return fmt.Errorf("replacing binary: %w", err) +} + +func copyAndReplace(src, dst string, mode os.FileMode) error { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening source: %w", err) + } + defer srcFile.Close() + + // Write to a temp file in the same directory first + tmpPath := dst + ".new" + + dstFile, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + + if _, err := io.Copy(dstFile, srcFile); err != nil { + dstFile.Close() + os.Remove(tmpPath) + + return fmt.Errorf("copying content: %w", err) + } + + dstFile.Close() + + // Atomic rename within same filesystem + if err := os.Rename(tmpPath, dst); err != nil { + os.Remove(tmpPath) + + return fmt.Errorf("renaming temp file: %w", err) + } + + return nil +} + +func replaceBinaryWindows(currentPath, newPath string) error { + oldPath := currentPath + ".old" + + // Remove any existing .old file + os.Remove(oldPath) + + // Rename current to .old + if err := os.Rename(currentPath, oldPath); err != nil { + return fmt.Errorf("backing up current binary: %w", err) + } + + // Copy new binary (can't rename cross-device) + srcFile, err := os.Open(newPath) + if err != nil { + // Attempt rollback + os.Rename(oldPath, currentPath) + + return fmt.Errorf("opening new binary: %w", err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(currentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + // Attempt rollback + os.Rename(oldPath, currentPath) + + return fmt.Errorf("creating new binary: %w", err) + } + defer dstFile.Close() + + if _, err := io.Copy(dstFile, srcFile); err != nil { + // Attempt rollback + os.Remove(currentPath) + os.Rename(oldPath, currentPath) + + return fmt.Errorf("copying new binary: %w", err) + } + + // Clean up old binary (may fail if still in use, that's ok) + os.Remove(oldPath) + + return nil +} + +// Update performs the complete self-update process. +func Update(ctx context.Context, client *http.Client, release *version.ReleaseInfo, currentBinaryPath string) error { + platform, err := GetPlatformInfo() + if err != nil { + return err + } + + // Create temp directory for this update + cacheDir, err := state.CacheDir() + if err != nil { + return fmt.Errorf("getting cache directory: %w", err) + } + + tempDir := filepath.Join(cacheDir, "update-"+uuid.New().String()) + if err := os.MkdirAll(tempDir, 0o700); err != nil { + return fmt.Errorf("creating temp directory: %w", err) + } + // Cleanup on any exit path + defer os.RemoveAll(tempDir) + + // Download checksums + checksums, err := DownloadChecksums(ctx, client, release.Version) + if err != nil { + return fmt.Errorf("downloading checksums: %w", err) + } + + // Construct archive name and verify it exists in checksums + archiveName := GetArchiveName(release.Version, platform) + expectedChecksum, ok := checksums[archiveName] + + if !ok { + return fmt.Errorf("no checksum found for %s", archiveName) + } + + // Download archive + archivePath := filepath.Join(tempDir, archiveName) + downloadURL := GetDownloadURL(release.Version, archiveName) + + if err := DownloadFile(ctx, client, downloadURL, archivePath); err != nil { + return fmt.Errorf("downloading archive: %w", err) + } + + // Verify checksum + if err := VerifyChecksum(archivePath, expectedChecksum); err != nil { + return fmt.Errorf("verifying checksum: %w", err) + } + + // Extract binary + newBinaryPath, err := ExtractBinary(archivePath, tempDir, platform) + if err != nil { + return fmt.Errorf("extracting binary: %w", err) + } + + // Replace current binary + if err := ReplaceBinary(currentBinaryPath, newBinaryPath); err != nil { + return fmt.Errorf("replacing binary: %w", err) + } + + return nil +} diff --git a/selfupdate/selfupdate_test.go b/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..7fd417d --- /dev/null +++ b/selfupdate/selfupdate_test.go @@ -0,0 +1,181 @@ +package selfupdate + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetPlatformInfo(t *testing.T) { + info, err := GetPlatformInfo() + require.NoError(t, err) + assert.NotEmpty(t, info.OS) + assert.NotEmpty(t, info.Arch) + + // Verify current platform maps correctly + expectedOS := mapOS(runtime.GOOS) + expectedArch := mapArch(runtime.GOARCH) + assert.Equal(t, expectedOS, info.OS) + assert.Equal(t, expectedArch, info.Arch) +} + +func TestMapOS(t *testing.T) { + tests := []struct { + goos string + expected string + }{ + {"darwin", "Darwin"}, + {"linux", "Linux"}, + {"windows", "Windows"}, + {"freebsd", ""}, + } + + for _, tt := range tests { + t.Run(tt.goos, func(t *testing.T) { + result := mapOS(tt.goos) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestMapArch(t *testing.T) { + tests := []struct { + goarch string + expected string + }{ + {"amd64", "x86_64"}, + {"386", "i386"}, + {"arm64", "arm64"}, + {"arm", ""}, + } + + for _, tt := range tests { + t.Run(tt.goarch, func(t *testing.T) { + result := mapArch(tt.goarch) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetArchiveName(t *testing.T) { + tests := []struct { + name string + version string + platform PlatformInfo + expected string + }{ + { + "Linux amd64 with v prefix", + "v4.6.7", + PlatformInfo{"Linux", "x86_64"}, + "apppack_4.6.7_Linux_x86_64.tar.gz", + }, + { + "Linux amd64 without v prefix", + "4.6.7", + PlatformInfo{"Linux", "x86_64"}, + "apppack_4.6.7_Linux_x86_64.tar.gz", + }, + { + "Darwin arm64", + "v4.6.7", + PlatformInfo{"Darwin", "arm64"}, + "apppack_4.6.7_Darwin_arm64.tar.gz", + }, + { + "Darwin x86_64", + "v4.6.7", + PlatformInfo{"Darwin", "x86_64"}, + "apppack_4.6.7_Darwin_x86_64.tar.gz", + }, + { + "Windows x86_64", + "v4.6.7", + PlatformInfo{"Windows", "x86_64"}, + "apppack_4.6.7_Windows_x86_64.zip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetArchiveName(tt.version, &tt.platform) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetDownloadURL(t *testing.T) { + url := GetDownloadURL("v4.6.7", "apppack_4.6.7_Linux_x86_64.tar.gz") + expected := "https://github.com/apppackio/apppack/releases/download/v4.6.7/apppack_4.6.7_Linux_x86_64.tar.gz" + assert.Equal(t, expected, url) +} + +func TestGetChecksumURL(t *testing.T) { + url := GetChecksumURL("v4.6.7") + expected := "https://github.com/apppackio/apppack/releases/download/v4.6.7/checksums.txt" + assert.Equal(t, expected, url) +} + +func TestParseChecksums(t *testing.T) { + content := []byte(`abc123def456789012345678901234567890123456789012345678901234 apppack_4.6.7_Linux_x86_64.tar.gz +789xyz012345678901234567890123456789012345678901234567890123 apppack_4.6.7_Darwin_arm64.tar.gz +fedcba987654321098765432109876543210987654321098765432109876 apppack_4.6.7_Windows_x86_64.zip +`) + + checksums, err := ParseChecksums(content) + require.NoError(t, err) + + assert.Len(t, checksums, 3) + assert.Equal(t, "abc123def456789012345678901234567890123456789012345678901234", checksums["apppack_4.6.7_Linux_x86_64.tar.gz"]) + assert.Equal(t, "789xyz012345678901234567890123456789012345678901234567890123", checksums["apppack_4.6.7_Darwin_arm64.tar.gz"]) + assert.Equal(t, "fedcba987654321098765432109876543210987654321098765432109876", checksums["apppack_4.6.7_Windows_x86_64.zip"]) +} + +func TestParseChecksumsEmptyLines(t *testing.T) { + content := []byte(` +abc123 file1.txt + +xyz789 file2.txt + +`) + + checksums, err := ParseChecksums(content) + require.NoError(t, err) + assert.Len(t, checksums, 2) +} + +func TestVerifyChecksum(t *testing.T) { + // Create temp file with known content + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + content := []byte("test content for checksum verification") + + err := os.WriteFile(tmpFile, content, 0o644) + require.NoError(t, err) + + // Compute expected SHA256 + h := sha256.New() + h.Write(content) + expected := hex.EncodeToString(h.Sum(nil)) + + // Test successful verification + err = VerifyChecksum(tmpFile, expected) + assert.NoError(t, err) + + // Test failed verification + err = VerifyChecksum(tmpFile, "wronghash") + assert.Error(t, err) + assert.Contains(t, err.Error(), "checksum mismatch") +} + +func TestVerifyChecksumFileNotFound(t *testing.T) { + err := VerifyChecksum("/nonexistent/file.txt", "somehash") + assert.Error(t, err) + assert.Contains(t, err.Error(), "opening file") +} diff --git a/version/version.go b/version/version.go index 4d40843..ec4230f 100644 --- a/version/version.go +++ b/version/version.go @@ -47,7 +47,7 @@ func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, rep return nil, nil } - releaseInfo, err := getLatestReleaseInfo(ctx, client, repo) + releaseInfo, err := GetLatestReleaseInfo(ctx, client, repo) if err != nil { return nil, err } @@ -57,14 +57,15 @@ func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, rep return nil, err } - if versionGreaterThan(releaseInfo.Version, currentVersion) { + if VersionGreaterThan(releaseInfo.Version, currentVersion) { return releaseInfo, nil } return nil, nil } -func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*ReleaseInfo, error) { +// GetLatestReleaseInfo fetches the latest release information from GitHub. +func GetLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*ReleaseInfo, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), http.NoBody) if err != nil { return nil, err @@ -129,7 +130,8 @@ func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error { return err } -func versionGreaterThan(v, w string) bool { +// VersionGreaterThan returns true if version v is greater than version w. +func VersionGreaterThan(v, w string) bool { w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string { idx := strings.IndexRune(m, '-') n, _ := strconv.Atoi(m[0:idx]) From d0179daefecb70d680cfaa666c7875cd25534099 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 3 Dec 2025 14:27:44 +1300 Subject: [PATCH 2/3] Fix potential DoS via decompression bomb in archive extraction Use io.CopyN with a 100MB limit instead of io.Copy when extracting binaries from archives to prevent decompression bomb attacks. Addresses GO-S2110 (CWE-409) --- selfupdate/selfupdate.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/selfupdate/selfupdate.go b/selfupdate/selfupdate.go index bd757f8..c7c8158 100644 --- a/selfupdate/selfupdate.go +++ b/selfupdate/selfupdate.go @@ -25,6 +25,10 @@ import ( const ( repoOwner = "apppackio" repoName = "apppack" + + // maxBinarySize is the maximum allowed size for the extracted binary (100MB). + // This prevents potential DoS via decompression bombs. + maxBinarySize = 100 * 1024 * 1024 ) // PlatformInfo represents the current platform for download purposes. @@ -256,7 +260,7 @@ func extractTarGz(archivePath, destDir string) (string, error) { return "", fmt.Errorf("creating binary file: %w", err) } - if _, err := io.Copy(outFile, tr); err != nil { + if _, err := io.CopyN(outFile, tr, maxBinarySize); err != nil && !errors.Is(err, io.EOF) { outFile.Close() return "", fmt.Errorf("extracting binary: %w", err) @@ -301,11 +305,11 @@ func extractZip(archivePath, destDir string) (string, error) { return "", fmt.Errorf("creating binary file: %w", err) } - _, err = io.Copy(outFile, rc) + _, err = io.CopyN(outFile, rc, maxBinarySize) rc.Close() outFile.Close() - if err != nil { + if err != nil && !errors.Is(err, io.EOF) { return "", fmt.Errorf("extracting binary: %w", err) } From 3b0f959280d1a11de7897af3da6df15a00defeb8 Mon Sep 17 00:00:00 2001 From: Chris Beaven Date: Wed, 3 Dec 2025 14:30:39 +1300 Subject: [PATCH 3/3] docs: add version check/update commands to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 930a4e6..baf1e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* `version check` command to check if a newer CLI version is available. +* `version update` command to download and install the latest CLI version from GitHub releases. * `build start` command now accepts optional `--ref` flag to build from specific git references (branches, tags, or commit hashes). * `modify app` command to update some parameters of application/pipeline stacks.