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

542 lines
15 KiB
Go

package mirror
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/grmrgecko/repo-sync/fetch"
log "github.com/sirupsen/logrus"
)
// debFile is one file referenced by repository metadata with its expected
// size and checksums, keyed by lowercase algorithm name.
type debFile struct {
path string
size int64
sums map[string]string
}
// expect converts the file's metadata into a download expectation.
func (f *debFile) expect() *fetch.Expect {
e := &fetch.Expect{Size: f.size, Sums: f.sums}
if f.size <= 0 {
e.Size = -1
}
return e
}
// debRelease is the parsed subset of an apt Release file needed to mirror
// a suite.
type debRelease struct {
components []string
architectures []string
acquireByHash bool
files map[string]*debFile
}
// releaseSumFields maps Release checksum block names to algorithm names,
// including the MD5sum casing some repositories use.
var releaseSumFields = map[string]string{
"MD5Sum": "md5sum",
"MD5sum": "md5sum",
"SHA1": "sha1",
"SHA256": "sha256",
"SHA512": "sha512",
}
// byHashDirs maps checksum algorithm names to the by-hash directory names
// apt uses.
var byHashDirs = map[string]string{
"md5sum": "MD5Sum",
"sha1": "SHA1",
"sha256": "SHA256",
"sha512": "SHA512",
}
// parseRelease extracts the fields the sync needs from a Release or
// InRelease document.
func parseRelease(data []byte) (*debRelease, error) {
st, err := newControlReader(bytes.NewReader(stripClearsign(data))).readStanza()
if err != nil {
return nil, fmt.Errorf("parse release file: %w", err)
}
rel := &debRelease{files: map[string]*debFile{}}
rel.components = strings.Fields(st.Get("Components"))
rel.architectures = strings.Fields(st.Get("Architectures"))
rel.acquireByHash = strings.EqualFold(st.Get("Acquire-By-Hash"), "yes")
// Merge each checksum block into one record per file path.
for field, algo := range releaseSumFields {
for _, line := range strings.Split(st.Get(field), "\n") {
parts := strings.Fields(line)
if len(parts) != 3 {
continue
}
size, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
continue
}
f := rel.files[parts[2]]
if f == nil {
f = &debFile{path: parts[2], sums: map[string]string{}}
rel.files[parts[2]] = f
}
f.size = size
f.sums[algo] = strings.ToLower(parts[0])
}
}
return rel, nil
}
// syncDeb synchronizes one apt suite or flat repository from repoURL.
func syncDeb(ctx context.Context, src *fetch.Source, repoURL string, opts *Options) error {
keep := fetch.NewKeepSet(opts.Prune)
tr := newTrace(opts)
ctx = tr.track(ctx)
// Split the URL into the archive root and the suite path. Repositories
// without a dists directory are flat, with everything relative to the
// repository URL itself.
u, err := url.Parse(repoURL)
if err != nil {
return err
}
suiteRel := ""
rootURL := repoURL
if idx := strings.Index(u.Path, "/dists/"); idx >= 0 {
suiteRel = strings.Trim(u.Path[idx+1:], "/")
root := *u
root.Path = u.Path[:idx]
rootURL = root.String()
src.Rebase(suiteRel)
}
destRoot, err := opts.repoDest(rootURL)
if err != nil {
return err
}
destSuite := destRoot
suitePrefix := ""
if suiteRel != "" {
if destSuite, err = fetch.LocalJoin(destRoot, suiteRel); err != nil {
return err
}
suitePrefix = suiteRel + "/"
}
// Track the pool files this suite lists but the upstream does not serve.
// The records live in the suite directory, as the pool is shared with
// the archive's other suites.
miss := opts.newMissing(destSuite)
// Stage the release files; they are promoted last so a live tree keeps
// a consistent metadata chain.
releaseNames := []string{"InRelease", "Release", "Release.gpg"}
releaseStates := make([]fetch.FileState, len(releaseNames))
for i, name := range releaseNames {
dst, err := fetch.LocalJoin(destSuite, name)
if err != nil {
return err
}
state, err := fetch.File(ctx, src, suitePrefix+name, dst, nil, true, false)
if err != nil {
if errors.Is(err, fetch.ErrNotFound) {
// The upstream dropped this release file; drop the local
// copy so outdated metadata is never served.
if !opts.DryRun {
fetch.RemoveStale(dst)
}
continue
}
return fmt.Errorf("fetch %s: %w", name, err)
}
releaseStates[i] = state
}
// Parse the strongest release document available, which may be the
// promoted copy when the upstream reported it unchanged.
var relData []byte
switch {
case releaseStates[0] != fetch.FileMissing:
relData, err = os.ReadFile(fetch.StagedOrFinal(filepath.Join(destSuite, "InRelease")))
case releaseStates[1] != fetch.FileMissing:
relData, err = os.ReadFile(fetch.StagedOrFinal(filepath.Join(destSuite, "Release")))
default:
return errors.New("repository serves neither InRelease nor Release")
}
if err != nil {
return err
}
rel, err := parseRelease(relData)
if err != nil {
return err
}
// Stage every index file the release lists, applying component and
// architecture filters. Missing variants are normal; repositories list
// checksums for files they do not serve. A dry run only fetches the
// package and source indexes needed to plan the pool.
var indexJobs, plannedJobs []fetch.Job
for _, f := range sortedFiles(rel.files) {
// Releases commonly list their own release files; those are
// already staged above and must not be fetched twice.
if slices.Contains(releaseNames, f.path) {
continue
}
if !includeIndexFile(f.path, opts.Components, opts.Architectures) {
continue
}
dst, err := fetch.LocalJoin(destSuite, f.path)
if err != nil {
return err
}
job := fetch.Job{
ReqPath: suitePrefix + f.path,
Dst: dst,
Want: f.expect(),
Optional: true,
Stage: true,
}
if opts.DryRun {
base, _ := splitCompressExt(f.path)
if name := path.Base(base); name != "Packages" && name != "Sources" {
plannedJobs = append(plannedJobs, job)
continue
}
}
indexJobs = append(indexJobs, job)
}
fetch.PlanJobs(plannedJobs, opts.Verify, keep)
states, err := fetch.Many(ctx, src, indexJobs, opts.Workers, opts.Verify, keep, nil)
if err != nil {
return fmt.Errorf("fetch suite indexes: %w", err)
}
// Pick one local compression variant of each package and source index
// to parse for pool file references.
variants := map[string]string{}
variantExt := map[string]string{}
prefOrder := map[string]int{".gz": 0, ".xz": 1, ".zst": 2, ".bz2": 3, "": 4}
for i, job := range indexJobs {
if states[i] == fetch.FileMissing {
continue
}
relPath := strings.TrimPrefix(job.ReqPath, suitePrefix)
base, ext := splitCompressExt(relPath)
if name := path.Base(base); name != "Packages" && name != "Sources" {
continue
}
local := job.Dst
if states[i] == fetch.FileStaged {
local = job.Dst + fetch.StagedSuffix
}
if cur, ok := variantExt[base]; !ok || prefOrder[ext] < prefOrder[cur] {
variantExt[base] = ext
variants[base] = local
}
}
// Collect the pool files referenced by the chosen indexes.
pool := map[string]*debFile{}
for _, base := range sortedKeys(variants) {
files, err := parseIndexFile(variants[base], path.Base(base))
if err != nil {
return fmt.Errorf("parse %s: %w", base, err)
}
for _, f := range files {
if _, ok := pool[f.path]; !ok {
pool[f.path] = f
}
}
}
// Download pool files relative to the archive root.
poolJobs := make([]fetch.Job, 0, len(pool))
for _, f := range sortedFiles(pool) {
dst, err := fetch.LocalJoin(destRoot, f.path)
if err != nil {
return err
}
poolJobs = append(poolJobs, fetch.Job{ReqPath: f.path, Dst: dst, Want: f.expect()})
}
log.WithField("files", len(poolJobs)).Info("Synchronizing pool files.")
if opts.DryRun {
fetch.PlanJobs(poolJobs, opts.Verify, keep)
} else if _, err := fetch.Many(ctx, src, poolJobs, opts.Workers, opts.Verify, keep, miss); err != nil {
return fmt.Errorf("fetch pool files: %w", err)
}
// Promote the staged indexes now the pool files they reference exist.
for i, job := range indexJobs {
if states[i] == fetch.FileMissing {
continue
}
if err := fetch.PromoteOrDiscard(job.Dst, opts.DryRun); err != nil {
return err
}
if rel.acquireByHash && !opts.DryRun {
addByHash(job.Dst, strings.TrimPrefix(job.ReqPath, suitePrefix), rel, keep)
}
}
// Promote the release files last to complete the metadata chain.
for _, name := range []string{"Release.gpg", "Release", "InRelease"} {
dst, err := fetch.LocalJoin(destSuite, name)
if err != nil {
return err
}
if err := fetch.PromoteOrDiscard(dst, opts.DryRun); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
keep.Add(dst)
}
}
// Publish the traces at the archive root, where the convention places
// them and where they describe the archive as a whole rather than one
// suite. Writing before pruning lets the keep set cover them, which
// matters for a flat repository, whose root is itself the pruned tree.
tr.publish(ctx, destRoot, src, keep, opts)
// Prune only the suite directory for structured repositories; the pool
// can be shared with other suites of the same archive.
if opts.Prune {
if suiteRel == "" {
fetch.PruneTree(destRoot, keep, opts.PruneGrace, opts.DryRun)
} else {
log.Info("Pruning is limited to the suite directory; shared pool files are kept.")
fetch.PruneTree(destSuite, keep, opts.PruneGrace, opts.DryRun)
}
}
// The suite is published either way; missing pool files only decide
// whether the run reports itself as failed.
return miss.Finish()
}
// addByHash links an index file into its directory's by-hash tree for each
// checksum the release lists, matching apt's Acquire-By-Hash layout.
// Failures are logged and do not fail the sync.
func addByHash(dst, relPath string, rel *debRelease, keep *fetch.KeepSet) {
f := rel.files[relPath]
if f == nil || !strings.Contains(relPath, "/") {
return
}
dir := filepath.Dir(dst)
for algo, digest := range f.sums {
sub, ok := byHashDirs[algo]
if !ok {
continue
}
link := filepath.Join(dir, "by-hash", sub, digest)
if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil {
log.WithError(err).WithField("path", link).Warn("Failed to create by-hash directory.")
return
}
if _, err := os.Stat(link); err == nil {
keep.Add(link)
continue
}
if err := os.Link(dst, link); err != nil {
// Fall back to copying when hard links are not supported.
if err := copyFile(dst, link); err != nil {
log.WithError(err).WithField("path", link).Warn("Failed to create by-hash entry.")
continue
}
}
keep.Add(link)
}
}
// copyFile duplicates src to dst.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
_ = os.Remove(dst)
return err
}
return out.Close()
}
// parseIndexFile reads a Packages or Sources index and returns the pool
// files it references, with paths relative to the archive root.
func parseIndexFile(filename, kind string) ([]*debFile, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
r, closeFn, err := fetch.Decompressor(f, filename)
if err != nil {
return nil, err
}
if closeFn != nil {
defer closeFn()
}
cr := newControlReader(r)
var out []*debFile
for {
st, err := cr.readStanza()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if kind == "Sources" {
out = append(out, sourceFiles(st)...)
continue
}
name := st.Get("Filename")
if name == "" {
continue
}
size, _ := strconv.ParseInt(st.Get("Size"), 10, 64)
sums := map[string]string{}
if v := st.Get("MD5sum", "MD5Sum"); v != "" {
sums["md5sum"] = strings.ToLower(v)
}
for _, algo := range []string{"SHA1", "SHA256", "SHA512"} {
if v := st.Get(algo); v != "" {
sums[strings.ToLower(algo)] = strings.ToLower(v)
}
}
out = append(out, &debFile{path: path.Clean(name), size: size, sums: sums})
}
return out, nil
}
// sourceSumFields maps source stanza checksum list fields to algorithm
// names; Files is the historical MD5 list.
var sourceSumFields = map[string]string{
"Files": "md5sum",
"Checksums-Sha1": "sha1",
"Checksums-Sha256": "sha256",
"Checksums-Sha512": "sha512",
}
// sourceFiles extracts the files of one source package stanza, merging the
// per-algorithm checksum lists by file name.
func sourceFiles(st stanza) []*debFile {
dir := st.Get("Directory")
byName := map[string]*debFile{}
var order []string
for field, algo := range sourceSumFields {
for _, line := range strings.Split(st.Get(field), "\n") {
parts := strings.Fields(line)
if len(parts) != 3 {
continue
}
size, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
continue
}
name := parts[2]
f := byName[name]
if f == nil {
f = &debFile{path: path.Join(dir, name), sums: map[string]string{}}
byName[name] = f
order = append(order, name)
}
f.size = size
f.sums[algo] = strings.ToLower(parts[0])
}
}
out := make([]*debFile, 0, len(byName))
for _, name := range order {
out = append(out, byName[name])
}
return out
}
// includeIndexFile applies the component and architecture filters to a
// release-listed file path.
func includeIndexFile(p string, comps, archs []string) bool {
if len(comps) > 0 {
if segs := strings.Split(p, "/"); len(segs) > 1 && !slices.Contains(comps, segs[0]) {
return false
}
}
if len(archs) > 0 {
if arch := indexArch(p); arch != "" && !slices.Contains(archs, arch) {
return false
}
}
return true
}
// indexArch extracts the architecture a release-listed file applies to, or
// an empty string when the file is architecture independent.
func indexArch(p string) string {
for _, seg := range strings.Split(p, "/") {
if arch, ok := strings.CutPrefix(seg, "binary-"); ok {
return arch
}
if arch, ok := strings.CutPrefix(seg, "installer-"); ok {
return arch
}
if seg == "source" {
return "source"
}
}
base, _ := splitCompressExt(path.Base(p))
if arch, ok := strings.CutPrefix(base, "Contents-udeb-"); ok {
return arch
}
if arch, ok := strings.CutPrefix(base, "Contents-"); ok {
return arch
}
return ""
}
// splitCompressExt splits a known compression extension off a path.
func splitCompressExt(p string) (string, string) {
for _, ext := range []string{".gz", ".xz", ".bz2", ".zst"} {
if strings.HasSuffix(p, ext) {
return strings.TrimSuffix(p, ext), ext
}
}
return p, ""
}
// sortedFiles returns metadata file records in a stable path order.
func sortedFiles(files map[string]*debFile) []*debFile {
out := make([]*debFile, 0, len(files))
for _, f := range files {
out = append(out, f)
}
sort.Slice(out, func(i, j int) bool { return out[i].path < out[j].path })
return out
}
// sortedKeys returns a map's keys in a stable order.
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}