diff --git a/README.md b/README.md index 0bd04fd..02cfa88 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,19 @@ brew install greywall This also installs [greyproxy](https://github.com/GreyhavenHQ/greyproxy) as a dependency. -**Linux / Mac:** +**Linux / Mac (build from source):** ```bash -curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.sh | sh +git clone https://github.com/GreyhavenHQ/greywall +cd greywall +make setup && make build +cp greywall ~/.local/bin/greywall +``` + +Then install greyproxy (the network proxy): + +```bash +greywall setup ```
@@ -44,14 +53,7 @@ curl -fsSL https://raw.githubusercontent.com/GreyhavenHQ/greywall/main/install.s ```bash go install github.com/GreyhavenHQ/greywall/cmd/greywall@latest -``` - -**Build from source:** - -```bash -git clone https://github.com/GreyhavenHQ/greywall -cd greywall -make setup && make build +greywall setup ```
diff --git a/cmd/greywall/main.go b/cmd/greywall/main.go index a513c13..a836cca 100644 --- a/cmd/greywall/main.go +++ b/cmd/greywall/main.go @@ -4,6 +4,7 @@ package main import ( "encoding/json" "fmt" + "io" "net/url" "os" "os/exec" @@ -125,6 +126,7 @@ Configuration file format: rootCmd.AddCommand(newProfilesCmd()) rootCmd.AddCommand(newCheckCmd()) rootCmd.AddCommand(newSetupCmd()) + rootCmd.AddCommand(newUpdateCmd()) if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -589,13 +591,15 @@ func newSetupCmd() *cobra.Command { return &cobra.Command{ Use: "setup", Short: "Install and start greyproxy (network proxy for sandboxed commands)", - Long: `Downloads and installs greyproxy from GitHub releases. + Long: `Builds and installs greyproxy from source. greyproxy provides SOCKS5 proxying and DNS resolution for sandboxed commands. +Requires git and go on PATH. + The installer will: - 1. Download the latest greyproxy release for your platform - 2. Install the binary to ~/.local/bin/greyproxy - 3. Register and start a systemd user service`, + 1. Clone the greyproxy repository at the latest release tag + 2. Build the binary with go build + 3. Install to ~/.local/bin/greyproxy and register as a service`, Args: cobra.NoArgs, RunE: runSetup, } @@ -605,23 +609,25 @@ func runSetup(_ *cobra.Command, _ []string) error { status := proxy.Detect() if status.Installed && status.Running { - latest, err := proxy.CheckLatestVersion() + latest, err := proxy.CheckLatestTag(false) if err != nil { fmt.Fprintf(os.Stderr, "Warning: could not check for updates: %v\n", err) fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version) fmt.Printf("Run 'greywall check' for full status.\n") return nil } - if proxy.IsOlderVersion(status.Version, latest) { - fmt.Printf("greyproxy update available: v%s -> v%s\n", status.Version, latest) + latestVer := strings.TrimPrefix(latest, "v") + if proxy.IsOlderVersion(status.Version, latestVer) { + fmt.Printf("greyproxy update available: v%s -> %s\n", status.Version, latest) if proxy.IsBrewManaged(status.Path) { fmt.Printf("greyproxy is managed by Homebrew. To update, run:\n") fmt.Printf(" brew upgrade greyproxy\n") return nil } - fmt.Printf("Upgrading...\n") - return proxy.Install(proxy.InstallOptions{ + fmt.Printf("Upgrading from source...\n") + return proxy.InstallFromSource(proxy.SourceBuildOptions{ Output: os.Stderr, + Tag: latest, }) } fmt.Printf("greyproxy is already installed (v%s) and running.\n", status.Version) @@ -644,11 +650,167 @@ func runSetup(_ *cobra.Command, _ []string) error { return nil } - return proxy.Install(proxy.InstallOptions{ + return proxy.InstallFromSource(proxy.SourceBuildOptions{ Output: os.Stderr, }) } +// newUpdateCmd creates the update subcommand for updating greywall and greyproxy. +func newUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update greywall and greyproxy to the latest release (builds from source)", + Long: `Updates both greywall and greyproxy by cloning their repos and building from source. + +Requires git, go, and make on PATH. + +Examples: + greywall update # update to latest stable + greywall update --beta # update to latest beta`, + Args: cobra.NoArgs, + RunE: runUpdate, + } + cmd.Flags().Bool("beta", false, "Update to the latest beta (pre-release) version") + return cmd +} + +func runUpdate(cmd *cobra.Command, _ []string) error { + beta, _ := cmd.Flags().GetBool("beta") + + // 1. Fetch latest greyproxy tag + greyproxyTag, err := proxy.CheckLatestTag(beta) + if err != nil { + return fmt.Errorf("failed to fetch latest greyproxy tag: %w", err) + } + + // 2. Fetch latest greywall tag + greywallTag, err := proxy.CheckLatestTagFor("GreyhavenHQ", "greywall", beta) + if err != nil { + return fmt.Errorf("failed to fetch latest greywall tag: %w", err) + } + + channel := "stable" + if beta { + channel = "beta" + } + fmt.Printf("Updating to latest %s: greywall %s, greyproxy %s\n\n", channel, greywallTag, greyproxyTag) + + // 3. Update greyproxy + fmt.Println("==> Updating greyproxy...") + if err := proxy.InstallFromSource(proxy.SourceBuildOptions{ + Output: os.Stderr, + Tag: greyproxyTag, + }); err != nil { + return fmt.Errorf("failed to update greyproxy: %w", err) + } + + // 4. Update greywall (skip if brew-managed) + selfPath, _ := os.Executable() + if proxy.IsBrewManaged(selfPath) { + fmt.Printf("\ngreywall is managed by Homebrew. To update, run:\n") + if beta { + fmt.Printf(" greywall update --beta (Homebrew tracks stable only — use this command for beta)\n") + } else { + fmt.Printf(" brew upgrade greywall\n") + } + return nil + } + + fmt.Println("\n==> Updating greywall...") + if err := updateSelf(greywallTag, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to update greywall: %v\n", err) + fmt.Fprintf(os.Stderr, "To update manually:\n") + fmt.Fprintf(os.Stderr, " git clone --branch %s https://github.com/GreyhavenHQ/greywall.git\n", greywallTag) + fmt.Fprintf(os.Stderr, " cd greywall && make build-ci && cp greywall ~/.local/bin/\n") + } + + return nil +} + +// updateSelf builds greywall from source at the given tag and replaces the running binary. +func updateSelf(tag string, output io.Writer) error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required: install git and try again") + } + if _, err := exec.LookPath("make"); err != nil { + return fmt.Errorf("make is required: install make and try again") + } + + // Resolve symlinks to get the real executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to determine executable path: %w", err) + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("failed to resolve executable path: %w", err) + } + + tmpDir, err := os.MkdirTemp("", "greywall-build-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + _, _ = fmt.Fprintf(output, "Cloning greywall %s...\n", tag) + cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch", tag, "https://github.com/GreyhavenHQ/greywall.git", tmpDir) //nolint:gosec // URL and tag are controlled + cloneCmd.Stdout = output + cloneCmd.Stderr = output + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) + } + + _, _ = fmt.Fprintf(output, "Building greywall...\n") + buildCmd := exec.Command("make", "build-ci") //nolint:gosec + buildCmd.Dir = tmpDir + buildCmd.Stdout = output + buildCmd.Stderr = output + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + newBinary := filepath.Join(tmpDir, "greywall") + if _, err := os.Stat(newBinary); err != nil { + return fmt.Errorf("built binary not found: %w", err) + } + + _, _ = fmt.Fprintf(output, "Replacing %s...\n", execPath) + if err := os.Rename(newBinary, execPath); err != nil { + // Rename across filesystems fails; copy instead + if err2 := copyFileTo(newBinary, execPath); err2 != nil { + return fmt.Errorf("failed to replace binary: %w (copy also failed: %v)", err, err2) + } + } + + _, _ = fmt.Fprintf(output, "greywall updated to %s\n", tag) + return nil +} + +// copyFileTo copies src to dst atomically (write to temp, then rename). +func copyFileTo(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // src is a path we control + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + tmpDst := dst + ".new" + out, err := os.OpenFile(tmpDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) //nolint:gosec + if err != nil { + return err + } + defer func() { _ = os.Remove(tmpDst) }() + + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Rename(tmpDst, dst) +} + // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/greywall/main_test.go b/cmd/greywall/main_test.go new file mode 100644 index 0000000..ca2f93a --- /dev/null +++ b/cmd/greywall/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyFileTo(t *testing.T) { + t.Run("copies content and sets executable bit", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + content := []byte("hello binary") + if err := os.WriteFile(src, content, 0o600); err != nil { + t.Fatal(err) + } + + if err := copyFileTo(src, dst); err != nil { + t.Fatalf("copyFileTo: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("reading dst: %v", err) + } + if string(got) != string(content) { + t.Errorf("content mismatch: got %q, want %q", got, content) + } + + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if info.Mode()&0o111 == 0 { + t.Errorf("dst is not executable: mode %v", info.Mode()) + } + }) + + t.Run("overwrites existing dst", func(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + + if err := os.WriteFile(dst, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(src, []byte("new"), 0o755); err != nil { + t.Fatal(err) + } + + if err := copyFileTo(src, dst); err != nil { + t.Fatalf("copyFileTo: %v", err) + } + + got, _ := os.ReadFile(dst) + if string(got) != "new" { + t.Errorf("got %q, want %q", got, "new") + } + }) + + t.Run("returns error for missing src", func(t *testing.T) { + dir := t.TempDir() + err := copyFileTo(filepath.Join(dir, "nonexistent"), filepath.Join(dir, "dst")) + if err == nil { + t.Fatal("expected error for missing src, got nil") + } + }) +} diff --git a/internal/proxy/install.go b/internal/proxy/install.go index f42843f..21346fb 100644 --- a/internal/proxy/install.go +++ b/internal/proxy/install.go @@ -1,8 +1,6 @@ package proxy import ( - "archive/tar" - "compress/gzip" "context" "encoding/json" "fmt" @@ -11,16 +9,16 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strconv" "strings" "time" ) const ( - githubOwner = "greyhavenhq" - githubRepo = "greyproxy" - apiTimeout = 15 * time.Second + githubOwner = "greyhavenhq" + githubRepo = "greyproxy" + greyproxyRepoURL = "https://github.com/greyhavenhq/greyproxy.git" + apiTimeout = 15 * time.Second ) // release represents a GitHub release. @@ -35,11 +33,6 @@ type asset struct { BrowserDownloadURL string `json:"browser_download_url"` } -// InstallOptions controls the greyproxy installation behavior. -type InstallOptions struct { - Output io.Writer // progress output (typically os.Stderr) -} - // CheckLatestVersion fetches the latest greyproxy release tag from GitHub // and returns the version string (without the "v" prefix). func CheckLatestVersion() (string, error) { @@ -82,61 +75,85 @@ func IsOlderVersion(current, latest string) bool { return false } -// Install downloads the latest greyproxy release and runs "greyproxy install". -// Set GREYWALL_NO_GREYPROXY_INSTALL=1 to skip installation entirely. -func Install(opts InstallOptions) error { - if os.Getenv("GREYWALL_NO_GREYPROXY_INSTALL") == "1" { - return nil - } +// fetchLatestRelease queries the GitHub API for the latest greyproxy release. +func fetchLatestRelease() (*release, error) { + return fetchReleaseFor(nil, "", githubOwner, githubRepo, "latest") +} +// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". +func runGreyproxyInstall(binaryPath string) error { + cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// SourceBuildOptions controls the source-build installation behavior. +type SourceBuildOptions struct { + Output io.Writer // progress output (typically os.Stderr) + Tag string // specific tag to build; if empty, uses latest + Beta bool // if Tag is empty and Beta is true, fetches latest pre-release tag +} + +// InstallFromSource clones the greyproxy repo at the given tag, builds it, +// and runs "greyproxy install --force" to register the service. +// Requires git and go on PATH. +func InstallFromSource(opts SourceBuildOptions) error { if opts.Output == nil { opts.Output = os.Stderr } - // 1. Fetch latest release - _, _ = fmt.Fprintf(opts.Output, "Fetching latest greyproxy release...\n") - rel, err := fetchLatestRelease() - if err != nil { - return fmt.Errorf("failed to fetch latest release: %w", err) + tag := opts.Tag + if tag == "" { + var err error + tag, err = CheckLatestTag(opts.Beta) + if err != nil { + return fmt.Errorf("failed to fetch latest tag: %w", err) + } } - ver := strings.TrimPrefix(rel.TagName, "v") - _, _ = fmt.Fprintf(opts.Output, "Latest version: %s\n", ver) + _, _ = fmt.Fprintf(opts.Output, "Building greyproxy %s from source...\n", tag) - // 2. Find the correct asset for this platform - assetURL, assetName, err := resolveAssetURL(rel) - if err != nil { - return err + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required to build from source: install git and try again") } - _, _ = fmt.Fprintf(opts.Output, "Downloading %s...\n", assetName) - - // 3. Download to temp file - archivePath, err := downloadAsset(assetURL) - if err != nil { - return fmt.Errorf("download failed: %w", err) + if _, err := exec.LookPath("go"); err != nil { + return fmt.Errorf("go is required to build from source: install Go from https://go.dev/dl/ and try again") } - defer func() { _ = os.Remove(archivePath) }() - // 4. Extract - _, _ = fmt.Fprintf(opts.Output, "Extracting...\n") - extractDir, err := extractTarGz(archivePath) + tmpDir, err := os.MkdirTemp("", "greyproxy-build-*") if err != nil { - return fmt.Errorf("extraction failed: %w", err) + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + _, _ = fmt.Fprintf(opts.Output, "Cloning greyproxy...\n") + cloneCmd := exec.Command("git", "clone", "--depth=1", "--branch", tag, greyproxyRepoURL, tmpDir) //nolint:gosec // URL and tag are from hardcoded constants and GitHub API + cloneCmd.Stdout = opts.Output + cloneCmd.Stderr = opts.Output + if err := cloneCmd.Run(); err != nil { + return fmt.Errorf("git clone failed: %w", err) } - defer func() { _ = os.RemoveAll(extractDir) }() - // 5. Find the greyproxy binary in extracted content - binaryPath := filepath.Join(extractDir, "greyproxy") - if _, err := os.Stat(binaryPath); err != nil { - return fmt.Errorf("greyproxy binary not found in archive") + _, _ = fmt.Fprintf(opts.Output, "Building...\n") + ver := strings.TrimPrefix(tag, "v") + buildCmd := exec.Command("go", "build", //nolint:gosec // arguments are controlled constants and a sanitized version string + "-ldflags", fmt.Sprintf("-s -w -X main.version=%s", ver), + "-o", "greyproxy", + "./cmd/greyproxy", + ) + buildCmd.Dir = tmpDir + buildCmd.Stdout = opts.Output + buildCmd.Stderr = opts.Output + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) } - // 6. Shell out to "greyproxy install" + binaryPath := filepath.Join(tmpDir, "greyproxy") _, _ = fmt.Fprintf(opts.Output, "\n") if err := runGreyproxyInstall(binaryPath); err != nil { return fmt.Errorf("greyproxy install failed: %w", err) } - // 7. Verify _, _ = fmt.Fprintf(opts.Output, "\nVerifying installation...\n") status := Detect() if status.Installed { @@ -152,11 +169,39 @@ func Install(opts InstallOptions) error { return nil } -// fetchLatestRelease queries the GitHub API for the latest greyproxy release. -func fetchLatestRelease() (*release, error) { - apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", githubOwner, githubRepo) +// CheckLatestTag returns the latest greyproxy release tag (with "v" prefix). +// If beta is true, returns the latest pre-release tag. +func CheckLatestTag(beta bool) (string, error) { + return CheckLatestTagFor(githubOwner, githubRepo, beta) +} + +// CheckLatestTagFor returns the latest release tag for any GitHub repo. +// If beta is true, returns the latest pre-release tag; otherwise returns the latest stable tag. +func CheckLatestTagFor(owner, repo string, beta bool) (string, error) { + return checkLatestTagFor(nil, "", owner, repo, beta) +} - client := &http.Client{Timeout: apiTimeout} +func checkLatestTagFor(client *http.Client, apiBase, owner, repo string, beta bool) (string, error) { + if !beta { + rel, err := fetchReleaseFor(client, apiBase, owner, repo, "latest") + if err != nil { + return "", err + } + return rel.TagName, nil + } + return fetchLatestPreReleaseTagFor(client, apiBase, owner, repo) +} + +// fetchReleaseFor fetches a specific GitHub release endpoint (e.g. "latest" or a tag name). +// client and apiBase are optional; nil/empty use production defaults. +func fetchReleaseFor(client *http.Client, apiBase, owner, repo, endpoint string) (*release, error) { + if client == nil { + client = &http.Client{Timeout: apiTimeout} + } + if apiBase == "" { + apiBase = "https://api.github.com" + } + apiURL := fmt.Sprintf("%s/repos/%s/%s/releases/%s", apiBase, owner, repo, endpoint) ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() @@ -168,7 +213,7 @@ func fetchLatestRelease() (*release, error) { req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("User-Agent", "greywall-setup") - resp, err := client.Do(req) //nolint:gosec // apiURL is built from hardcoded constants + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs if err != nil { return nil, fmt.Errorf("GitHub API request failed: %w", err) } @@ -185,120 +230,49 @@ func fetchLatestRelease() (*release, error) { return &rel, nil } -// resolveAssetURL finds the correct asset download URL for the current OS/arch. -func resolveAssetURL(rel *release) (downloadURL, name string, err error) { - ver := strings.TrimPrefix(rel.TagName, "v") - osName := runtime.GOOS - archName := runtime.GOARCH - - expected := fmt.Sprintf("greyproxy_%s_%s_%s.tar.gz", ver, osName, archName) - - for _, a := range rel.Assets { - if a.Name == expected { - return a.BrowserDownloadURL, a.Name, nil - } +// fetchLatestPreReleaseTagFor returns the most recent pre-release tag for the given repo. +// client and apiBase are optional; nil/empty use production defaults. +func fetchLatestPreReleaseTagFor(client *http.Client, apiBase, owner, repo string) (string, error) { + if client == nil { + client = &http.Client{Timeout: apiTimeout} } - return "", "", fmt.Errorf("no release asset found for %s/%s (expected: %s)", osName, archName, expected) -} - -// downloadAsset downloads a URL to a temp file, returning its path. -func downloadAsset(downloadURL string) (string, error) { - client := &http.Client{Timeout: 5 * time.Minute} + if apiBase == "" { + apiBase = "https://api.github.com" + } + apiURL := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=20", apiBase, owner, repo) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), apiTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", err } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "greywall-setup") - resp, err := client.Do(req) //nolint:gosec // downloadURL comes from GitHub API response + resp, err := client.Do(req) //nolint:gosec // apiURL is built from controlled inputs if err != nil { - return "", err + return "", fmt.Errorf("GitHub API request failed: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("download returned status %d", resp.StatusCode) - } - - tmpFile, err := os.CreateTemp("", "greyproxy-*.tar.gz") - if err != nil { - return "", err - } - - if _, err := io.Copy(tmpFile, resp.Body); err != nil { - _ = tmpFile.Close() - _ = os.Remove(tmpFile.Name()) //nolint:gosec // tmpFile.Name() is from os.CreateTemp, not user input - return "", err - } - _ = tmpFile.Close() - - return tmpFile.Name(), nil -} - -// extractTarGz extracts a .tar.gz archive to a temp directory, returning the dir path. -func extractTarGz(archivePath string) (string, error) { - f, err := os.Open(archivePath) //nolint:gosec // archivePath is a temp file we created - if err != nil { - return "", err + return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode) } - defer func() { _ = f.Close() }() - gz, err := gzip.NewReader(f) - if err != nil { - return "", fmt.Errorf("failed to create gzip reader: %w", err) + var releases []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` } - defer func() { _ = gz.Close() }() - - tmpDir, err := os.MkdirTemp("", "greyproxy-extract-*") - if err != nil { - return "", err + if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil { + return "", fmt.Errorf("failed to parse releases response: %w", err) } - tr := tar.NewReader(gz) - for { - header, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - _ = os.RemoveAll(tmpDir) - return "", fmt.Errorf("tar read error: %w", err) - } - - // Sanitize: only extract regular files with safe names - name := filepath.Base(header.Name) - if name == "." || name == ".." || strings.Contains(header.Name, "..") { - continue - } - - target := filepath.Join(tmpDir, name) //nolint:gosec // name is sanitized via filepath.Base and path traversal check above - - switch header.Typeflag { - case tar.TypeReg: - out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) //nolint:gosec // mode from tar header of trusted archive - if err != nil { - _ = os.RemoveAll(tmpDir) - return "", err - } - if _, err := io.Copy(out, io.LimitReader(tr, 256<<20)); err != nil { // 256 MB limit per file - _ = out.Close() - _ = os.RemoveAll(tmpDir) - return "", err - } - _ = out.Close() + for _, r := range releases { + if r.PreRelease { + return r.TagName, nil } } - - return tmpDir, nil -} - -// runGreyproxyInstall shells out to the extracted greyproxy binary with "install --force". -func runGreyproxyInstall(binaryPath string) error { - cmd := exec.Command(binaryPath, "install", "--force") //nolint:gosec // binaryPath is from our extracted archive - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return "", fmt.Errorf("no pre-release found for %s/%s", owner, repo) } diff --git a/internal/proxy/install_test.go b/internal/proxy/install_test.go new file mode 100644 index 0000000..f07746e --- /dev/null +++ b/internal/proxy/install_test.go @@ -0,0 +1,127 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsOlderVersion(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + // Strictly older + {"1.0.0", "1.0.1", true}, + {"1.0.0", "1.1.0", true}, + {"1.0.0", "2.0.0", true}, + {"0.9.9", "1.0.0", true}, + // Same version + {"1.0.0", "1.0.0", false}, + {"2.3.4", "2.3.4", false}, + // Strictly newer + {"1.0.1", "1.0.0", false}, + {"2.0.0", "1.9.9", false}, + // Invalid current → treated as outdated + {"dev", "1.0.0", true}, + {"", "1.0.0", true}, + {"1.0", "1.0.0", true}, + // Invalid latest → not older + {"1.0.0", "dev", false}, + {"1.0.0", "", false}, + {"1.0.0", "1.0", false}, + // Non-numeric components in current → treated as outdated + {"a.b.c", "1.0.0", true}, + // Non-numeric components in latest → not older + {"1.0.0", "a.b.c", false}, + } + + for _, tc := range tests { + got := IsOlderVersion(tc.current, tc.latest) + if got != tc.want { + t.Errorf("IsOlderVersion(%q, %q) = %v, want %v", tc.current, tc.latest, got, tc.want) + } + } +} + +func TestCheckLatestTagFor_Stable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/releases/latest" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release{TagName: "v1.2.3"}) + })) + defer srv.Close() + + tag, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tag != "v1.2.3" { + t.Errorf("got tag %q, want %q", tag, "v1.2.3") + } +} + +func TestCheckLatestTagFor_Beta(t *testing.T) { + releases := []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` + }{ + {TagName: "v2.0.0-beta.1", PreRelease: true}, + {TagName: "v1.9.0", PreRelease: false}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/owner/repo/releases" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) + })) + defer srv.Close() + + tag, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tag != "v2.0.0-beta.1" { + t.Errorf("got tag %q, want %q", tag, "v2.0.0-beta.1") + } +} + +func TestCheckLatestTagFor_BetaNoneFound(t *testing.T) { + releases := []struct { + TagName string `json:"tag_name"` + PreRelease bool `json:"prerelease"` + }{ + {TagName: "v1.9.0", PreRelease: false}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(releases) + })) + defer srv.Close() + + _, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", true) + if err == nil { + t.Fatal("expected error when no pre-release found, got nil") + } +} + +func TestCheckLatestTagFor_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer srv.Close() + + _, err := checkLatestTagFor(srv.Client(), srv.URL, "owner", "repo", false) + if err == nil { + t.Fatal("expected error on non-200 response, got nil") + } +}