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)
+ }
+}