A generic zip archive fetcher: race several download hosts, verify the archive's SHA-256 before extraction, and land the extracted tree in one atomic rename.
fetch is a Satellite module of wand, whose launcher uses it to download managed browsers. It knows nothing about browsers: no URL templates, platform names or revisions. Those belong to the caller.
import "github.com/headlesslab/fetch"
err := fetch.Zip(ctx, "/home/me/.cache/app/tool-1.2.3", []string{
"https://origin.example.com/tool/1.2.3/tool-linux64.zip",
"https://mirror.example.net/tool/1.2.3/tool-linux64.zip",
},
fetch.WithSHA256("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"),
fetch.WithStripFirstDir(), // the archive nests everything in one top-level folder
fetch.WithLogger(log.Default()),
)fetch.Zip(ctx, dir, urls, opts...) fetches one archive into the directory dir:
- Every URL is probed concurrently; the first host to answer with the first bytes of the archive wins. The others stay in reserve, in the order they answered: when a download fails, or its hash does not match, the next responder is tried. When no URL yields a verified archive, the error names every attempt.
- The archive is hashed while it downloads and compared with
WithSHA256before it is opened. A mismatch fails with a*fetch.HashMismatchErrornaming the expected and the actual digest. WithoutWithSHA256the archive is extracted unverified, and the logger says so. - A file lock (
<dir>.lock, viagofrs/flock) is held around download and extraction, so concurrent fetchers of the same directory, in one process or across processes, serialize; the ones that wait find the directory in place and return without downloading. The lock file stays on disk: removing it would race other waiters. - Extraction goes into a temporary sibling directory that is renamed to
dirat the end, sodireither does not exist or is complete. Any failure removes the temporary directory. Entries that would escape the destination (absolute paths,.., a symbolic link whose target leads outside once every earlier link is followed, an entry placed under a symbolic link) fail the fetch. File modes and symbolic links are preserved; on Windows, recreating a symbolic link needs a privilege or Developer Mode, so an archive with one fails there. WithStripFirstDirexpects exactly one top-level folder in the archive and makes that folder's contentdir, as browser archives nest one folder. Anything else at the top level fails the fetch rather than being dropped.- When
diralready exists,Zipreturns without a request. Removedirto fetch again. - The context cancels probing, download and extraction.
WithClientsupplies the*http.Client(defaulthttp.DefaultClient);WithLoggerreceives one line per event: the host chosen, progress, verification, extraction.
Zip is the only archive format. Requires Go 1.21 or later.
The suite runs against ephemeral HTTP servers and needs no network:
go test -race ./...CI runs through the reusable workflow in headlesslab/.github on Linux (Go floor and stable), Windows and macOS, with golangci-lint and govulncheck.
MIT.