repo-sync/fetch/fetch.go
James Coleman 5af1dd8928
Some checks failed
Go package / build (push) Has been cancelled
first commit
2026-07-28 19:22:20 -05:00

798 lines
23 KiB
Go

// Package fetch downloads repository files over HTTP with mirror failover,
// verifying published sizes and checksums, staging updates so a live tree
// is never served a partially updated repository, and pruning files the
// upstream no longer lists.
package fetch
import (
"bufio"
"bytes"
"compress/bzip2"
"compress/gzip"
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"hash/fnv"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
cfg "github.com/grmrgecko/repo-sync/config"
"github.com/klauspost/compress/zstd"
log "github.com/sirupsen/logrus"
"github.com/ulikunitz/xz"
)
// client is the shared HTTP client used for all repository fetches. It is
// atomic because a SIGHUP reload swaps it while requests are in flight.
var client atomic.Pointer[http.Client]
func init() {
// Build a client immediately so the package is usable without an
// explicit Reload. The config package is initialized first, so its
// defaults are already installed.
Reload()
}
// Reload rebuilds the shared HTTP client from the active crawler
// configuration. A header timeout is used instead of a whole-request
// timeout so large package downloads are not cut short.
func Reload() {
old := client.Load()
client.Store(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
ResponseHeaderTimeout: cfg.C.Crawler.RequestTimeout,
},
})
// Release the replaced transport's idle pool; in-flight requests on it
// finish undisturbed.
if old != nil {
old.CloseIdleConnections()
}
}
// ErrNotFound marks an upstream file that does not exist, letting callers
// treat missing optional files differently from transport failures.
var ErrNotFound = errors.New("upstream file not found")
// ErrNotModified marks a conditional request the upstream answered with
// 304, meaning the local copy is already current.
var ErrNotModified = errors.New("upstream file not modified")
// StagedSuffix is appended to staged downloads until they are promoted.
const StagedSuffix = ".staged"
// PartialSuffix is appended to in-progress downloads. Partials of files
// with published checksums survive failures so the next run can resume.
const PartialSuffix = ".partial"
// LockFileName is the flock target guarding a destination directory.
const LockFileName = ".repo-sync.lock"
// PruneStateName is the prune grace bookkeeping file kept at the root of a
// pruned tree.
const PruneStateName = ".repo-sync-prune.json"
// DiscoverStateName is the discovery cache kept at the root of a
// destination tree, recording what each crawl found and when.
const DiscoverStateName = ".repo-sync-state.json"
// Expect describes the size and checksums a fetched file must match. A size
// of -1 means the size is unknown, and Sums maps lowercase algorithm names
// to hex digests.
type Expect struct {
Size int64
Sums map[string]string
}
// MakeExpect builds an Expect from a size and a single checksum, treating
// non-positive sizes and empty checksums as unknown.
func MakeExpect(size int64, algo, digest string) *Expect {
e := &Expect{Size: size, Sums: map[string]string{}}
if size <= 0 {
e.Size = -1
}
if algo != "" && digest != "" {
e.Sums[strings.ToLower(algo)] = strings.ToLower(digest)
}
return e
}
// hashOrder lists checksum algorithms from most to least preferred.
var hashOrder = []string{"sha512", "sha384", "sha256", "sha224", "sha1", "sha", "md5", "md5sum"}
// newHasher returns a hash implementation for a checksum algorithm name as
// found in repository metadata, or nil when the algorithm is unknown.
func newHasher(algo string) hash.Hash {
switch strings.ToLower(algo) {
case "md5", "md5sum":
return md5.New()
case "sha", "sha1":
return sha1.New()
case "sha224":
return sha256.New224()
case "sha256":
return sha256.New()
case "sha384":
return sha512.New384()
case "sha512":
return sha512.New()
}
return nil
}
// bestSum picks the strongest checksum available in the expectation.
func (e *Expect) bestSum() (string, string) {
for _, algo := range hashOrder {
if digest, ok := e.Sums[algo]; ok && digest != "" {
return algo, digest
}
}
return "", ""
}
// FileState reports what File did with a file. It is only meaningful when
// the accompanying error is nil.
type FileState int
const (
// FileMissing marks an optional file the upstream does not serve.
FileMissing FileState = iota
// FileUnchanged marks an existing local file that matched expectations.
FileUnchanged
// FileFetched marks a file downloaded into its final location.
FileFetched
// FileStaged marks a file downloaded next to its final location for a
// later promote.
FileStaged
)
// verifyLocal reports whether an existing regular file already matches the
// expected size and, when checkHash is set, the expected checksum.
func verifyLocal(name string, want *Expect, checkHash bool) bool {
info, err := os.Stat(name)
if err != nil || !info.Mode().IsRegular() {
return false
}
if want.Size >= 0 && info.Size() != want.Size {
return false
}
if !checkHash {
return true
}
algo, digest := want.bestSum()
if digest == "" {
return true
}
sum, err := hashFile(name, algo)
if err != nil {
return false
}
return sum == digest
}
// hashFile computes the named checksum of a file, returned as lowercase hex.
func hashFile(name, algo string) (string, error) {
h := newHasher(algo)
if h == nil {
return "", fmt.Errorf("unsupported checksum algorithm %q", algo)
}
f, err := os.Open(name)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// fileStripes bounds the destination lock table. Downloads to the same
// destination are serialized by stripe; a collision between unrelated
// destinations only delays a download briefly.
const fileStripes = 512
// fileLocks serializes downloads by destination path. The mirror server's
// on-demand fetches and background crawls can target the same file
// concurrently, and their partial downloads must not interleave.
var fileLocks [fileStripes]sync.Mutex
// lockFile holds the lock for a destination path and returns its unlock
// function.
func lockFile(dst string) func() {
h := fnv.New32a()
_, _ = io.WriteString(h, dst)
m := &fileLocks[h.Sum32()%fileStripes]
m.Lock()
return m.Unlock
}
// File downloads reqPath from the source into dst, verifying size and
// checksum when known. Existing files that already match are left alone.
// With stage set the download is written next to dst for a later promote so
// readers of a live tree never see a partially updated repository.
func File(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage, verifyExisting bool) (FileState, error) {
// Serialize writers to this destination so concurrent callers, such
// as the mirror server's on-demand fetches and background crawls,
// cannot interleave partial downloads of the same file.
unlock := lockFile(dst)
defer unlock()
// Clear any staged leftover from an interrupted run so a later promote
// can never publish stale content.
if stage {
_ = os.Remove(dst + StagedSuffix)
}
// Skip files that already match the expected content.
if want != nil && verifyLocal(dst, want, verifyExisting) {
recordUnchanged(ctx)
return FileUnchanged, nil
}
// Checksummed files with a known size can resume a failed download's
// partial file with a ranged request.
var resumeFrom int64
if want != nil && want.Size > 0 {
if _, digest := want.bestSum(); digest != "" {
if info, err := os.Stat(dst + PartialSuffix); err == nil && info.Size() > 0 && info.Size() < want.Size {
resumeFrom = info.Size()
}
}
}
for {
state, err := download(ctx, src, reqPath, dst, want, stage, resumeFrom)
if err != nil && resumeFrom > 0 && !errors.Is(err, ErrNotFound) && ctx.Err() == nil {
// The partial may not be a prefix of the current upstream file;
// retry once from scratch.
log.WithError(err).WithField("path", reqPath).Debug("Resumed download failed; retrying in full.")
_ = os.Remove(dst + PartialSuffix)
resumeFrom = 0
continue
}
return state, err
}
}
// download performs one transfer attempt for File, appending to the
// partial file when resuming from a prior failure.
func download(ctx context.Context, src *Source, reqPath, dst string, want *Expect, stage bool, resumeFrom int64) (FileState, error) {
// Files without published checksums cannot be verified locally, so an
// existing copy is revalidated with a conditional request instead of
// being re-downloaded every run.
var modifiedSince time.Time
if want == nil {
if info, err := os.Stat(dst); err == nil && info.Mode().IsRegular() {
modifiedSince = info.ModTime()
}
}
// Request the file, failing over between mirrors as needed.
resp, err := src.Get(ctx, reqPath, GetOptions{ModifiedSince: modifiedSince, RangeFrom: resumeFrom})
if errors.Is(err, ErrNotModified) {
recordUnchanged(ctx)
log.WithField("path", reqPath).Debug("Kept unchanged repository file.")
return FileUnchanged, nil
}
if err != nil {
return FileMissing, err
}
defer resp.Body.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return FileMissing, err
}
var hasher hash.Hash
var algo, digest string
if want != nil {
algo, digest = want.bestSum()
if digest != "" {
hasher = newHasher(algo)
}
}
// Partials are only kept for files whose content the next attempt can
// verify by checksum.
keepPartial := digest != "" && want.Size > 0
// A 206 continues the partial file, whose existing bytes must feed the
// hash first; any other success replaces it from the start.
partial := dst + PartialSuffix
resumed := resumeFrom > 0 && resp.StatusCode == http.StatusPartialContent
openFlags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC
if resumed {
openFlags = os.O_CREATE | os.O_WRONLY | os.O_APPEND
if hasher != nil {
pf, err := os.Open(partial)
if err != nil {
return FileMissing, err
}
_, err = io.Copy(hasher, pf)
pf.Close()
if err != nil {
return FileMissing, err
}
}
log.WithFields(log.Fields{"path": reqPath, "offset": resumeFrom}).Debug("Resuming partial download.")
}
// Stream the body into the partial file so a crash never leaves an
// incomplete file at the final location.
f, err := os.OpenFile(partial, openFlags, 0644)
if err != nil {
return FileMissing, err
}
w := io.Writer(f)
if hasher != nil {
w = io.MultiWriter(f, hasher)
}
written, copyErr := io.Copy(w, resp.Body)
closeErr := f.Close()
if copyErr != nil {
if !keepPartial {
_ = os.Remove(partial)
}
return FileMissing, fmt.Errorf("download %s: %w", reqPath, copyErr)
}
if closeErr != nil {
if !keepPartial {
_ = os.Remove(partial)
}
return FileMissing, closeErr
}
// Verify the transfer against the published size and checksum. A
// mismatch invalidates the partial file entirely.
total := written
if resumed {
total += resumeFrom
}
if want != nil && want.Size >= 0 && total != want.Size {
_ = os.Remove(partial)
return FileMissing, fmt.Errorf("download %s: size %d does not match expected %d", reqPath, total, want.Size)
}
if hasher != nil {
if sum := hex.EncodeToString(hasher.Sum(nil)); sum != digest {
_ = os.Remove(partial)
return FileMissing, fmt.Errorf("download %s: %s checksum %s does not match expected %s", reqPath, algo, sum, digest)
}
}
// Move the download into place, or next to it when staging.
final := dst
state := FileFetched
if stage {
final = dst + StagedSuffix
state = FileStaged
}
if err := os.Rename(partial, final); err != nil {
_ = os.Remove(partial)
return FileMissing, err
}
// Carry the upstream modification time so later runs can revalidate
// with conditional requests. Promotion preserves it.
if lm, err := http.ParseTime(resp.Header.Get("Last-Modified")); err == nil {
_ = os.Chtimes(final, time.Now(), lm)
}
recordFetched(ctx, written)
log.WithField("path", reqPath).Debug("Fetched repository file.")
return state, nil
}
// PlanJobs records what a real run would download without transferring
// anything, still marking every destination as part of the repository so a
// dry-run prune report stays accurate. Optional files the upstream may not
// serve are counted, so the result is an upper bound.
func PlanJobs(jobs []Job, verifyExisting bool, keep *KeepSet) {
for _, job := range jobs {
keep.Add(job.Dst)
if job.Want != nil && verifyLocal(job.Dst, job.Want, verifyExisting) {
Stats.Unchanged.Add(1)
continue
}
Stats.Planned.Add(1)
if job.Want != nil && job.Want.Size > 0 {
Stats.PlannedBytes.Add(job.Want.Size)
}
log.WithField("path", job.ReqPath).Debug("Would fetch repository file.")
}
}
// RemoveStale deletes a local file the upstream no longer serves, so an
// outdated copy is never served beside refreshed metadata. Missing files
// are not an error.
func RemoveStale(name string) {
if err := os.Remove(name); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.WithError(err).WithField("path", name).Warn("Failed to remove stale file.")
}
}
// StagedOrFinal returns the staged copy of dst when one exists, falling
// back to the promoted file for runs where the upstream was unchanged.
func StagedOrFinal(dst string) string {
staged := dst + StagedSuffix
if _, err := os.Stat(staged); err == nil {
return staged
}
return dst
}
// PromoteStaged moves a staged download into its final location. Missing
// staged files are not an error, as unchanged files are never staged.
func PromoteStaged(dst string) error {
staged := dst + StagedSuffix
if _, err := os.Stat(staged); errors.Is(err, fs.ErrNotExist) {
return nil
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
return os.Rename(staged, dst)
}
// PromoteOrDiscard finishes a staged file: promoted normally, or removed in
// a dry run so no published repository state changes.
func PromoteOrDiscard(dst string, dryRun bool) error {
if dryRun {
_ = os.Remove(dst + StagedSuffix)
return nil
}
return PromoteStaged(dst)
}
// Job describes one file for Many to download.
type Job struct {
ReqPath string
Dst string
Want *Expect
Optional bool
Stage bool
}
// Many downloads jobs with a fixed worker pool, recording each job's
// resulting state. The first hard failure aborts the remaining work. Files
// the upstream does not serve abort it too, unless the job is optional or
// miss tolerates the absence.
func Many(ctx context.Context, src *Source, jobs []Job, workers int, verifyExisting bool, keep *KeepSet, miss *Missing) ([]FileState, error) {
states := make([]FileState, len(jobs))
indexes := make(chan int)
errs := make(chan error, 1)
var wg sync.WaitGroup
if workers < 1 {
workers = 1
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for idx := range indexes {
job := jobs[idx]
state, err := File(ctx, src, job.ReqPath, job.Dst, job.Want, job.Stage, verifyExisting)
if errors.Is(err, ErrNotFound) {
if job.Optional {
log.WithField("path", job.ReqPath).Debug("Skipped file the upstream does not serve.")
continue
}
// A file the metadata lists is expected to exist, so its
// absence is counted and reported however it is handled.
recordMissing(ctx)
if miss.Tolerate(job.ReqPath) {
continue
}
}
if err != nil {
select {
case errs <- err:
default:
}
return
}
states[idx] = state
keep.Add(job.Dst)
}
}()
}
for idx := range jobs {
select {
case err := <-errs:
close(indexes)
wg.Wait()
return states, err
case <-ctx.Done():
close(indexes)
wg.Wait()
return states, ctx.Err()
case indexes <- idx:
}
}
close(indexes)
wg.Wait()
select {
case err := <-errs:
return states, err
default:
return states, nil
}
}
// KeepSet tracks the files that belong to the current sync so pruning can
// remove everything else. A nil KeepSet ignores all operations.
type KeepSet struct {
mu sync.Mutex
paths map[string]bool
}
// NewKeepSet returns a tracking set when pruning is enabled, or nil so all
// tracking becomes a no-op.
func NewKeepSet(enabled bool) *KeepSet {
if !enabled {
return nil
}
return &KeepSet{paths: map[string]bool{}}
}
// Add records a local file as part of the repository.
func (k *KeepSet) Add(name string) {
if k == nil {
return
}
abs, err := filepath.Abs(name)
if err != nil {
return
}
k.mu.Lock()
k.paths[abs] = true
k.mu.Unlock()
}
// has reports whether a local file is part of the repository.
func (k *KeepSet) has(name string) bool {
if k == nil {
return false
}
abs, err := filepath.Abs(name)
if err != nil {
return false
}
k.mu.Lock()
defer k.mu.Unlock()
return k.paths[abs]
}
// loadPruneState reads the grace bookkeeping for a pruned tree, mapping
// root-relative paths to when they were first seen unreferenced.
func loadPruneState(root string) map[string]time.Time {
state := map[string]time.Time{}
data, err := os.ReadFile(filepath.Join(root, PruneStateName))
if err != nil {
return state
}
if err := json.Unmarshal(data, &state); err != nil {
log.WithError(err).WithField("path", root).Warn("Failed to parse prune state; starting fresh.")
return map[string]time.Time{}
}
return state
}
// savePruneState persists the grace bookkeeping, or removes it when no
// files are waiting out their grace period.
func savePruneState(root string, state map[string]time.Time) {
name := filepath.Join(root, PruneStateName)
if len(state) == 0 {
_ = os.Remove(name)
return
}
data, err := json.Marshal(state)
if err == nil {
err = os.WriteFile(name, data, 0644)
}
if err != nil {
log.WithError(err).WithField("path", name).Warn("Failed to save prune state.")
}
}
// PruneTree removes files under root that are not part of the current sync,
// then clears out any directories left empty. A grace period defers
// deletion until a file has been unreferenced across runs for that long,
// so clients holding earlier metadata keep working. Failures are logged
// and do not fail the sync.
func PruneTree(root string, keep *KeepSet, grace time.Duration, dryRun bool) {
if keep == nil {
return
}
var state map[string]time.Time
if grace > 0 {
state = loadPruneState(root)
}
now := time.Now()
var dirs []string
err := filepath.WalkDir(root, func(name string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if name != root {
dirs = append(dirs, name)
}
return nil
}
base := filepath.Base(name)
if base == PruneStateName || base == MissingStateName ||
base == DiscoverStateName || base == LockFileName {
return nil
}
rel, relErr := filepath.Rel(root, name)
if relErr != nil {
return nil
}
if keep.has(name) {
delete(state, rel)
return nil
}
// Internal work files are removed regardless of grace; anything
// else waits out the grace period across runs first.
internal := strings.HasSuffix(base, StagedSuffix) || strings.HasSuffix(base, PartialSuffix)
if grace > 0 && !internal {
first, ok := state[rel]
if !ok {
// A dry run records nothing, so report the pending grace
// entry instead of staying silent about the file.
if dryRun {
log.WithField("path", name).Info("Stale file would enter the prune grace period.")
return nil
}
state[rel] = now
log.WithField("path", name).Debug("Stale file entered the prune grace period.")
return nil
}
if now.Sub(first) < grace {
return nil
}
}
if dryRun {
Stats.Pruned.Add(1)
log.WithField("path", name).Info("Would prune stale file.")
return nil
}
if err := os.Remove(name); err != nil {
log.WithError(err).WithField("path", name).Warn("Failed to prune stale file.")
return nil
}
delete(state, rel)
Stats.Pruned.Add(1)
log.WithField("path", name).Debug("Pruned stale file.")
return nil
})
if err != nil {
log.WithError(err).WithField("path", root).Warn("Failed to walk repository for pruning.")
}
// Drop bookkeeping for files that no longer exist, then persist it.
if grace > 0 && !dryRun {
for rel := range state {
if _, err := os.Stat(filepath.Join(root, rel)); errors.Is(err, fs.ErrNotExist) {
delete(state, rel)
}
}
savePruneState(root, state)
}
if dryRun {
return
}
// Remove now-empty directories deepest first. Separator count is the
// true depth, so a parent is always removed after its children even
// when a sibling's child has a longer name.
sep := string(os.PathSeparator)
sort.Slice(dirs, func(i, j int) bool {
return strings.Count(dirs[i], sep) > strings.Count(dirs[j], sep)
})
for _, dir := range dirs {
_ = os.Remove(dir)
}
}
// Decompressor wraps a reader with the decompressor matching the file name
// extension, returning an optional close function for the wrapper. Staged
// suffixes are ignored so staged indexes can be parsed before promotion.
func Decompressor(r io.Reader, filename string) (io.Reader, func(), error) {
filename = strings.TrimSuffix(filename, StagedSuffix)
switch {
case strings.HasSuffix(filename, ".gz"):
gz, err := gzip.NewReader(r)
if err != nil {
return nil, nil, err
}
return gz, func() { _ = gz.Close() }, nil
case strings.HasSuffix(filename, ".bz2"):
return bzip2.NewReader(r), nil, nil
case strings.HasSuffix(filename, ".xz"):
xr, err := xz.NewReader(r)
if err != nil {
return nil, nil, err
}
return xr, nil, nil
case strings.HasSuffix(filename, ".zst"):
zr, err := zstd.NewReader(r)
if err != nil {
return nil, nil, err
}
return zr, zr.Close, nil
default:
return r, nil, nil
}
}
// SniffDecompressor wraps a reader with the decompressor matching its
// leading magic bytes, for formats whose file names carry no extension.
func SniffDecompressor(r io.Reader) (io.Reader, func(), error) {
br := bufio.NewReader(r)
magic, err := br.Peek(6)
if err != nil && !errors.Is(err, io.EOF) {
return nil, nil, err
}
switch {
case len(magic) >= 2 && magic[0] == 0x1f && magic[1] == 0x8b:
gz, err := gzip.NewReader(br)
if err != nil {
return nil, nil, err
}
return gz, func() { _ = gz.Close() }, nil
case len(magic) >= 3 && magic[0] == 'B' && magic[1] == 'Z' && magic[2] == 'h':
return bzip2.NewReader(br), nil, nil
case len(magic) >= 6 && bytes.Equal(magic[:6], []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}):
xr, err := xz.NewReader(br)
if err != nil {
return nil, nil, err
}
return xr, nil, nil
case len(magic) >= 4 && bytes.Equal(magic[:4], []byte{0x28, 0xb5, 0x2f, 0xfd}):
zr, err := zstd.NewReader(br)
if err != nil {
return nil, nil, err
}
return zr, zr.Close, nil
default:
return br, nil, nil
}
}
// LocalJoin resolves a repository-relative path under root, rejecting paths
// that would escape the destination tree.
func LocalJoin(root, rel string) (string, error) {
clean := path.Clean("/" + strings.ReplaceAll(rel, "\\", "/"))
dst := filepath.Join(root, filepath.FromSlash(clean))
rootClean := filepath.Clean(root)
prefix := rootClean + string(os.PathSeparator)
if rootClean == string(os.PathSeparator) {
prefix = rootClean
}
if dst != rootClean && !strings.HasPrefix(dst, prefix) {
return "", fmt.Errorf("path %q escapes destination %q", rel, root)
}
return dst, nil
}