diff --git a/README.md b/README.md index 47c2bfe..bc7262d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/< - `alpine`: [remote](docs/alpine/remote.md), [remote-github](docs/alpine/remote-github.md) - `ansible`: [remote](docs/ansible/remote.md), [local](docs/ansible/local.md), [virtual](docs/ansible/virtual.md) - `cargo`: [remote](docs/cargo/remote.md) -- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md) +- `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md), [virtual](docs/deb/virtual.md) - `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md) - `generic`: [remote](docs/generic/remote.md) - `gems`: [remote](docs/gems/remote.md), [local](docs/gems/local.md), [virtual](docs/gems/virtual.md) diff --git a/docs/deb/virtual.md b/docs/deb/virtual.md new file mode 100644 index 0000000..cab997c --- /dev/null +++ b/docs/deb/virtual.md @@ -0,0 +1,32 @@ +# `deb` virtual + +A deb virtual serves one apt source over local and remote deb members at +`/api/v1/virtual//`, in either the flat (`./`) or `dists/` layout. + +- Packages indexes are merged; on a duplicate (Package, Version, Architecture) + the earlier member wins. +- The Release is generated unsigned with `Acquire-By-Hash: yes`; indexes are + served by name and by SHA256 from the current and previous merge. +- `Filename` points at the virtual (`/`), which 302s to the + owning member. +- All members must share one layout; mixing flat and suite members is + unsupported (suite requests fail with 502). +- A member error fails the request with 502. Merges are cached for 60s per + suite, up to 32 suites per virtual. + +```hcl +resource "artifactapi_virtual" "deb" { + name = "deb" + package_type = "deb" + members = [artifactapi_local_deb.internal.name, artifactapi_remote_github_deb.tool.name] +} +``` + +``` +# /etc/apt/sources.list.d/artifactapi.list (flat) +deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/deb/ ./ +# suite layout +deb [trusted=yes] https://artifactapi.example.com/api/v1/virtual/debian/ trixie main +``` + +Upload to the local member directly; the virtual is read-only. diff --git a/e2e-docker/deb_virtual_test.go b/e2e-docker/deb_virtual_test.go new file mode 100644 index 0000000..b244340 --- /dev/null +++ b/e2e-docker/deb_virtual_test.go @@ -0,0 +1,115 @@ +//go:build dockere2e + +package e2edocker + +import ( + "net/http" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/testsupport" +) + +// TestVirtualDebMerge merges two local deb repos carrying the same package: the +// first member wins the duplicate, and a real apt sees both members' packages +// and installs through the virtual. +func TestVirtualDebMerge(t *testing.T) { + createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`) + createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`) + defer deleteRepo(t, "vdeb-a") + defer deleteRepo(t, "vdeb-b") + + uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package") + uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package") + uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package") + + // Local indexes regenerate asynchronously; merge only once vdeb-b lists both. + deadline := time.Now().Add(15 * time.Second) + for { + resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "") + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) { + break + } + time.Sleep(500 * time.Millisecond) + } + + createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`) + defer deleteVirtual(t, "vdeb") + + _, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "") + pk := string(body) + if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") || + !strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") { + t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk) + } + + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + script := "set -euo pipefail; " + + "rm -f /etc/apt/sources.list.d/*; " + + "echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " + + "apt-get update; " + + "apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " + + "apt-get install -y vdeb-one vdeb-two; " + + "dpkg -s vdeb-one vdeb-two" + out, err := exec.Command("docker", "run", "--rm", "--network", network, + "-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/", + "debian:13", "bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out) + } +} + +// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a +// real apt through the virtual's dists/ layout (by-hash indexes). +func TestVirtualDebSuite(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + for _, n := range []string{"vdebs-a", "vdebs-b"} { + createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`) + defer deleteRepo(t, n) + } + createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`) + defer deleteVirtual(t, "vdebs") + + resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") || + !strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") { + t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel) + } + + script := "set -euo pipefail; " + + "rm -f /etc/apt/sources.list.d/*; " + + "echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " + + "apt-get update; " + + "idx=/tmp/avail; apt-cache dumpavail > $idx; " + + "grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " + + "pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2/binary-/Packages. +var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`) + +// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash). +var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`) + +// debCompressions is the order a member's index variants are fetched in. +var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""} + +// DebMember is one member's Release plus its decompressed Packages indexes, +// keyed by index directory relative to the Release ("" for a flat repo). +type DebMember struct { + RemoteName string + Release []byte + Packages map[string][]byte +} + +// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by +// their path relative to the Release. +type DebRepo struct { + Release []byte + Files map[string][]byte +} + +type debRelease struct { + fields map[string]string + files []string +} + +// parseDebRelease reads a Release file's single-line fields and the names +// listed in its checksum sections. +func parseDebRelease(body []byte) debRelease { + rel := debRelease{fields: map[string]string{}} + inSums := false + seen := map[string]bool{} + for line := range strings.SplitSeq(string(body), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + if line[0] == ' ' || line[0] == '\t' { + if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] { + seen[f[2]] = true + rel.files = append(rel.files, f[2]) + } + continue + } + k, v, _ := strings.Cut(line, ":") + v = strings.TrimSpace(v) + inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA")) + if _, ok := rel.fields[k]; !ok { + rel.fields[k] = v + } + } + return rel +} + +// debIndexFiles returns, per index directory, the variant of its Packages file +// to fetch from a member's Release listing. +func debIndexFiles(rel debRelease) map[string]string { + listed := map[string]bool{} + for _, f := range rel.files { + listed[f] = true + } + out := map[string]string{} + for _, f := range rel.files { + m := debIndexRe.FindStringSubmatch(f) + if m == nil || out[m[1]] != "" { + continue + } + for _, c := range debCompressions { + if listed[m[1]+"Packages"+c] { + out[m[1]] = m[1] + "Packages" + c + break + } + } + } + return out +} + +func splitStanzas(body []byte) []string { + var out []string + for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") { + if s = strings.Trim(s, "\n"); s != "" { + out = append(out, s) + } + } + return out +} + +func stanzaField(stanza, key string) string { + for line := range strings.SplitSeq(stanza, "\n") { + if k, v, ok := strings.Cut(line, ":"); ok && k == key { + return strings.TrimSpace(v) + } + } + return "" +} + +func unionFields(dst []string, field string) []string { + for f := range strings.FieldsSeq(field) { + if !slices.Contains(dst, f) { + dst = append(dst, f) + } + } + return dst +} + +func parseDebDate(s string) (time.Time, bool) { + for _, layout := range []string{time.RFC1123Z, time.RFC1123} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// MergeDeb merges members into one apt repo. Members are in priority order: the +// first member to carry a (Package, Version, Architecture) in an index wins; +// stanzas missing any of those are always kept. Filename is prefixed with the +// owning member's name so the virtual can route downloads back to it. +func MergeDeb(members []DebMember) *DebRepo { + var suite, codename string + var arches, comps, dirs []string + date := time.Unix(0, 0) + for _, m := range members { + rel := parseDebRelease(m.Release) + if suite == "" { + suite, codename = rel.fields["Suite"], rel.fields["Codename"] + } + arches = unionFields(arches, rel.fields["Architectures"]) + comps = unionFields(comps, rel.fields["Components"]) + if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) { + date = t + } + for d := range m.Packages { + if !slices.Contains(dirs, d) { + dirs = append(dirs, d) + } + } + } + slices.Sort(dirs) + + repo := &DebRepo{Files: map[string][]byte{}} + for _, d := range dirs { + seen := map[string]bool{} + var b bytes.Buffer + for _, m := range members { + for _, s := range splitStanzas(m.Packages[d]) { + pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture") + if pkg != "" && ver != "" && arch != "" { + key := pkg + "\x00" + ver + "\x00" + arch + if seen[key] { + continue + } + seen[key] = true + } + if fn := stanzaField(s, "Filename"); fn != "" { + s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/")) + } + b.WriteString(s) + b.WriteString("\n\n") + } + } + gz := gzipDeterministic(b.Bytes()) + repo.Files[d+"Packages"] = b.Bytes() + repo.Files[d+"Packages.gz"] = gz + repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes() + repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz + } + + var r bytes.Buffer + if suite != "" { + fmt.Fprintf(&r, "Suite: %s\n", suite) + } + if codename != "" { + fmt.Fprintf(&r, "Codename: %s\n", codename) + } + fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z)) + fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " ")) + if len(comps) > 0 { + fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " ")) + } + r.WriteString("Acquire-By-Hash: yes\n") + for _, sec := range []struct { + name string + sum func([]byte) string + }{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} { + r.WriteString(sec.name + ":\n") + for _, d := range dirs { + for _, n := range []string{d + "Packages", d + "Packages.gz"} { + fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n) + } + } + } + repo.Release = r.Bytes() + return repo +} + +func rewriteFilename(stanza, fn string) string { + lines := strings.Split(stanza, "\n") + for i, l := range lines { + if strings.HasPrefix(l, "Filename:") { + lines[i] = "Filename: " + fn + break + } + } + return strings.Join(lines, "\n") +} + +// debIndexPath splits an index request into its Release directory and the file +// under it. ok is false for anything a deb virtual does not merge. +func debIndexPath(p string) (base, name string, ok bool) { + if rest, found := strings.CutPrefix(p, "dists/"); found { + suite, n, found := strings.Cut(rest, "/") + if !found || suite == "" { + return "", "", false + } + base, p = "dists/"+suite+"/", n + } + if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) { + return "", "", false + } + return base, p, true +} + +func md5Hex(data []byte) string { + h := md5.Sum(data) + return hex.EncodeToString(h[:]) +} diff --git a/internal/virtual/deb_merger_test.go b/internal/virtual/deb_merger_test.go new file mode 100644 index 0000000..64b2dc3 --- /dev/null +++ b/internal/virtual/deb_merger_test.go @@ -0,0 +1,179 @@ +package virtual + +import ( + "bytes" + "crypto/md5" + "encoding/hex" + "fmt" + "maps" + "path" + "slices" + "strings" + "testing" +) + +func debStanza(pkg, ver, arch, filename string) string { + return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename) +} + +func flatDebMember(name, date string, stanzas ...string) DebMember { + rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n" + return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}} +} + +func TestMergeDebDedupesByMemberOrder(t *testing.T) { + repo := MergeDeb([]DebMember{ + flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000", + debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"), + debStanza("bar", "2.0", "amd64", "/pool/bar.deb"), + debStanza("dot", "1.0", "amd64", "./dot.deb"), + "Package: noversion\nFilename: nv1.deb\n", + "Package: noversion\nFilename: nv2.deb\n"), + flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC", + debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"), + debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"), + debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")), + }) + pk := string(repo.Files["Packages"]) + stanzas := splitStanzas(repo.Files["Packages"]) + if len(stanzas) != 7 { + t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk) + } + for _, want := range []string{ + "Filename: a/pool/foo_1.0_amd64.deb", + "Filename: a/pool/bar.deb", + "Filename: a/dot.deb", + "Filename: a/nv1.deb", + "Filename: a/nv2.deb", + "Filename: b/pool/foo_arm64.deb", + "Filename: b/o/r/releases/download/v1/foo.deb", + " multi-line body", + } { + if !strings.Contains(pk, want) { + t.Errorf("merged Packages missing %q:\n%s", want, pk) + } + } + if strings.Contains(pk, "foo-from-b") { + t.Fatalf("second member's duplicate must lose:\n%s", pk) + } + if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) { + t.Fatal("Packages.gz does not match Packages") + } + if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") { + t.Fatalf("Date should be the newest member date:\n%s", repo.Release) + } +} + +func TestMergeDebReleaseHashesFiles(t *testing.T) { + repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}) + rel := parseDebRelease(repo.Release) + if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) { + t.Fatalf("Release lists %v", got) + } + inSHA := false + checked := 0 + for line := range strings.SplitSeq(string(repo.Release), "\n") { + if !strings.HasPrefix(line, " ") { + inSHA = line == "SHA256:" + continue + } + var sum, name string + var size int + if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil { + t.Fatal(err) + } + body := repo.Files[name] + want := sha256Hex(body) + if !inSHA { + h := md5.Sum(body) + want = hex.EncodeToString(h[:]) + } + if sum != want || size != len(body) { + t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body)) + } + checked++ + } + if checked != 4 { + t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release) + } + if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) { + t.Fatal("merge must be deterministic") + } +} + +func TestMergeDebSuiteLayout(t *testing.T) { + rel := func(comps string) []byte { + return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n") + } + repo := MergeDeb([]DebMember{ + {RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}}, + {RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{ + "main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")), + "contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")), + }}, + }) + for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} { + if !strings.Contains(string(repo.Release), want) { + t.Errorf("Release missing %q:\n%s", want, repo.Release) + } + } + want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"} + if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) { + t.Fatalf("Release lists %v, want %v", got, want) + } + if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") { + t.Fatalf("Release must advertise by-hash:\n%s", repo.Release) + } + for _, n := range want { + byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n]) + if !bytes.Equal(repo.Files[byHash], repo.Files[n]) { + t.Errorf("%s not served at %s", n, byHash) + } + } + if len(repo.Files) != 2*len(want) { + t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files))) + } + if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 { + t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n) + } +} + +func TestDebIndexFiles(t *testing.T) { + rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" + + " a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" + + " a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n")) + got := debIndexFiles(rel) + want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"} + if !maps.Equal(got, want) { + t.Fatalf("got %v, want %v", got, want) + } + if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" { + t.Fatalf("flat repo should fetch Packages.gz, got %v", flat) + } +} + +func TestDebIndexPath(t *testing.T) { + for _, tc := range []struct { + in, base, name string + ok bool + }{ + {"Release", "", "Release", true}, + {"Packages.gz", "", "Packages.gz", true}, + {"dists/trixie/Release", "dists/trixie/", "Release", true}, + {"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true}, + {"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true}, + {"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true}, + {"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false}, + {"InRelease", "", "", false}, + {"Release.gpg", "", "", false}, + {"dists/trixie/InRelease", "", "", false}, + {"dists/trixie/main/i18n/Translation-en", "", "", false}, + {"dists/Release", "", "", false}, + {"pool/foo.deb", "", "", false}, + } { + base, name, ok := debIndexPath(tc.in) + if base != tc.base || name != tc.name || ok != tc.ok { + t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok) + } + } +} diff --git a/internal/virtual/engine.go b/internal/virtual/engine.go index 4088194..c062bc6 100644 --- a/internal/virtual/engine.go +++ b/internal/virtual/engine.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "path" "slices" "strings" "sync" @@ -26,6 +27,7 @@ type Engine struct { proxyEngine *proxy.Engine getRemote func(context.Context, string) (*models.Remote, error) rpmMember func(context.Context, string) (*RPMMember, error) + debMember func(ctx context.Context, name, base string) (*DebMember, error) npmMember func(ctx context.Context, name, pkg string) (*NPMMember, error) // NPMLocal returns a local npm repo's packument with tarball paths relative @@ -62,19 +64,25 @@ type mergedRepo interface { func (r *RPMRepo) index() []byte { return r.Repomd } func (r *RPMRepo) files() map[string][]byte { return r.Files } +func (r *DebRepo) index() []byte { return r.Release } +func (r *DebRepo) files() map[string][]byte { return r.Files } // repoGen holds a virtual's current merge plus the previous one, so a client // holding the previous index can still fetch the files it references. type repoGen struct { cur, prev mergedRepo - at time.Time + at, used time.Time } const rpmMergeTTL = 60 * time.Second +// maxDebSuites bounds the client-chosen suites cached per deb virtual. +const maxDebSuites = 32 + func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine { e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL} e.rpmMember = e.fetchRPMMember + e.debMember = e.fetchDebMember e.npmMember = e.fetchNPMMember e.gemsMember = e.fetchGemsMember e.ansibleDoc = e.ansibleMemberDoc @@ -84,8 +92,11 @@ func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine { } func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) { - if virt.PackageType == models.PackageRPM { + switch virt.PackageType { + case models.PackageRPM: return e.fetchRPM(ctx, virt, path) + case models.PackageDeb: + return e.fetchDeb(ctx, virt, path) } merger, err := GetMerger(virt.PackageType) @@ -204,16 +215,22 @@ var ( ErrBadPath = errors.New("invalid path") ) -// MemberRedirect maps an rpm, npm or ansible virtual package path -// (/, as written by MergeRPM/MergeNPM/AnsibleVersion) to the -// owning member's route. ok is false for any other path; ErrBadPath means the -// path is absolute or escapes the member. +// MemberRedirect maps an rpm, deb, npm or ansible virtual package path +// (/, as written by MergeRPM/MergeDeb/MergeNPM/AnsibleVersion) +// to the owning member's route. ok is false for any other path; ErrBadPath +// means the path is absolute or escapes the member. func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) { switch virt.PackageType { case models.PackageRPM: if strings.HasPrefix(path, "repodata/") { return "", false, nil } + case models.PackageDeb: + // apt requests a flat repo's files under its "./" dist verbatim. + path = strings.TrimPrefix(path, "./") + if strings.HasPrefix(path, "dists/") { + return "", false, nil + } case models.PackageNPM, models.PackageAnsible: default: return "", false, nil @@ -294,6 +311,7 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont e.mu.Lock() g := e.gens[key] if g != nil && time.Since(g.at) < e.mergeTTL { + g.used = time.Now() e.mu.Unlock() return g.cur, nil } @@ -312,12 +330,12 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont g := e.gens[key] switch { case g == nil: - e.gens[key] = &repoGen{cur: repo, at: time.Now()} + e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()} case string(g.cur.index()) == string(repo.index()): - g.at = time.Now() + g.at, g.used = time.Now(), time.Now() repo = g.cur default: - g.prev, g.cur, g.at = g.cur, repo, time.Now() + g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now() } return repo, nil }) @@ -364,6 +382,85 @@ func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error return members, nil } +func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) { + base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/")) + if !ok { + return nil, "", ErrNotFound + } + key := "deb\x00" + virt.Name + "\x00" + base + if name != "Release" { + if body, ok := e.cachedFile(key, name); ok { + return body, debContentType(name), nil + } + } + + repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) { + members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) }) + if err != nil { + return nil, err + } + return MergeDeb(members), nil + }) + if err != nil { + return nil, "", err + } + e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites) + if name == "Release" { + return repo.index(), "text/plain", nil + } + if body, ok := repo.files()[name]; ok { + return body, debContentType(name), nil + } + return nil, "", ErrNotFound +} + +func debContentType(name string) string { + if strings.HasSuffix(name, ".gz") { + return "application/gzip" + } + return "text/plain" +} + +// fetchDebMember reads a member's Release under base and each binary Packages +// index it lists. +func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) { + remote, err := e.getRemote(ctx, name) + if err != nil { + return nil, fmt.Errorf("remote %q: %w", name, err) + } + rel, err := e.fetchMemberPath(ctx, *remote, base+"Release") + if err != nil { + return nil, err + } + m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}} + for dir, file := range debIndexFiles(parseDebRelease(rel)) { + raw, err := e.fetchMemberPath(ctx, *remote, base+file) + if err != nil { + return nil, err + } + if m.Packages[dir], err = decompress(file, raw); err != nil { + return nil, fmt.Errorf("decompress %s: %w", file, err) + } + } + return m, nil +} + +// evictLRU drops the least recently used merges under prefix beyond keep. +func (e *Engine) evictLRU(prefix string, keep int) { + e.mu.Lock() + defer e.mu.Unlock() + var keys []string + for k := range e.gens { + if strings.HasPrefix(k, prefix) { + keys = append(keys, k) + } + } + slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) }) + for _, k := range keys[min(len(keys), keep):] { + delete(e.gens, k) + } +} + func (e *Engine) cachedFile(key, path string) ([]byte, bool) { e.mu.Lock() defer e.mu.Unlock() @@ -376,6 +473,7 @@ func (e *Engine) cachedFile(key, path string) ([]byte, bool) { continue } if body, ok := r.files()[path]; ok { + g.used = time.Now() return body, true } } diff --git a/internal/virtual/engine_test.go b/internal/virtual/engine_test.go index cb7a334..04a6a82 100644 --- a/internal/virtual/engine_test.go +++ b/internal/virtual/engine_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "strings" "sync" "sync/atomic" "testing" @@ -157,6 +158,97 @@ func TestMemberRedirect(t *testing.T) { } } +func debEngine(members map[string]*DebMember) *Engine { + e := fakeEngine(nil) + e.debMember = func(_ context.Context, name, base string) (*DebMember, error) { + if m := members[name]; m != nil && base == "" { + return m, nil + } + return nil, errors.New("upstream down") + } + return e +} + +func debVirt(members ...string) models.Virtual { + return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members} +} + +func TestFetchDebFailsClosed(t *testing.T) { + a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb")) + e := debEngine(map[string]*DebMember{"a": &a}) + for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} { + if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) { + t.Errorf("%s: want upstream error with a member down, got %v", p, err) + } + } + for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} { + if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) { + t.Errorf("%s: want not found, got %v", p, err) + } + } +} + +func TestFetchDebRemergesAfterTTL(t *testing.T) { + a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb")) + e := debEngine(map[string]*DebMember{"a": &a}) + virt := debVirt("a") + + rel, _, err := e.Fetch(context.Background(), virt, "./Release", "") + if err != nil { + t.Fatal(err) + } + a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb")) + e.mergeTTL = 0 + newRel, _, err := e.Fetch(context.Background(), virt, "Release", "") + if err != nil || string(newRel) == string(rel) { + t.Fatalf("Release should reflect the member change (err %v)", err) + } + pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "") + if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") { + t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk) + } +} + +func TestFetchDebMergeIsSharedPerSuite(t *testing.T) { + var calls atomic.Int32 + e := fakeEngine(nil) + e.mergeTTL = time.Minute + e.debMember = func(_ context.Context, name, base string) (*DebMember, error) { + calls.Add(1) + m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb")) + return &m, nil + } + for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} { + if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + } + if n := calls.Load(); n != 3 { + t.Fatalf("member fetched %d times, want once per Release dir (3)", n) + } +} + +func TestMemberRedirectDeb(t *testing.T) { + e := fakeEngine(nil) + virt := debVirt("gh", "loc") + for _, tc := range []struct { + path, want string + ok bool + err error + }{ + {path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"}, + {path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"}, + {path: "gh/../loc/x.deb", err: ErrBadPath}, + {path: "dists/trixie/Release"}, + {path: "Packages"}, + } { + got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/") + if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) { + t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err) + } + } +} + type fakeRepo struct { idx string fs map[string][]byte @@ -206,3 +298,72 @@ func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) { t.Error("unknown key served a file") } } + +func suiteDebMember(name, version string) *DebMember { + return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"), + Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}} +} + +func TestFetchDebByHashSurvivesRemerge(t *testing.T) { + version := "1" + e := fakeEngine(nil) + e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil } + virt := debVirt("a") + fetch := func(p string) []byte { + body, _, err := e.Fetch(context.Background(), virt, p, "") + if err != nil { + t.Fatalf("%s: %v", p, err) + } + return body + } + byHash := func(rel []byte) string { + m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel) + if m == nil { + t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel) + } + return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1]) + } + + oldRel := fetch("dists/trixie/Release") + version, e.mergeTTL = "2", 0 + if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) { + t.Fatal("Release should reflect the member change") + } + if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") { + t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got) + } + if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") { + t.Fatalf("fixed-name Packages should be the current merge:\n%s", got) + } +} + +func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) { + e := fakeEngine(nil) + e.mergeTTL = time.Minute + e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil } + other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}} + if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil { + t.Fatal(err) + } + for i := range maxDebSuites + 5 { + if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil { + t.Fatal(err) + } + time.Sleep(time.Microsecond) + } + n := 0 + for k := range e.gens { + if strings.HasPrefix(k, "deb\x00v\x00") { + n++ + } + } + if n != maxDebSuites { + t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites) + } + if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil { + t.Fatal("least recently used suites should be evicted first") + } + if e.gens["deb\x00other\x00dists/keep/"] == nil { + t.Fatal("another virtual's suites must not be evicted") + } +}