From c04181a2050c46a4d022b591af48a5144e2985db Mon Sep 17 00:00:00 2001 From: Timmy Date: Thu, 26 Mar 2026 20:49:31 +0000 Subject: [PATCH 1/3] fix: auto-upgrade fzf to fix live search race condition on Linux fzf < 0.56.1 has a race condition (junegunn/fzf#4070) where change:reload: bindings silently fail during rapid typing - the reader process is terminated before it properly initializes, causing the list to stop updating until the user presses Ctrl+R or waits for the process to finish naturally. Debian/Ubuntu repos ship fzf 0.44.1 which is affected. macOS (Homebrew) and Arch (pacman) ship latest and are unaffected. Changes: - ensureFzfGo() now checks fzf >= 0.56.1, not just existence - If outdated, tries package manager upgrade first - Falls back to downloading latest release binary from GitHub (tar.gz/zip) - Binary installed to ~/.local/share/fpf/fzf/ (preferred over system fzf) - All fzf invocations use resolveFzfBinaryPath() to pick the best binary - Supports Linux (tar.gz), macOS (tar.gz), and Windows (zip) --- cmd/fpf/cli_runtime.go | 260 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 238 insertions(+), 22 deletions(-) diff --git a/cmd/fpf/cli_runtime.go b/cmd/fpf/cli_runtime.go index d30c892..63a9694 100644 --- a/cmd/fpf/cli_runtime.go +++ b/cmd/fpf/cli_runtime.go @@ -1,9 +1,14 @@ package main import ( + "archive/tar" + "archive/zip" "bufio" "bytes" + "compress/gzip" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -27,6 +32,8 @@ const ( actionFeed cliAction = "feed-search" ) +const fzfMinVersionChangeReload = "0.56.1" + type cliInput struct { Action cliAction AssumeYes bool @@ -649,7 +656,8 @@ func runFuzzySelectorGo(query, inputFile, header, helpFile, keybindFile, reloadC } defer stdinFile.Close() - cmd := exec.Command("fzf", args...) + fzfBin := resolveFzfBinaryPath() + cmd := exec.Command(fzfBin, args...) cmd.Env = append(os.Environ(), "SHELL=bash") cmd.Stdin = stdinFile var stdout bytes.Buffer @@ -822,7 +830,7 @@ func dynamicReloadUseIPCGo() bool { func fzfSupportsListenGo() bool { // Check for --listen flag support first - cmd := exec.Command("fzf", "--help") + cmd := exec.Command(resolveFzfBinaryPath(), "--help") out, err := cmd.CombinedOutput() if err != nil { return false @@ -835,7 +843,7 @@ func fzfSupportsListenGo() bool { } func checkFzfVersionMin(minVersion string) bool { - cmd := exec.Command("fzf", "--version") + cmd := exec.Command(resolveFzfBinaryPath(), "--version") out, err := cmd.Output() if err != nil { return false @@ -882,7 +890,7 @@ func parseVersion(v string) []int { } func fzfSupportsResultBindGo() bool { - cmd := exec.Command("fzf", "--bind=result:abort", "--filter", "probe") + cmd := exec.Command(resolveFzfBinaryPath(), "--bind=result:abort", "--filter", "probe") cmd.Stdin = strings.NewReader("probe\n") out, _ := cmd.CombinedOutput() return !strings.Contains(string(out), "unsupported key: result") @@ -1106,6 +1114,9 @@ func fzfCommandAvailableGo() bool { } return false } + if localFzfBinaryPath() != "" { + return true + } return commandExistsGo("fzf") } @@ -1151,6 +1162,41 @@ func installFzfWithManagerGo(manager string) error { } } +func fzfLocalDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".local", "share", "fpf", "fzf") +} + +func localFzfBinaryPath() string { + dir := fzfLocalDir() + if dir == "" { + return "" + } + binaryName := "fzf" + if runtime.GOOS == "windows" { + binaryName = "fzf.exe" + } + p := filepath.Join(dir, binaryName) + info, err := os.Stat(p) + if err != nil || info.IsDir() { + return "" + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return "" + } + return p +} + +func resolveFzfBinaryPath() string { + if p := localFzfBinaryPath(); p != "" { + return p + } + return "fzf" +} + func installFzfFromReleaseFallbackGo() bool { if strings.TrimSpace(os.Getenv("FPF_TEST_BOOTSTRAP_FZF_FALLBACK")) == "1" { mockCmdPath := strings.TrimSpace(os.Getenv("FPF_TEST_MOCKCMD_PATH")) @@ -1165,7 +1211,155 @@ func installFzfFromReleaseFallbackGo() bool { } return true } - return false + + goos := runtime.GOOS + goarch := runtime.GOARCH + if goos != "linux" && goos != "darwin" && goos != "windows" { + return false + } + + dir := fzfLocalDir() + if dir == "" { + return false + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return false + } + + version := resolveLatestFzfVersion() + if version == "" { + return false + } + + binaryName := "fzf" + if goos == "windows" { + binaryName = "fzf.exe" + } + target := filepath.Join(dir, binaryName) + + var url string + if goos == "windows" { + url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s_%s.zip", version, version, goos+"_"+goarch) + } else { + url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s_%s.tar.gz", version, version, goos+"_"+goarch) + } + + if err := downloadAndInstallFzf(url, target); err != nil { + _ = os.Remove(target) + return false + } + + info, err := os.Stat(target) + if err != nil || info.IsDir() { + return false + } + if goos != "windows" && info.Mode()&0o111 == 0 { + return false + } + return true +} + +func resolveLatestFzfVersion() string { + client := &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Get("https://github.com/junegunn/fzf/releases/latest") + if err != nil { + return "" + } + resp.Body.Close() + loc := resp.Header.Get("Location") + if loc == "" { + return "" + } + // Location looks like: .../releases/tag/v0.71.0 + idx := strings.LastIndex(loc, "/v") + if idx < 0 { + return "" + } + return loc[idx+2:] +} + +func downloadAndInstallFzf(url, target string) error { + client := &http.Client{Timeout: 120 * time.Second} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) + } + + binaryName := filepath.Base(target) + + if strings.HasSuffix(url, ".zip") { + return extractZipFzf(resp.Body, target, binaryName) + } + return extractTarGzFzf(resp.Body, target) +} + +func extractTarGzFzf(reader io.Reader, target string) error { + gz, err := gzip.NewReader(reader) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if filepath.Base(hdr.Name) == "fzf" && hdr.Typeflag == tar.TypeReg { + return writeExecutable(target, tr) + } + } + return fmt.Errorf("fzf binary not found in archive") +} + +func extractZipFzf(reader io.Reader, target, binaryName string) error { + data, err := io.ReadAll(reader) + if err != nil { + return err + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return fmt.Errorf("zip: %w", err) + } + for _, f := range zr.File { + if filepath.Base(f.Name) == binaryName && !f.FileInfo().IsDir() { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + return writeExecutable(target, rc) + } + } + return fmt.Errorf("%s not found in archive", binaryName) +} + +func writeExecutable(target string, reader io.Reader) error { + tmp := target + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, err := io.Copy(f, reader); err != nil { + f.Close() + _ = os.Remove(tmp) + return err + } + f.Close() + return os.Rename(tmp, target) } func buildFzfBootstrapCandidatesGo(managers []string) []string { @@ -1194,23 +1388,45 @@ func buildFzfBootstrapCandidatesGo(managers []string) []string { } func ensureFzfGo(managers []string) error { - if fzfCommandAvailableGo() { - return nil - } - candidates := buildFzfBootstrapCandidatesGo(managers) - if len(candidates) == 0 { - return fmt.Errorf("fzf is required and no compatible manager is available to auto-install it") - } - fmt.Fprintf(os.Stderr, "fzf is missing. Auto-installing with: %s\n", joinManagerLabelsGo(candidates)) - for _, manager := range candidates { - fmt.Fprintf(os.Stderr, "Attempting fzf install with %s\n", managerLabelGo(manager)) - if err := installFzfWithManagerGo(manager); err == nil && fzfCommandAvailableGo() { - return nil + if !fzfCommandAvailableGo() { + candidates := buildFzfBootstrapCandidatesGo(managers) + if len(candidates) == 0 { + return fmt.Errorf("fzf is required and no compatible manager is available to auto-install it") + } + fmt.Fprintf(os.Stderr, "fzf is missing. Auto-installing with: %s\n", joinManagerLabelsGo(candidates)) + for _, manager := range candidates { + fmt.Fprintf(os.Stderr, "Attempting fzf install with %s\n", managerLabelGo(manager)) + if err := installFzfWithManagerGo(manager); err == nil && fzfCommandAvailableGo() { + break + } + } + if !fzfCommandAvailableGo() { + fmt.Fprintln(os.Stderr, "Package-manager bootstrap did not provide fzf. Trying release binary fallback.") + if !installFzfFromReleaseFallbackGo() || !fzfCommandAvailableGo() { + return fmt.Errorf("Failed to auto-install fzf. Install fzf manually and rerun.") + } } } - fmt.Fprintln(os.Stderr, "Package-manager bootstrap did not provide fzf. Trying release binary fallback.") - if installFzfFromReleaseFallbackGo() && fzfCommandAvailableGo() { - return nil + + if !checkFzfVersionMin(fzfMinVersionChangeReload) { + fmt.Fprintf(os.Stderr, "fzf is outdated (need >= %s for reliable live search). Upgrading...\n", fzfMinVersionChangeReload) + upgraded := false + for _, manager := range buildFzfBootstrapCandidatesGo(managers) { + if err := installFzfWithManagerGo(manager); err == nil && checkFzfVersionMin(fzfMinVersionChangeReload) { + upgraded = true + break + } + } + if !upgraded { + fmt.Fprintln(os.Stderr, "Package manager did not provide a recent fzf. Downloading latest release...") + if installFzfFromReleaseFallbackGo() && localFzfBinaryPath() != "" { + upgraded = true + } + } + if !upgraded { + fmt.Fprintf(os.Stderr, "Warning: fzf < %s has a known race condition with live search.\n", fzfMinVersionChangeReload) + fmt.Fprintln(os.Stderr, "Live updates may be unreliable. Please upgrade fzf manually.") + } } - return fmt.Errorf("Failed to auto-install fzf. Install fzf manually and rerun.") -} \ No newline at end of file + return nil +} From 4cd0db448482ef44d1daf60f0ef99687823313d3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:59:00 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`fix?= =?UTF-8?q?/fzf-live-search-linux`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @Timmy6942025. * https://github.com/Timmy6942025/fpf-cli/pull/3#issuecomment-4138123187 The following files were modified: * `cmd/fpf/cli_runtime.go` --- cmd/fpf/cli_runtime.go | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/cmd/fpf/cli_runtime.go b/cmd/fpf/cli_runtime.go index 63a9694..d48c914 100644 --- a/cmd/fpf/cli_runtime.go +++ b/cmd/fpf/cli_runtime.go @@ -594,6 +594,8 @@ func writeDisplayRows(path string, rows []displayRow) error { return os.WriteFile(path, []byte(b.String()), 0o644) } +// runFuzzySelectorGo runs an fzf subprocess configured with preview, keybinds, and optional dynamic reload bindings and returns the raw selection. +// It returns the stdout produced by fzf on success; returns an empty string and nil when the user cancels (fzf exit code 1 or 130); returns a non-nil error for other failures. func runFuzzySelectorGo(query, inputFile, header, helpFile, keybindFile, reloadCmd, reloadFullCmd, reloadIPCCmd, sessionTmp string) (string, error) { stageStart := time.Now() defer logPerfTraceStage("fzf", stageStart) @@ -828,6 +830,7 @@ func dynamicReloadUseIPCGo() bool { } } +// It checks `fzf --help` for a `--listen` entry and ensures the fzf version is >= 0.56.1. func fzfSupportsListenGo() bool { // Check for --listen flag support first cmd := exec.Command(resolveFzfBinaryPath(), "--help") @@ -842,6 +845,8 @@ func fzfSupportsListenGo() bool { return checkFzfVersionMin("0.56.1") } +// checkFzfVersionMin reports whether the installed fzf binary's version is greater than or equal to minVersion. +// It returns false if the fzf executable cannot be run or its version cannot be determined. func checkFzfVersionMin(minVersion string) bool { cmd := exec.Command(resolveFzfBinaryPath(), "--version") out, err := cmd.Output() @@ -876,6 +881,10 @@ func compareVersions(v1, v2 string) int { return 0 } +// parseVersion parses a dot-separated version string into a slice of integers. +// It splits v on '.' and converts each segment to an int, stopping at the first +// segment that is not a valid integer. Returns the sequence of parsed integers +// (which may be empty). func parseVersion(v string) []int { parts := strings.Split(v, ".") result := make([]int, 0, len(parts)) @@ -889,6 +898,8 @@ func parseVersion(v string) []int { return result } +// fzfSupportsResultBindGo reports whether the installed fzf supports the `result` binding for `--bind`. +// It returns true if the `result` key is supported, false otherwise. func fzfSupportsResultBindGo() bool { cmd := exec.Command(resolveFzfBinaryPath(), "--bind=result:abort", "--filter", "probe") cmd.Stdin = strings.NewReader("probe\n") @@ -1103,6 +1114,8 @@ func commandExistsGo(name string) bool { return err == nil } +// fzfCommandAvailableGo reports whether an fzf executable is available for use. +// It returns true if a usable fzf binary is found via test overrides, the local fzf install, or the system PATH. func fzfCommandAvailableGo() bool { if strings.TrimSpace(os.Getenv("FPF_TEST_FORCE_FZF_MISSING")) == "1" { mockBin := strings.TrimSpace(os.Getenv("FPF_TEST_MOCK_BIN")) @@ -1129,6 +1142,12 @@ func managerCanInstallFzfGo(manager string) bool { } } +// installFzfWithManagerGo installs the fzf executable using the specified package manager. +// +// If the environment variable FPF_TEST_FZF_MANAGER_INSTALL_FAIL is set to "1", the function +// returns a forced failure error for testing. For supported managers it runs the manager's +// install command and returns any error produced; for an unsupported manager it returns an +// error indicating installation is not supported. func installFzfWithManagerGo(manager string) error { if strings.TrimSpace(os.Getenv("FPF_TEST_FZF_MANAGER_INSTALL_FAIL")) == "1" { return fmt.Errorf("forced manager install failure") @@ -1162,6 +1181,8 @@ func installFzfWithManagerGo(manager string) error { } } +// fzfLocalDir returns the per-user directory path where fpf places a bundled fzf binary. +// It returns an empty string if the user's home directory cannot be determined. func fzfLocalDir() string { home, err := os.UserHomeDir() if err != nil { @@ -1170,6 +1191,8 @@ func fzfLocalDir() string { return filepath.Join(home, ".local", "share", "fpf", "fzf") } +// localFzfBinaryPath returns the path to the per-user fzf binary in the fpf local directory if the file exists, is not a directory, and—on non-Windows systems—has execute permission; otherwise it returns an empty string. +// On Windows it looks for `fzf.exe` and does not check execute permission. func localFzfBinaryPath() string { dir := fzfLocalDir() if dir == "" { @@ -1190,6 +1213,7 @@ func localFzfBinaryPath() string { return p } +// resolveFzfBinaryPath returns the path to the fzf executable, preferring a local per-user installation when available and falling back to the system "fzf" command. func resolveFzfBinaryPath() string { if p := localFzfBinaryPath(); p != "" { return p @@ -1197,6 +1221,8 @@ func resolveFzfBinaryPath() string { return "fzf" } +// installFzfFromReleaseFallbackGo attempts to install fzf into the per-user fpf fzf directory by downloading and extracting the latest GitHub release for the current OS (supports linux, darwin, windows). In test mode (FPF_TEST_BOOTSTRAP_FZF_FALLBACK=1) it instead creates a symlink from a provided mock command into the mock bin. +// It returns true if the installation produced an executable fzf binary at the expected local path, false on any failure. func installFzfFromReleaseFallbackGo() bool { if strings.TrimSpace(os.Getenv("FPF_TEST_BOOTSTRAP_FZF_FALLBACK")) == "1" { mockCmdPath := strings.TrimSpace(os.Getenv("FPF_TEST_MOCKCMD_PATH")) @@ -1259,6 +1285,8 @@ func installFzfFromReleaseFallbackGo() bool { return true } +// resolveLatestFzfVersion retrieves the latest fzf release version by parsing the redirect target of GitHub's releases/latest URL. +// It returns the version string without the leading "v" (for example, "0.71.0"), or an empty string if the request fails or the redirect format is unexpected. func resolveLatestFzfVersion() string { client := &http.Client{ Timeout: 15 * time.Second, @@ -1283,6 +1311,10 @@ func resolveLatestFzfVersion() string { return loc[idx+2:] } +// downloadAndInstallFzf downloads the archive at the given URL and installs the contained fzf binary to target. +// The function performs an HTTP GET and returns an error for network failures or non-200 responses. +// If the URL ends with ".zip" the archive is extracted with extractZipFzf; otherwise it is treated as a tar.gz and extracted with extractTarGzFzf. +// Any error from the extraction or write operation is returned. func downloadAndInstallFzf(url, target string) error { client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Get(url) @@ -1302,6 +1334,8 @@ func downloadAndInstallFzf(url, target string) error { return extractTarGzFzf(resp.Body, target) } +// extractTarGzFzf extracts the first regular file named "fzf" from a gzip-compressed tar stream and writes it to target as an executable. +// It returns an error if the gzip or tar stream cannot be read, if writing the extracted file fails, or if no matching "fzf" file is found in the archive. func extractTarGzFzf(reader io.Reader, target string) error { gz, err := gzip.NewReader(reader) if err != nil { @@ -1325,6 +1359,9 @@ func extractTarGzFzf(reader io.Reader, target string) error { return fmt.Errorf("fzf binary not found in archive") } +// extractZipFzf extracts the entry whose basename equals binaryName from the provided ZIP stream +// and writes it as an executable at target using writeExecutable. +// It returns an error if the archive cannot be read, the named binary is not present, or writing the executable fails. func extractZipFzf(reader io.Reader, target, binaryName string) error { data, err := io.ReadAll(reader) if err != nil { @@ -1347,6 +1384,9 @@ func extractZipFzf(reader io.Reader, target, binaryName string) error { return fmt.Errorf("%s not found in archive", binaryName) } +// writeExecutable writes data from reader to target file atomically and makes the file executable. +// It creates a temporary file at target+".tmp" with mode 0755, writes the contents, closes it, +// and renames it into place. On write failures the temporary file is removed; any error is returned. func writeExecutable(target string, reader io.Reader) error { tmp := target + ".tmp" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) @@ -1362,6 +1402,9 @@ func writeExecutable(target string, reader io.Reader) error { return os.Rename(tmp, target) } +// buildFzfBootstrapCandidatesGo builds an ordered list of package managers suitable for bootstrapping fzf. +// It filters the provided managers to those that can install fzf and are command-ready, preserves the +// first-seen order while deduplicating, and if none remain returns a fallback list of common managers. func buildFzfBootstrapCandidatesGo(managers []string) []string { seen := map[string]struct{}{} out := make([]string, 0) @@ -1387,6 +1430,21 @@ func buildFzfBootstrapCandidatesGo(managers []string) []string { return out } +// ensureFzfGo ensures a usable fzf binary is available and that its version +// meets the minimum required for reliable live search. +// +// It tries to auto-install fzf by attempting package-manager installs using +// the ordered candidate list derived from the provided managers, and if that +// fails falls back to downloading and installing a release into the local +// fzf directory. If fzf is present but older than the minimum required +// version, it will attempt the same upgrade steps; if the upgrade cannot be +// performed the function emits a warning but returns success. +// +// The managers parameter supplies preferred package-manager names (order is +// respected) to use when attempting bootstrap or upgrade operations. +// +// Returns an error only when fzf cannot be installed (package-manager attempts +// and release fallback both fail); otherwise returns nil. func ensureFzfGo(managers []string) error { if !fzfCommandAvailableGo() { candidates := buildFzfBootstrapCandidatesGo(managers) From 1206b0c57c1264231cb38fd3c686a242bc8e05a4 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:09:56 +0000 Subject: [PATCH 3/3] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit --- cmd/fpf/cli_runtime.go | 164 +++++++++++++++++++++++++++++++++++------ 1 file changed, 142 insertions(+), 22 deletions(-) diff --git a/cmd/fpf/cli_runtime.go b/cmd/fpf/cli_runtime.go index d48c914..c5695d0 100644 --- a/cmd/fpf/cli_runtime.go +++ b/cmd/fpf/cli_runtime.go @@ -6,6 +6,8 @@ import ( "bufio" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" @@ -1257,17 +1259,31 @@ func installFzfFromReleaseFallbackGo() bool { return false } + // Map GOARCH to fzf's expected arch strings + fzfArch := goarch + switch goarch { + case "arm": + fzfArch = "armv7" // fzf uses armv7 for 32-bit ARM + case "amd64": + fzfArch = "amd64" + case "386": + fzfArch = "386" + case "arm64": + fzfArch = "arm64" + } + binaryName := "fzf" if goos == "windows" { binaryName = "fzf.exe" } target := filepath.Join(dir, binaryName) + // fzf asset name format: fzf-{version}-{os}_{arch}.tar.gz (or .zip on Windows) var url string if goos == "windows" { - url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s_%s.zip", version, version, goos+"_"+goarch) + url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s-%s_%s.zip", version, version, goos, fzfArch) } else { - url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s_%s.tar.gz", version, version, goos+"_"+goarch) + url = fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s-%s_%s.tar.gz", version, version, goos, fzfArch) } if err := downloadAndInstallFzf(url, target); err != nil { @@ -1311,11 +1327,68 @@ func resolveLatestFzfVersion() string { return loc[idx+2:] } -// downloadAndInstallFzf downloads the archive at the given URL and installs the contained fzf binary to target. +// fetchFzfChecksum retrieves the expected SHA256 checksum for the given fzf asset filename. +// It downloads the checksums file from GitHub releases and parses it to find the matching entry. +// Returns the hex-encoded checksum or an error if the file cannot be fetched or parsed. +func fetchFzfChecksum(version, assetFilename string) (string, error) { + checksumURL := fmt.Sprintf("https://github.com/junegunn/fzf/releases/download/v%s/fzf-%s-checksums.txt", version, version) + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(checksumURL) + if err != nil { + return "", fmt.Errorf("failed to fetch checksums: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("checksums HTTP %d from %s", resp.StatusCode, checksumURL) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + // Format: + parts := strings.Fields(line) + if len(parts) >= 2 { + checksum := parts[0] + filename := parts[1] + if filename == assetFilename { + return checksum, nil + } + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error reading checksums: %w", err) + } + return "", fmt.Errorf("checksum not found for %s", assetFilename) +} + +// downloadAndInstallFzf downloads the archive at the given URL, verifies its SHA256 checksum, +// and installs the contained fzf binary to target. // The function performs an HTTP GET and returns an error for network failures or non-200 responses. -// If the URL ends with ".zip" the archive is extracted with extractZipFzf; otherwise it is treated as a tar.gz and extracted with extractTarGzFzf. -// Any error from the extraction or write operation is returned. +// It fetches the expected checksum, downloads the asset into memory, computes and verifies the SHA256, +// and then extracts the binary. If the URL ends with ".zip" the archive is extracted with extractZipFzf; +// otherwise it is treated as a tar.gz and extracted with extractTarGzFzf. +// Any error from download, checksum verification, extraction or write operation is returned. func downloadAndInstallFzf(url, target string) error { + // Extract version and asset filename from URL + // URL format: https://github.com/junegunn/fzf/releases/download/v{version}/fzf-{version}-{os}_{arch}.{ext} + parts := strings.Split(url, "/") + if len(parts) < 2 { + return fmt.Errorf("invalid URL format") + } + assetFilename := parts[len(parts)-1] + versionTag := parts[len(parts)-2] + version := strings.TrimPrefix(versionTag, "v") + + // Fetch expected checksum + expectedChecksum, err := fetchFzfChecksum(version, assetFilename) + if err != nil { + return fmt.Errorf("checksum fetch failed: %w", err) + } + + // Download asset into memory client := &http.Client{Timeout: 120 * time.Second} resp, err := client.Get(url) if err != nil { @@ -1326,12 +1399,28 @@ func downloadAndInstallFzf(url, target string) error { return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to download asset: %w", err) + } + + // Compute SHA256 + hash := sha256.Sum256(data) + actualChecksum := hex.EncodeToString(hash[:]) + + // Verify checksum + if actualChecksum != expectedChecksum { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actualChecksum) + } + binaryName := filepath.Base(target) + // Extract the binary from the verified archive + reader := bytes.NewReader(data) if strings.HasSuffix(url, ".zip") { - return extractZipFzf(resp.Body, target, binaryName) + return extractZipFzf(reader, target, binaryName) } - return extractTarGzFzf(resp.Body, target) + return extractTarGzFzf(reader, target) } // extractTarGzFzf extracts the first regular file named "fzf" from a gzip-compressed tar stream and writes it to target as an executable. @@ -1385,21 +1474,51 @@ func extractZipFzf(reader io.Reader, target, binaryName string) error { } // writeExecutable writes data from reader to target file atomically and makes the file executable. -// It creates a temporary file at target+".tmp" with mode 0755, writes the contents, closes it, -// and renames it into place. On write failures the temporary file is removed; any error is returned. +// It creates a unique temporary file in the target directory with mode 0755, writes the contents, +// closes it, and renames it into place. On any failure the temporary file is cleaned up; any error is returned. func writeExecutable(target string, reader io.Reader) error { - tmp := target + ".tmp" - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + targetDir := filepath.Dir(target) + targetBase := filepath.Base(target) + + // Create a unique temp file in the target directory + f, err := os.CreateTemp(targetDir, targetBase+".*.tmp") if err != nil { return err } + tmpPath := f.Name() + + // Ensure cleanup on error + defer func() { + if tmpPath != "" { + _ = os.Remove(tmpPath) + } + }() + + // Set executable permissions + if err := f.Chmod(0o755); err != nil { + f.Close() + return err + } + + // Write data if _, err := io.Copy(f, reader); err != nil { f.Close() - _ = os.Remove(tmp) return err } - f.Close() - return os.Rename(tmp, target) + + // Close and check for errors + if err := f.Close(); err != nil { + return err + } + + // Atomically rename to target + if err := os.Rename(tmpPath, target); err != nil { + return err + } + + // Success - prevent cleanup of temp file + tmpPath = "" + return nil } // buildFzfBootstrapCandidatesGo builds an ordered list of package managers suitable for bootstrapping fzf. @@ -1437,14 +1556,14 @@ func buildFzfBootstrapCandidatesGo(managers []string) []string { // the ordered candidate list derived from the provided managers, and if that // fails falls back to downloading and installing a release into the local // fzf directory. If fzf is present but older than the minimum required -// version, it will attempt the same upgrade steps; if the upgrade cannot be -// performed the function emits a warning but returns success. +// version, it will attempt the same upgrade steps. // // The managers parameter supplies preferred package-manager names (order is // respected) to use when attempting bootstrap or upgrade operations. // -// Returns an error only when fzf cannot be installed (package-manager attempts -// and release fallback both fail); otherwise returns nil. +// Returns an error when fzf cannot be installed (package-manager attempts +// and release fallback both fail), or when an existing fzf installation is +// outdated and cannot be upgraded to the minimum required version. func ensureFzfGo(managers []string) error { if !fzfCommandAvailableGo() { candidates := buildFzfBootstrapCandidatesGo(managers) @@ -1477,14 +1596,15 @@ func ensureFzfGo(managers []string) error { } if !upgraded { fmt.Fprintln(os.Stderr, "Package manager did not provide a recent fzf. Downloading latest release...") - if installFzfFromReleaseFallbackGo() && localFzfBinaryPath() != "" { + if installFzfFromReleaseFallbackGo() && localFzfBinaryPath() != "" && checkFzfVersionMin(fzfMinVersionChangeReload) { upgraded = true } } if !upgraded { - fmt.Fprintf(os.Stderr, "Warning: fzf < %s has a known race condition with live search.\n", fzfMinVersionChangeReload) - fmt.Fprintln(os.Stderr, "Live updates may be unreliable. Please upgrade fzf manually.") + fmt.Fprintf(os.Stderr, "Error: fzf < %s has a known race condition with live search.\n", fzfMinVersionChangeReload) + fmt.Fprintln(os.Stderr, "Live updates cannot be enabled. Please upgrade fzf manually.") + return fmt.Errorf("fzf version %s or higher is required", fzfMinVersionChangeReload) } } return nil -} +} \ No newline at end of file