Documentation
¶
Overview ¶
download.go is the fetch-and-verify layer of package release: the security-sensitive step that turns a download URL (from resolve.go's LatestRelease) into a trusted, on-disk stint binary. The package doc proper lives in version.go.
Everything here is built around one rule: we will later *execute* what this code writes to disk, so nothing reaches destDir until its bytes have been proved to match the published checksum, and no archive entry is allowed to write outside destDir. The flow, top to bottom:
DownloadAndExtract is the orchestrator. It streams the archive to a temp file (downloadToTemp), fetches and parses the expected sha256 for our asset (fetchExpectedChecksum), recomputes the sha256 of what we actually received, and refuses with an error mentioning "checksum" on any mismatch — writing NO binary into destDir on that path. Only on a match does it extract the single stint binary (extractBinary).
downloadToTemp does the bounded streaming GET into a temp file we own, hashing the bytes as they stream so the hash and the file cannot disagree about what was downloaded.
fetchExpectedChecksum does the bounded GET of checksums.txt and hands the body to parseChecksumsForAsset, which finds the one line for our asset basename. A missing or duplicate line is an explicit error: we will not guess which hash to trust.
extractBinary branches on the asset suffix (.tar.gz vs .zip), and the two archive readers share assertSafeArchiveEntry, the single path-traversal guard — an entry whose name is absolute or contains a ".." component is rejected before a single byte of it is written.
Why hash-while-streaming rather than read-back-and-hash: it pairs the bytes we persist with the bytes we verify on one pass (CLAUDE.md "pair assertions" across two paths is satisfied by the later size re-check), and it avoids a second full read of a 100 MiB file. The temp file is created in destDir's own tree's temp location and removed on every return path so a failed or mismatched download leaves nothing behind.
execgate.go is the verify-by-running gate of package release: the last safety check before a downloaded binary is swapped on top of the running executable (see swap.go). The package doc proper lives in version.go.
Why run the candidate and read back its own version, rather than trust the release tag we downloaded for? Because everything upstream of this point is about *bytes* — we resolved a tag, fetched an asset for it, and checked its sha256 against checksums.txt. None of that proves the asset, once executed, reports the version we meant to install. A checksums.txt that matches a mislabeled asset, a GoReleaser misconfiguration that stamps the wrong -ldflags Version, or a mirror that quietly serves the wrong file would all sail past the download/verify layer. The cheapest, most direct proof that "the thing we are about to make the user's `stint` is the version we intended" is to execute it and ask. If executing it disagrees with the tag, we refuse the swap. This is defense in depth: the checksum proves integrity (the bytes are what the publisher signed), this gate proves identity (the bytes, when run, are the version we asked for).
The exec is bounded by a hard timeout via exec.CommandContext: a candidate binary is, by definition, not yet trusted, and a `version` invocation that hangs (a corrupt binary, a binary that blocks on stdin, a wedged child) must not wedge the upgrade. A bounded subprocess keeps control flow ours — the CLAUDE.md "put a limit on everything" rule applied to a process we did not write.
What this gate deliberately does NOT do: it does not compare versions with release.Compare or care about ordering — it demands exact string equality against the tag the caller resolved. "Newer is fine" is a download-layer policy decision; by the time bytes are on disk and about to be swapped, the only safe answer is "this is exactly the version we committed to", so the check is ==, not >=.
resolve.go is the release-discovery layer of package release: it asks the GitHub API "what is the latest published stint release, and where do its download URLs live?" and it constructs the asset filename for a given platform. The package doc proper lives in version.go.
Two functions, two distinct jobs:
LatestRelease talks to the network. It GETs the GitHub "latest release" endpoint, reads a *bounded* slice of the body, and decodes just the two fields we need (the tag and the per-asset download URLs). Every failure the network or the server can hand us — a dialed-but-refused connection, a 404 (repo or release missing), a 500 (GitHub having a bad day), a truncated or garbage JSON body — is returned as an explicit error. None is swallowed, because a self-updater that silently decides "no update" on a transient 500 is worse than one that says so.
AssetName is pure string arithmetic: it reproduces, byte-for-byte, the archive name GoReleaser writes (see .goreleaser.yaml archives.name_template and install.sh:62). It must stay in lockstep with those two, because the URL LatestRelease hands back is keyed by exactly this name.
Why a package-level apiBaseURL var instead of a parameter: the real host is a security-relevant constant — we will download and (in a later task) execute what this endpoint points us at, so the host must NOT be derivable from untrusted runtime input. The var defaults to the canonical https host and is overridden ONLY by the in-package test, which points it at an httptest server. It is unexported precisely so no caller outside this package (and no CLI flag) can redirect the updater to an attacker's host.
swap.go is the binary-swap layer of package release: the security-sensitive final step of a self-update where the bytes we have downloaded, verified, and (in a sibling task) test-run are moved on top of the running executable. The package doc proper lives in version.go.
The work is split deliberately across three files so the dangerous part is small and the testable part is large:
swap.go (this file, all platforms) holds the *preflight* — CanReplace — plus the pure path predicates it leans on (absolute-path checks, the package-manager detection, the target-directory writability probe). None of it moves a byte; all of it is exercisable on any host, which is why the brew/scoop logic and the writability probe live here rather than in a build-tagged file. A swap is refused here, loudly and early, before any platform code touches the filesystem.
swap_unix.go (//go:build !windows) holds the unix swap body: it writes the new bytes to a temp file *inside the target's own directory* and os.Renames it over the target. Same directory means same filesystem, which is what makes the rename atomic — there is no cross-device copy on the happy path, so an interrupted swap leaves either the whole old binary or the whole new one, never a half-written executable.
swap_windows.go (//go:build windows) holds the windows swap body: Windows refuses to rename *over* a running .exe, so it renames the in-use target out of the way to <target>.old first, then moves the new file into place. That file is not compiled on this darwin host; it is kept self-consistent and commented for the eventual windows build and review.
Why a typed/sentinel refusal for package-manager installs (ErrManagedInstall): a stint installed by Homebrew or Scoop lives under that manager's control — its files, its symlinks, its upgrade bookkeeping. Silently overwriting the binary there would desynchronize the manager's metadata from the bytes on disk and break the *next* `brew upgrade`. So we detect those layouts and refuse with an actionable message ("run `brew upgrade stint`"), and we make the refusal a typed error so a caller (the upgrade command) can recognize it and print the redirect rather than a generic failure. The detection is pure string inspection of the *resolved* path, which is exactly why it sits in this host-testable file.
swap_unix.go is the non-windows half of the binary swap (see swap.go for the shared preflight and the three-file rationale). On unix-likes — Linux, macOS, the BSDs — a running executable can be replaced *underneath* a live process: the kernel keeps the open inode alive until the last reference closes, so an os.Rename onto the path the running stint was launched from simply repoints the directory entry while the in-flight process keeps running the old inode.
The single safety property this body buys is atomicity. We write the new bytes to a temp file *in the target's own directory* and then os.Rename that temp over the target. Same directory ⇒ same filesystem ⇒ rename(2) is atomic and never a cross-device copy: a crash mid-swap leaves the directory entry pointing at either the complete old binary or the complete new one, never a truncated executable a user could then try to run. Writing to /tmp and copying across would forfeit exactly this guarantee, so we never do it.
url.go is the origin-pin layer of package release: the single place that asserts a resolved download URL is one we are willing to fetch from. The package doc proper lives in version.go.
Why this exists, and why it is not folded into the low-level fetcher (download.go's getBounded): the bytes a self-updater downloads are bytes it will later EXECUTE, so the origin of those bytes is a load-bearing trust control — the sha256 gate cannot substitute for it, because the checksums file is fetched from the SAME origin as the asset (an origin that controls the asset controls its checksums.txt too). The GitHub release-asset URLs we follow are always https and rooted at github.com (the browser_download_url the API hands back). We pin BOTH at URL-selection time — where a URL is first chosen from the GitHub API response — and deliberately leave getBounded scheme/host-agnostic so the in-package download_test.go httptest seam (which serves over http://127.0.0.1) is not broken by a blanket check at the wire.
Redirects are a separate concern handled by NewDownloadHTTPClient's CheckRedirect: GitHub legitimately 302s an asset download to https://objects.githubusercontent.com, so we MUST allow an https→https redirect to a different host, but we forbid any redirect that downgrades the scheme to plaintext http. We do NOT host-pin the redirect target (it is a CDN host, not github.com); the only redirect invariant is "no scheme downgrade".
Package release holds the self-update machinery for the stint binary: discovering the latest GitHub release, downloading the right asset, verifying its sha256, and (in later tasks) swapping the running executable. This file is the version layer — the small, pure, dependency-free arithmetic the rest of the package leans on to answer "is this string a real release?" and "is release A newer than B?".
Scope of the version model (deliberately narrow):
- A release tag is exactly "vMAJOR.MINOR.PATCH" where each field is a run of ASCII digits. A leading 'v' is canonical but tolerated as optional on either side of a Compare so callers can hand us either a git tag ("v0.2.0") or a bare runtime version ("0.2.0").
- We do NOT model semver pre-release/build metadata ordering. The binaries we update to come from GoReleaser, which publishes clean vMAJOR.MINOR.PATCH tags; anything carrying a "-..." suffix (a git-describe string like "v0.1.0-3-gabc1234", a "-dirty" working tree, or the literal "dev") is by definition not a published release and is reported as such by IsRelease rather than ordered.
Why hand-rolled instead of golang.org/x/mod/semver: see the decision note on the task. In short — CLAUDE.md says minimize dependencies, our domain is clean three-field tags with no pre-release ordering, and a three-integer compare is trivially auditable and assertable. If real pre-release precedence ever becomes a requirement, revisit x/mod then.
Index ¶
- Variables
- func AssetName(version string, goos string, goarch string) string
- func CanReplace(targetPath string) error
- func Compare(a string, b string) int
- func DownloadAndExtract(ctx context.Context, client *http.Client, assetURL string, checksumsURL string, ...) (string, error)
- func IsRelease(v string) bool
- func LatestRelease(ctx context.Context, client *http.Client) (string, map[string]string, error)
- func NewDownloadHTTPClient(overallTimeout time.Duration) *http.Client
- func SwapBinary(targetPath string, newBinaryPath string) error
- func ValidateDownloadURL(rawURL string) error
- func VerifyBinaryVersion(binPath string, wantTag string) error
Constants ¶
This section is empty.
Variables ¶
var ErrInsecureDownloadURL = errors.New("release: download URL is not an https GitHub URL")
ErrInsecureDownloadURL is returned by ValidateDownloadURL when a resolved asset or checksums URL is not an https URL rooted at the canonical GitHub host. It is a typed sentinel so a caller (and a test) can match the refusal with errors.Is rather than string-matching the message.
var ErrManagedInstall = errors.New("release: target looks package-manager-managed; upgrade via the package manager")
ErrManagedInstall is the sentinel CanReplace returns (wrapped, with an actionable message) when the resolved target path looks like it belongs to a package manager — a Homebrew Cellar/homebrew layout or a Scoop shims layout. Callers test for it with errors.Is to print the "use your package manager" redirect instead of a generic swap failure. It is a refusal, not a bug: the install is fine, it just is not ours to overwrite.
Functions ¶
func AssetName ¶
AssetName builds the release archive filename for a given version and target platform, reproducing exactly what GoReleaser writes (.goreleaser.yaml archives.name_template) and what install.sh:62 reconstructs:
stint_<version-without-leading-v>_<goos>_<goarch>.tar.gz
with a ".zip" extension instead of ".tar.gz" when goos is "windows" (the goreleaser format_overrides for windows). The leading 'v' is stripped from the version because GoReleaser's {{ .Version }} is the bare number; passing either "v0.1.0" or "0.1.0" yields the same name.
All three arguments are required and asserted non-empty: an empty token here would build a malformed name like "stint__amd64.tar.gz" that no release ever published, which is a caller bug (a failed runtime.GOOS lookup, an unresolved version) worth crashing on rather than papering over.
func CanReplace ¶
CanReplace is the preflight gate run *before* any bytes move: it answers "is it safe and possible for this process to overwrite targetPath?" without touching the target file itself. It resolves the path's symlinks (a stint on $PATH is very often a symlink into a versioned dir, e.g. Homebrew's Cellar), asserts the result is absolute, refuses package-manager-managed layouts with ErrManagedInstall, and finally probes that the target's *directory* is writable — because the unix swap renames a sibling temp file over the target, so directory write permission, not target-file permission, is what governs.
targetPath MUST be a non-empty absolute path; an empty or relative target is a wiring bug at the call site (a failed os.Executable, an unresolved flag), so we crash rather than return an error the caller might mistake for "not writable". Every *operating* failure — a broken symlink, a managed install, a read-only directory — is returned as an explicit error.
func Compare ¶
Compare reports whether release version a is older than (<0), equal to (0), or newer than (>0) release version b, comparing MAJOR then MINOR then PATCH numerically. A leading 'v' is optional on either argument.
Both arguments MUST be parseable clean release tags (optionally v-prefixed); a malformed or non-release input is a programmer error here, not an operating error — callers gate with IsRelease first when the input is untrusted. Passing "dev" or a git-describe string is a bug at the call site, so we crash rather than silently ordering it.
func DownloadAndExtract ¶
func DownloadAndExtract( ctx context.Context, client *http.Client, assetURL string, checksumsURL string, destDir string, ) (string, error)
DownloadAndExtract fetches the release archive at assetURL, verifies its sha256 against the entry for the asset's basename in the checksums.txt at checksumsURL, and on a match extracts the single stint binary into destDir, returning that binary's absolute path. On any failure the returned path is "" and no binary is left in destDir.
All four string arguments are required and asserted; destDir MUST be an absolute path (the caller resolves it before reaching here) so an extracted path cannot depend on the process's working directory. A nil ctx or client is a wiring bug and crashes; every network, filesystem, checksum, and archive failure is an operating error and is returned, named.
Order matters for safety: we download to a temp file we own and verify the checksum *before* extractBinary writes anything into destDir, so a mismatched download never deposits an executable a later step might run.
func IsRelease ¶
IsRelease reports whether v is a clean, published release tag: exactly "vMAJOR.MINOR.PATCH" (the leading 'v' optional) with no pre-release or build suffix. It is false for "dev", for git-describe strings such as "v0.1.0-3-gabc1234", and for any "-dirty" working-tree version. This is the gate callers use before trusting a version enough to Compare it or to offer it as an upgrade target.
func LatestRelease ¶
LatestRelease fetches the latest published stint release from GitHub and returns its tag (e.g. "v0.2.0") and a map from asset filename to that asset's browser download URL.
The supplied ctx bounds the caller's overall deadline; LatestRelease layers its own latestReleaseTimeout on top so a caller that passes context.Background still gets a bounded request. The supplied client carries any caller policy (proxy, custom transport); a nil client is a programmer error, asserted below, because the call chain always has one to hand.
Every error path is explicit and named: a failure to build or send the request, a non-200 status (the status text is in the error), an over-limit body, or malformed JSON. On any error the returned tag is "" and the map is nil, so a caller that ignores the error cannot mistake a failure for "no assets".
func NewDownloadHTTPClient ¶
NewDownloadHTTPClient builds the *http.Client the binary self-update uses to fetch the asset and checksums. It carries the supplied overall timeout and a CheckRedirect that refuses any redirect which downgrades the scheme to a non-https URL — GitHub legitimately redirects an asset download to the https CDN host, so an https→https hop is allowed, but an https→http downgrade is refused so a redirect can never strip the transport authentication the origin pin relies on. The redirect target host is intentionally NOT pinned (it is a CDN host, not github.com).
Pre: overallTimeout is positive (asserted). Post: the returned client is non-nil, carries overallTimeout, and rejects a plaintext or over-limit redirect.
func SwapBinary ¶
SwapBinary atomically replaces the executable at targetPath with the bytes of the file at newBinaryPath, leaving the target a regular file in mode swappedBinaryMode (0755). It is the unix implementation; the windows build supplies its own SwapBinary with the same signature and contract.
Both arguments MUST be non-empty absolute paths: targetPath comes from os.Executable (already gated by CanReplace) and newBinaryPath from the download/verify step, so a relative or empty path here is a wiring bug and crashes. Every filesystem error — open, create, copy, sync, chmod, rename — is returned explicitly; a swap that fails must say *why*, because the caller is about to decide whether the user still has a working binary.
The caller is expected to have run CanReplace(targetPath) already; SwapBinary re-derives the target directory itself (it needs it for the temp file) but does not re-run the package-manager refusal — that is the preflight's job and running it twice would just duplicate the message.
func ValidateDownloadURL ¶
ValidateDownloadURL parses rawURL and confirms it is an https URL whose host is the canonical GitHub asset host. It is called at the trust boundary where a URL is selected from the GitHub API response (the CLI's pickPlatformAssetURLs), BEFORE any GET, so a tampered browser_download_url pointing at a plaintext or attacker-controlled origin is refused before a byte is fetched.
Pre: rawURL is the string a resolved asset/checksums URL carries (may be empty or malformed — both are operating errors, returned, not panicked). Post: a nil return means Scheme=="https" AND Host=="github.com"; any other shape returns an error wrapping ErrInsecureDownloadURL naming the offending scheme/host.
func VerifyBinaryVersion ¶
VerifyBinaryVersion runs `<binPath> version`, parses the version it reports, and returns nil only if that reported version equals wantTag exactly. It is the swap gate: a downloaded binary is never swapped in unless executing it reports precisely the version we intended to install.
binPath MUST be absolute (a relative path would resolve against an unpredictable working directory — a footgun for a security gate) and wantTag MUST be non-empty (an empty target means the caller failed to resolve a version upstream and is asking us to gate against nothing). Both are programmer errors at this call site, so they panic rather than return.
Every operating failure — a missing or non-executable binary, a non-zero exit, output we cannot parse, a version mismatch, or a timeout — returns an explicit wrapped error. A mismatch error quotes BOTH the wanted and the observed version so the caller can show the user exactly what disagreed.
Types ¶
This section is empty.