diff --git a/docs/configuration.md b/docs/configuration.md index 832860c2..75878cc6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1625,6 +1625,8 @@ A Julia package is mirrored with the source tarball `upstream.julia` serves at ` A Swift release is mirrored from `upstream.swift` with its source archive, checked against the SHA-256 checksum in the release metadata and cached under the key `/swift/{scope}/{name}/{version}.zip` serves. Give the registry scope as the namespace, for example `pkg:swift/apple/swift-argument-parser@1.5.0` for `apple.swift-argument-parser`, or the source repository, as SBOM tools write it: `pkg:swift/github.com/apple/swift-argument-parser@1.5.0` is looked up through the upstream's `/identifiers`, and the first identity it lists is mirrored. The release metadata, the package's release list and the release's `Package.swift` with its version-specific manifests are stored in the metadata cache too, so `swift package resolve` works with the upstream unreachable. Serving them, and the archive, offline needs `cache_metadata` on the server, since the archive is served with the checksum from the release metadata. +A CRAN version is mirrored as its source package from `upstream.cran`, for example `pkg:cran/glue@1.8.1`, and cached under the file name, so it is served for both `/cran/src/contrib/{file}` and `/cran/src/contrib/Archive/{name}/{file}`, where CRAN moves it once a newer version is published. The current version is fetched from `src/contrib` and checked against the MD5 sum in `src/contrib/PACKAGES.gz`; older versions are fetched unchecked from `src/contrib/Archive/{name}/`, as the CRAN handler fetches them. Binary packages aren't mirrored, and neither are the `PACKAGES` indexes, so `install.packages` needs the upstream or an index the proxy already cached; installing the tarball by URL works without either. + | Flag | Default | Description | |------|---------|-------------| | `--sbom` | | Path to CycloneDX or SPDX SBOM file | diff --git a/internal/mirror/cran.go b/internal/mirror/cran.go new file mode 100644 index 00000000..02d26bdb --- /dev/null +++ b/internal/mirror/cran.go @@ -0,0 +1,191 @@ +package mirror + +import ( + "bufio" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/git-pkgs/registries" +) + +const ( + cranDefaultURL = "https://cloud.r-project.org" + // cranIndexTTL is how long a fetched src/contrib/PACKAGES is reused, so + // mirroring many packages, or every version of one, reads it once. + cranIndexTTL = 5 * time.Minute + // cranMaxIndexSize limits the decompressed PACKAGES. + // CRAN's PACKAGES is about 7 MB. + cranMaxIndexSize = 64 << 20 + // cranMaxLine limits a line of PACKAGES. + cranMaxLine = 1 << 20 +) + +var ( + cranNamePattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.]*$`) + cranVersionPattern = regexp.MustCompile(`^[0-9]+[0-9.-]*$`) +) + +// cranIndex caches the MD5 sums src/contrib/PACKAGES lists for the current +// source packages of a CRAN repository, by file name. +type cranIndex struct { + mu sync.Mutex + baseURL string + fetched time.Time + md5s map[string]string +} + +// mirrorCRAN mirrors a CRAN source package under ("cran", name, version, +// name_version.tar.gz), the one entry the handler serves for both +// src/contrib and src/contrib/Archive. +func (m *Mirror) mirrorCRAN(ctx context.Context, pv PackageVersion, tracker *progressTracker) { + fail := func(err error) { m.recordFailure(tracker, pv, err) } + if !cranNamePattern.MatchString(pv.Name) { + fail(fmt.Errorf("invalid CRAN package name %q", pv.Name)) + return + } + if !cranVersionPattern.MatchString(pv.Version) { + fail(fmt.Errorf("invalid CRAN version %q", pv.Version)) + return + } + baseURL, client, err := m.cranRepository(pv.Name) + if err != nil { + fail(err) + return + } + filename := pv.Name + "_" + pv.Version + ".tar.gz" + + current, err := m.cranCurrentMD5s(ctx, baseURL, client) + if err != nil { + fail(fmt.Errorf("reading src/contrib/PACKAGES: %w", err)) + return + } + downloadURL := baseURL + "/src/contrib/" + filename + sum, ok := current[filename] + if !ok { + downloadURL = baseURL + "/src/contrib/Archive/" + url.PathEscape(pv.Name) + "/" + filename + } + var declared string + if sum != "" { + declared = "md5-" + sum + } + result, err := m.proxy.GetOrFetchArtifactFromURLWithIntegrity(ctx, pv.Ecosystem, pv.Name, pv.Version, filename, downloadURL, declared) + if err != nil { + fail(err) + return + } + m.recordResult(tracker, pv, result) +} + +// cranRepository returns the repository to mirror a CRAN package from and +// the client to read its indexes with: those Versions lists versions with, +// which come from upstream.cran, or the public CRAN. +func (m *Mirror) cranRepository(name string) (string, *registries.Client, error) { + r, _ := m.Versions.(RegistryVersions) + baseURL := cranDefaultURL + if r.URL != nil { + configured, err := r.URL("cran", name) + if err != nil { + return "", nil, err + } + if configured != "" { + baseURL = strings.TrimSuffix(configured, "/") + } + } + client := r.Client + if client == nil { + client = registries.DefaultClient() + } + return baseURL, client, nil +} + +// cranCurrentMD5s returns the MD5 sums of the current source packages of the +// repository at baseURL, reading src/contrib/PACKAGES.gz unless it was read +// within cranIndexTTL. +func (m *Mirror) cranCurrentMD5s(ctx context.Context, baseURL string, client *registries.Client) (map[string]string, error) { + m.cran.mu.Lock() + defer m.cran.mu.Unlock() + if m.cran.md5s != nil && m.cran.baseURL == baseURL && time.Since(m.cran.fetched) < cranIndexTTL { + return m.cran.md5s, nil + } + body, err := client.GetBody(ctx, baseURL+"/src/contrib/PACKAGES.gz") + if err != nil { + return nil, err + } + data, err := gunzipLimited(body) + if err != nil { + return nil, err + } + sums, err := parseCRANPackages(data) + if err != nil { + return nil, err + } + m.cran.md5s = sums + m.cran.baseURL = baseURL + m.cran.fetched = time.Now() + return m.cran.md5s, nil +} + +// gunzipLimited decompresses data, or returns it as is when it isn't +// gzipped. +func gunzipLimited(data []byte) ([]byte, error) { + if !bytes.HasPrefix(data, []byte{0x1f, 0x8b}) { + return data, nil + } + zr, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + defer func() { _ = zr.Close() }() + out, err := io.ReadAll(io.LimitReader(zr, cranMaxIndexSize+1)) + if err != nil { + return nil, err + } + if len(out) > cranMaxIndexSize { + return nil, fmt.Errorf("index is larger than %d bytes", cranMaxIndexSize) + } + return out, nil +} + +// parseCRANPackages returns the MD5sum of each source package a PACKAGES +// file lists, by file name. Entries without one are listed with "". +func parseCRANPackages(data []byte) (map[string]string, error) { + sums := map[string]string{} + var name, version, sum string + flush := func() { + if name != "" && version != "" { + sums[name+"_"+version+".tar.gz"] = sum + } + name, version, sum = "", "", "" + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(nil, cranMaxLine) + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "" { + flush() + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + switch value = strings.TrimSpace(value); key { + case "Package": + name = value + case "Version": + version = value + case "MD5sum": + sum = value + } + } + flush() + return sums, scanner.Err() +} diff --git a/internal/mirror/cran_test.go b/internal/mirror/cran_test.go new file mode 100644 index 00000000..3eae797b --- /dev/null +++ b/internal/mirror/cran_test.go @@ -0,0 +1,27 @@ +package mirror + +import ( + "maps" + "testing" +) + +func TestParseCRANPackages(t *testing.T) { + data := []byte(`Package: A3 +Version: 1.0.0 +Depends: R (>= 2.15.0), xtable, + pbapply +MD5sum: 027ebdd8affce8f0effaecfcd5f5ade2 +NeedsCompilation: no + +Package: abc +Version: 2.2.1 +NeedsCompilation: no +`) + want := map[string]string{ + "A3_1.0.0.tar.gz": "027ebdd8affce8f0effaecfcd5f5ade2", + "abc_2.2.1.tar.gz": "", + } + if got, err := parseCRANPackages(data); err != nil || !maps.Equal(got, want) { + t.Errorf("parseCRANPackages = %v, %v, want %v", got, err, want) + } +} diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go index 9b55a8c8..6f74e22d 100644 --- a/internal/mirror/mirror.go +++ b/internal/mirror/mirror.go @@ -43,6 +43,8 @@ type Mirror struct { // RefusePackages, when set, fails every item Images does not take with // this reason instead of fetching it, so nothing is cached. RefusePackages string + + cran cranIndex } // New creates a new Mirror with the given dependencies. @@ -255,6 +257,10 @@ func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *prog m.mirrorSwift(ctx, pv, tracker) return } + if pv.Ecosystem == "cran" { + m.mirrorCRAN(ctx, pv, tracker) + return + } result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "") if err != nil { m.recordFailure(tracker, pv, err) diff --git a/internal/server/mirror_cran_test.go b/internal/server/mirror_cran_test.go new file mode 100644 index 00000000..b62ec3ae --- /dev/null +++ b/internal/server/mirror_cran_test.go @@ -0,0 +1,150 @@ +package server + +import ( + "bytes" + "compress/gzip" + "crypto/md5" //nolint:gosec // CRAN publishes MD5 sums + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/handler" +) + +const ( + cranCurrentPath = "/src/contrib/example_1.1.tar.gz" + cranArchivedPath = "/src/contrib/Archive/example/example_1.0.tar.gz" + cranIndexPath = "/src/contrib/PACKAGES.gz" +) + +// cranUpstream serves a CRAN repository with example 1.1 current, listed +// with its MD5 in PACKAGES, and 0.9 and 1.0 archived. It counts requests by +// path. +type cranUpstream struct { + *httptest.Server + mu sync.Mutex + files map[string]string + hits map[string]int +} + +// newCRANUpstream serves the files of example with bodies replaced by +// tampered, which keep the MD5 sums of the originals. +func newCRANUpstream(t *testing.T, tampered map[string]string) *cranUpstream { + t.Helper() + current := "example 1.1 source" + sum := md5.Sum([]byte(current)) //nolint:gosec // see import + var index bytes.Buffer + zw := gzip.NewWriter(&index) + _, _ = io.WriteString(zw, "Package: aaa\nVersion: 2.0\nMD5sum: 00000000000000000000000000000000\n\n"+ + "Package: example\nVersion: 1.1\nDepends: R (>= 4.0)\nMD5sum: "+hex.EncodeToString(sum[:])+"\nNeedsCompilation: no\n") + _ = zw.Close() + + u := &cranUpstream{ + files: map[string]string{ + cranIndexPath: index.String(), + cranCurrentPath: current, + cranArchivedPath: "example 1.0 source", + "/src/contrib/Archive/example/example_0.9.tar.gz": "example 0.9 source", + "/web/packages/example/DESCRIPTION": "Package: example\nVersion: 1.1\n", + "/src/contrib/Archive/example/": `0.9 +1.0`, + }, + hits: map[string]int{}, + } + for path, body := range tampered { + u.files[path] = body + } + u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u.mu.Lock() + body, ok := u.files[r.URL.Path] + u.hits[r.URL.Path]++ + u.mu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + _, _ = io.WriteString(w, body) + })) + t.Cleanup(u.Close) + return u +} + +func (u *cranUpstream) hitsFor(path string) int { + u.mu.Lock() + defer u.mu.Unlock() + return u.hits[path] +} + +func cranMirrorConfig(upstream *cranUpstream) *config.Config { + cfg := config.Default() + cfg.Upstream.CRAN = upstream.URL + return cfg +} + +// getCRAN requests path from a CRAN handler for upstream. +func getCRAN(proxy *handler.Proxy, upstream *cranUpstream, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + cran := handler.NewCRANHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL) + cran.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + return rec +} + +func TestMirrorCRANSourcePackagesAreServedFromCache(t *testing.T) { + upstream := newCRANUpstream(t, nil) + + progress, proxy := mirrorWithConfig(t, cranMirrorConfig(upstream), "pkg:cran/example@1.1", "pkg:cran/example@1.0") + + if progress.Completed != 2 || progress.Failed != 0 { + t.Fatalf("completed = %d, failed = %d, errors = %v", progress.Completed, progress.Failed, progress.Errors) + } + for _, path := range []string{cranCurrentPath, cranArchivedPath} { + if got := upstream.hitsFor(path); got != 1 { + t.Errorf("%s fetched %d times, want 1", path, got) + } + } + + // R gets both from the cache once the upstream is down, the current + // version also from Archive/, where CRAN moves it once a newer one is + // published. + upstream.Close() + for path, want := range map[string]string{ + cranCurrentPath: "example 1.1 source", + "/src/contrib/Archive/example/example_1.1.tar.gz": "example 1.1 source", + cranArchivedPath: "example 1.0 source", + } { + if rec := getCRAN(proxy, upstream, path); rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("%s from cache: status = %d, body = %q, want 200 and %q", path, rec.Code, rec.Body, want) + } + } +} + +func TestMirrorCRANRefusesMD5Mismatch(t *testing.T) { + upstream := newCRANUpstream(t, map[string]string{cranCurrentPath: "tampered 1.1"}) + + progress, proxy := mirrorWithConfig(t, cranMirrorConfig(upstream), "pkg:cran/example@1.1") + + if progress.Failed != 1 || progress.Completed != 0 { + t.Fatalf("completed = %d, failed = %d, errors = %v; want it refused", progress.Completed, progress.Failed, progress.Errors) + } + upstream.Close() + if rec := getCRAN(proxy, upstream, cranCurrentPath); rec.Code == http.StatusOK { + t.Errorf("%s was cached although its MD5 doesn't match", cranCurrentPath) + } +} + +func TestMirrorCRANAllVersions(t *testing.T) { + upstream := newCRANUpstream(t, nil) + + progress, _ := mirrorWithConfig(t, cranMirrorConfig(upstream), "pkg:cran/example") + + if progress.Total != 3 || progress.Completed != 3 || progress.Failed != 0 { + t.Fatalf("total = %d, completed = %d, failed = %d, errors = %v; want 0.9, 1.0 and 1.1 once each", progress.Total, progress.Completed, progress.Failed, progress.Errors) + } + if got := upstream.hitsFor(cranIndexPath); got != 1 { + t.Errorf("PACKAGES.gz fetched %d times, want once", got) + } +}