diff --git a/README.md b/README.md index 2c538ad9..0553e4a1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ The proxy never uploads artifact bytes to a scanner. Each scanner is notified wi | RPM | RHEL/Fedora | | ✓ | | Alpine | Alpine Linux | | ✓ | | Arch | Arch Linux | | ✓ | +| FreeBSD | FreeBSD (ports and pkgbase) | | ✓ | | Chef Supermarket | Chef | | ✓ | | Generic | Any | | ✓ | | Git | Any | | ✓ | @@ -777,6 +778,62 @@ Avoid `mirror.archlinuxarm.org` as the upstream. It redirects each request to a changing regional mirror over plain HTTP, so the database and the packages can come from mirrors with a different sync state. +### FreeBSD / pkg + +Override the repositories in `/usr/local/etc/pkg/repos/FreeBSD.conf`. Settings +there are merged with `/etc/pkg/FreeBSD.conf`, so only the URL and mirror type +change and the signature settings stay. The default repository name `freebsd` +proxies the official mirror (`https://pkg.freebsd.org`). On FreeBSD 15: + +``` +FreeBSD-ports: { + url: "http://localhost:8080/freebsd/freebsd/${ABI}/quarterly", + mirror_type: "none" +} +FreeBSD-ports-kmods: { + url: "http://localhost:8080/freebsd/freebsd/${ABI}/kmods_quarterly_${VERSION_MINOR}", + mirror_type: "none" +} +FreeBSD-base: { + url: "http://localhost:8080/freebsd/freebsd/${ABI}/base_release_${VERSION_MINOR}", + mirror_type: "none" +} +``` + +FreeBSD 14 has a single repository called `FreeBSD`: + +``` +FreeBSD: { + url: "http://localhost:8080/freebsd/freebsd/${ABI}/quarterly", + mirror_type: "none" +} +``` + +Then: + +```bash +pkg update +``` + +Keep the path after `${ABI}` that `pkg -vv` shows for each repository +(`quarterly`, `latest`, `base_latest`, ...) and replace only the host part. + +The repository catalogues (`meta.conf`, `packagesite.pkg`, `data.pkg`) carry +their signature inside the archive and are served byte-for-byte unchanged +through the metadata cache (`metadata_ttl`, stale fallback). Enable +`cache_metadata` so `pkg update` keeps working while the upstream is down. +Packages under `Hashed/` have their content hash in the file name, so they are +stored in the shared artifact cache as `pkg:bsd/freebsd/{name}@{version}` and +remain available when the upstream is unreachable. + +Other files are passed through uncached. That includes the bootstrap package +under `Latest/`, whose signature file is rewritten with every repository build, +and package files without a hash in their name, which can be rebuilt under the +same name. The denylist still applies to unhashed package files. Since only +cached artifacts can be scanned, both kinds of package file are refused while +a scanner covers `freebsd`; use repositories built with `pkg repo --hash` and +bootstrap pkg without the proxy. + ### GitHub Releases / mise (aqua backend) Configure named generic upstreams: @@ -1171,6 +1228,7 @@ Recently cached: | `GET /v2/homebrew/core/*` | Homebrew core bottle manifests and blobs from GHCR | | `GET /apk/{repository}/*` | Alpine APK repository protocol | | `GET /arch/{repository}/*` | Arch Linux pacman repository protocol | +| `GET /freebsd/{repository}/*` | FreeBSD pkg repository protocol | | `GET /generic/{name}/*` | Generic HTTP download proxy (GitHub release assets, mise/aqua) | | `GET /git/{name}/{repo}/info/refs`, `POST /git/{name}/{repo}/git-upload-pack` | Git smart HTTP protocol, read-only | | `GET /debian/*` | Debian/APT repository protocol (main archive) | diff --git a/config.example.yaml b/config.example.yaml index 75348580..f7852327 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -315,6 +315,14 @@ upstream: # archlinux: "https://geo.mirror.pkgbuild.com" # archlinuxarm: "https://.mirror.archlinuxarm.org" + # Named FreeBSD pkg mirrors (used by /freebsd/{name}/), for ports and + # pkgbase repositories alike. + # Defaults to {"freebsd": "https://pkg.freebsd.org"} when empty; + # configuring any entry replaces that default. + # freebsd: + # freebsd: "https://pkg.freebsd.org" + # poudriere: "https://packages.example.com" + # Named generic HTTP upstreams (used by /generic/{name}/). The remaining # request path and query are appended to the upstream URL. GitHub release # assets ({owner}/{repo}/releases/download/{tag}/{asset}) are cached diff --git a/docs/configuration.md b/docs/configuration.md index 6f7be456..017d991f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -124,6 +124,7 @@ The package rule covers every release and asset in that repository, but each cac | `conda` | yes | `pkg:conda/conda-forge/numpy`, with the channel as the namespace; the package name's case doesn't matter. A key without a channel is refused. | | `cran` | yes | `pkg:cran/ggplot2`, the package name as published, case included (`pkg:cran/Rcpp`). A rule covers source and binary builds alike. | | `deb` | yes | `pkg:deb/curl`; case doesn't matter. A vendor namespace such as `pkg:deb/debian/curl` is accepted and dropped, so a rule covers the package from every configured archive. Debian and Ubuntu archives only keep the current version per suite, so an evicted older one can't be fetched again. | +| `freebsd` | yes | `pkg:bsd/freebsd/zsh`, the package name as published, case included (`pkg:bsd/freebsd/FreeBSD-acct`). A rule covers the package in every configured repository and ABI. Only packages under `Hashed/` are cached, and mirrors keep only the current build, so an evicted older one can't be fetched again. | | `gem` | yes | `pkg:gem/rails`, the gem name as published, case included. | | `generic` | yes, explicit rules only | `pkg:generic/owner%2Frepo` for a GitHub Release repository such as `jqlang/jq`. The ecosystem rule covers all cached version-pinned generic downloads, including GitHub Release assets and configured `generic_artifacts`. The global `default` never applies; metadata/API responses are not artifact retention candidates. | | `golang` | yes | `pkg:golang/github.com/stretchr/testify`, the module path as in `go.mod`; case doesn't matter. A major version like `/v2` (or `.v3` on gopkg.in) is a module of its own and needs its own rule. The `!`-escaped form from proxy URLs and the module cache (`github.com/!azure/...`) is refused. | @@ -537,6 +538,32 @@ When `upstream.arch` is empty, a single repository named `archlinux` pointing at that default. Arch Linux ARM has no default: pick a regional HTTPS mirror, not the `mirror.archlinuxarm.org` redirector. +```yaml +upstream: + + # Named FreeBSD pkg mirrors, served at /freebsd/{name}/. + freebsd: + freebsd: "https://pkg.freebsd.org" +``` + +FreeBSD pkg repositories are read-only. Requests to `/freebsd/{name}/…` are +passed to the mirror unchanged, e.g. +`/freebsd/freebsd/FreeBSD:14:amd64/quarterly/packagesite.pkg`, so ports and +pkgbase repositories and repositories built with poudriere work. Catalogues +(`meta`, `meta.conf`, `packagesite` and `data` as `.pkg`, `.txz` or `.tzst`) +are cached with the metadata TTL and served byte-for-byte unchanged, which +keeps their embedded signatures valid. Packages under `Hashed/` use the shared +artifact cache. Other package files (`.pkg`, `.txz`, `.tzst`), unhashed ones +and the bootstrap package under `Latest/`, can be rebuilt under the same name, +so they are passed through uncached after the [denylist](#version-denylist) +check on their `{name}-{version}`. Only cached artifacts are scanned, so while +a scanner covers the `freebsd` ecosystem these files are refused with +`403 Forbidden`: serve repositories built with `pkg repo --hash` (as the +official mirror is) and bootstrap pkg without the proxy. Everything else, such +as signatures and keys, is passed through uncached. When `upstream.freebsd` is empty, a single +repository named `freebsd` pointing at `https://pkg.freebsd.org` is available; +configuring any entry replaces that default. + ### Go checksum database ```yaml diff --git a/go.mod b/go.mod index 3c1d4e2f..0bcea254 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/git-pkgs/gcs v0.1.0 github.com/git-pkgs/integrity v0.2.0 github.com/git-pkgs/magic v0.5.0 - github.com/git-pkgs/purl v0.1.22 + github.com/git-pkgs/purl v0.1.23 github.com/git-pkgs/registries v0.11.0 github.com/git-pkgs/sbom v0.1.7 github.com/git-pkgs/spdx v0.3.3 diff --git a/go.sum b/go.sum index f486b50a..f298c284 100644 --- a/go.sum +++ b/go.sum @@ -133,8 +133,8 @@ github.com/git-pkgs/magic v0.5.0 h1:Pm+4fkHR+K6kg9pwJM4hnwWUuBlkYdLmF1yIAI8lU9Q= github.com/git-pkgs/magic v0.5.0/go.mod h1:SXOqcsNmbmpZjJZHEEWnwxprbsFvpwWgTAZrgXp4Jm4= github.com/git-pkgs/pom v0.1.8 h1:mXCuWw6XkrKKZb6IurRZyIznYLPhV5AYg5ZO38Uj/l8= github.com/git-pkgs/pom v0.1.8/go.mod h1:sQBeYRzjCUn77fcABPn6I/a1aBHFxm55jTmkNxYtGtw= -github.com/git-pkgs/purl v0.1.22 h1:qT9FNXT/3/RbWnQCxMHn1CFeuruMsq0RBH154oLt1Xs= -github.com/git-pkgs/purl v0.1.22/go.mod h1:l8rCfvt5iXVrFIcoQ8+vCLOaHhrtEW2id0LENP+IzMw= +github.com/git-pkgs/purl v0.1.23 h1:RG7F73S4ZnbAnB/posWf2WS4OhdG0xUbyX8UIkEUhPw= +github.com/git-pkgs/purl v0.1.23/go.mod h1:l8rCfvt5iXVrFIcoQ8+vCLOaHhrtEW2id0LENP+IzMw= github.com/git-pkgs/registries v0.11.0 h1:kkB80NjO9UncyyxGfKmdPXWqUMNE/CkNw5Z/6gqW7Rs= github.com/git-pkgs/registries v0.11.0/go.mod h1:bKxgdy/PwnzQXL2FnOktmSQHTKk6B85MA4VtvvYItPc= github.com/git-pkgs/sbom v0.1.7 h1:4eboy/Yed/SC9ZcC/xT50iywHPzWbmCU7azDb5T1RbA= diff --git a/internal/config/config.go b/internal/config/config.go index cbb4e6e1..ce8e4518 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -750,6 +750,12 @@ type UpstreamConfig struct { // Default when empty: {"archlinux": "https://geo.mirror.pkgbuild.com"}. Arch map[string]string `json:"arch" yaml:"arch"` + // FreeBSD maps repository names to FreeBSD pkg mirror base URLs, served + // at /freebsd/{name}/. The remaining request path mirrors the upstream + // layout, e.g. /freebsd/freebsd/FreeBSD:14:amd64/latest/packagesite.pkg. + // Default when empty: {"freebsd": "https://pkg.freebsd.org"}. + FreeBSD map[string]string `json:"freebsd" yaml:"freebsd"` + // OCI maps names to OCI registry URLs. Requests to a named registry use // the repository prefix upstream/{name}/, for example // oci://proxy.example.com/upstream/ghcr/owner/chart. @@ -839,6 +845,9 @@ func (u *UpstreamConfig) Validate() error { if err := validateNamedUpstreams("upstream.arch", u.Arch); err != nil { return err } + if err := validateNamedUpstreams("upstream.freebsd", u.FreeBSD); err != nil { + return err + } if err := validateNamedUpstreams("upstream.go_sumdbs", u.GoSumDBs); err != nil { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 224c6517..71fe7549 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1901,3 +1901,24 @@ func TestGenericUpstreamNamedHosted(t *testing.T) { t.Errorf("upstream.generic named hosted with hosting on: %v, want it refused", err) } } + +func TestValidateFreeBSDUpstreams(t *testing.T) { + tests := []struct { + name string + freebsd map[string]string + wantErr bool + }{ + {"valid", map[string]string{"freebsd": "https://pkg.freebsd.org", "poudriere": "https://packages.example.com"}, false}, + {"name contains path separator", map[string]string{"freebsd/ports": "https://pkg.freebsd.org"}, true}, + {"URL is not absolute", map[string]string{"freebsd": "pkg.freebsd.org"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.Upstream.FreeBSD = tt.freebsd + if err := cfg.Validate(); (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/handler/freebsd.go b/internal/handler/freebsd.go new file mode 100644 index 00000000..aceffa4f --- /dev/null +++ b/internal/handler/freebsd.go @@ -0,0 +1,256 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/git-pkgs/proxy/internal/retention" + "github.com/git-pkgs/purl" +) + +const ( + freebsdEcosystem = "freebsd" + // defaultFreeBSDRepositoryName is the repository name used when no + // upstream.freebsd repositories are configured. + defaultFreeBSDRepositoryName = "freebsd" + // defaultFreeBSDUpstream is the official FreeBSD package mirror. + defaultFreeBSDUpstream = "https://pkg.freebsd.org" + freebsdNamespace = "freebsd" + freebsdMatchCount = 3 // full match + name + version + freebsdHashedDir = "Hashed" + freebsdPackageMedia = "application/octet-stream" +) + +// Packages are stored as pkg:bsd/freebsd/{name}, whichever configured +// repository they came from. +func init() { + retention.Default.Register(retention.Spec{Key: freebsdEcosystem, CanonicalPackage: freebsdRetentionKey}) +} + +// freebsdRetentionKey maps pkg:bsd/freebsd/{name} to the stored package PURL. +// Another namespace, or a name with a slash, can never match a stored package +// and is refused. +func freebsdRetentionKey(p *purl.PURL) (string, bool) { + if p == nil || p.Type != "bsd" || p.Namespace != freebsdNamespace || strings.Contains(p.Name, "/") { + return "", false + } + canonical := purl.MakePURLString(freebsdEcosystem, p.Name, "") + return canonical, canonical != "" +} + +// FreeBSDHandler serves FreeBSD pkg repositories, ports and pkgbase alike. +// Each configured upstream is mounted at /freebsd/{repository}/ and the rest +// of the path mirrors the upstream layout ({ABI}/{repo}/{file}). +// +// Repository catalogues (meta.conf, packagesite.pkg, data.pkg) carry their +// signature inside the archive, so they are served byte for byte through the +// metadata cache. Packages under Hashed/ have the content hash in their name +// and never change, so they go to the artifact cache. Other package files, +// unhashed ones and the bootstrap package under Latest/, can be rebuilt under +// the same name and are passed through after the denylist; while a scanner +// covers freebsd they are refused, since only cached artifacts are scanned. +// Everything else, such as signatures and keys, is passed through. +type FreeBSDHandler struct { + proxy *Proxy + proxyURL string + repositories map[string]string +} + +// NewFreeBSDHandler creates a FreeBSD pkg repository handler. Without +// repositories it serves the official mirror as "freebsd". +func NewFreeBSDHandler(proxy *Proxy, proxyURL string, repositories map[string]string) *FreeBSDHandler { + h := &FreeBSDHandler{ + proxy: proxy, + proxyURL: strings.TrimSuffix(proxyURL, "/"), + repositories: make(map[string]string, len(repositories)), + } + for name, repositoryURL := range repositories { + h.repositories[name] = strings.TrimSuffix(repositoryURL, "/") + } + if len(h.repositories) == 0 { + h.repositories[defaultFreeBSDRepositoryName] = defaultFreeBSDUpstream + } + return h +} + +// Routes returns the HTTP handler for pkg requests. +// Mount this at /freebsd on your router. +func (h *FreeBSDHandler) Routes() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + path := strings.TrimPrefix(r.URL.Path, "/") + + // A decoded '?' or '#' would cut the upstream path short and cache one + // file under another file's name. + if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + repository, rest, ok := strings.Cut(path, "/") + upstreamURL, found := h.repositories[repository] + if !ok || rest == "" || !found { + http.NotFound(w, r) + return + } + + switch { + case isFreeBSDCatalogue(rest): + h.handleMetadata(w, r, repository, upstreamURL, rest) + case isFreeBSDHashedPackage(rest): + h.handlePackageDownload(w, r, repository, upstreamURL, rest) + case isFreeBSDPackageFile(rest): + h.handleUncachedPackage(w, r, upstreamURL, rest) + default: + h.proxyFile(w, r, upstreamURL, rest) + } + }) +} + +// freebsdCataloguePattern matches the files pkg reads to update a repository: +// meta and meta.conf, and the packagesite and data catalogues in the current +// (.pkg) and older (.txz, .tzst) packing formats. +var freebsdCataloguePattern = regexp.MustCompile(`^(?:meta(?:\.conf|\.txz|\.tzst|\.pkg)?|(?:packagesite|data)\.(?:pkg|txz|tzst))$`) + +func freebsdBase(path string) string { + return path[strings.LastIndex(path, "/")+1:] +} + +// isFreeBSDCatalogue reports whether path names a repository catalogue. The +// depth isn't checked, so repositories built with poudriere and served under +// another layout work too; package files are always {name}-{version}.pkg and +// can't take these names. +func isFreeBSDCatalogue(path string) bool { + return freebsdCataloguePattern.MatchString(freebsdBase(path)) +} + +// freebsdHashedPattern matches {name}-{version}~{hash}.pkg. Port versions +// never contain '-', so the version is what follows the last one, e.g. +// zsh-navigation-tools-2.2.7~2$imxi7k7w.pkg or +// FreeBSD-syslogd-man-14.snap20261019150821~2$k6xtent4.pkg. +var freebsdHashedPattern = regexp.MustCompile(`^(.+)-([^-~/]+)~[^~/]+\.pkg$`) + +// isFreeBSDHashedPackage reports whether path names a package in a Hashed/ +// directory: All/Hashed/ for ports, Hashed/ for pkgbase. +func isFreeBSDHashedPackage(path string) bool { + dir := strings.TrimSuffix(path, "/"+freebsdBase(path)) + return freebsdBase(dir) == freebsdHashedDir && freebsdHashedPattern.MatchString(freebsdBase(path)) +} + +// parseFreeBSDPackage returns the name and version of a hashed package file. +func parseFreeBSDPackage(filename string) (name, version string) { + matches := freebsdHashedPattern.FindStringSubmatch(filename) + if len(matches) != freebsdMatchCount { + return "", "" + } + return matches[1], matches[2] +} + +// freebsdUnhashedPattern matches {name}-{version} package files in the current +// (.pkg) and older (.txz, .tzst) packing formats, split at the last '-' like +// freebsdHashedPattern. +var freebsdUnhashedPattern = regexp.MustCompile(`^(.+)-([^-~/]+)\.(?:pkg|txz|tzst)$`) + +// freebsdPackageFilePattern matches every package file name, including +// Latest/pkg.pkg, which has no version. Catalogues match it too and must be +// routed first. +var freebsdPackageFilePattern = regexp.MustCompile(`\.(?:pkg|txz|tzst)$`) + +// isFreeBSDPackageFile reports whether path names a package file. +func isFreeBSDPackageFile(path string) bool { + return freebsdPackageFilePattern.MatchString(freebsdBase(path)) +} + +// parseFreeBSDPackageFile returns the name and version of any package file, +// hashed or not, or empty strings for a file without a version such as +// Latest/pkg.pkg. +func parseFreeBSDPackageFile(filename string) (name, version string) { + if name, version = parseFreeBSDPackage(filename); name != "" { + return name, version + } + matches := freebsdUnhashedPattern.FindStringSubmatch(filename) + if len(matches) != freebsdMatchCount { + return "", "" + } + return matches[1], matches[2] +} + +// errFreeBSDUnscannable refuses package files that can't be cached while +// scanning covers freebsd. +var errFreeBSDUnscannable = errors.New("package file can't be scanned: only packages under Hashed/ are cached and scanned; build the repository with pkg repo --hash") + +// handleUncachedPackage passes a package file outside Hashed/ through. Its +// name may be reused for a rebuild with other bytes, which pkg would then +// reject against the catalogue checksum, so it is never cached. The denylist +// applies when the name carries a version. Scanners only see cached +// artifacts, so the file is refused while one covers freebsd. +func (h *FreeBSDHandler) handleUncachedPackage(w http.ResponseWriter, r *http.Request, upstreamURL, path string) { + name, version := parseFreeBSDPackageFile(freebsdBase(path)) + if name != "" && h.proxy.versionDenied(freebsdEcosystem, name, version) { + h.proxy.serveArtifactError(w, fmt.Errorf("%w: %s", ErrVersionDenied, canonicalVersionPURL(freebsdEcosystem, name, version)), "") + return + } + if h.proxy.Scanners.Covers(freebsdEcosystem) { + h.proxy.Logger.Warn("refusing unscannable freebsd package", "path", path) + JSONError(w, http.StatusForbidden, errFreeBSDUnscannable.Error()) + return + } + h.proxyFile(w, r, upstreamURL, path) +} + +// handlePackageDownload caches hashed packages. The cache key holds the +// repository and full path, since every ABI and repository has its own +// builds. +func (h *FreeBSDHandler) handlePackageDownload(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string) { + name, version := parseFreeBSDPackage(freebsdBase(path)) + if name == "" { + h.proxyFile(w, r, upstreamURL, path) + return + } + + downloadURL := upstreamURL + "/" + path + cacheFilename := repository + "/" + path + + h.proxy.Logger.Info("freebsd package download", + "repository", repository, "name", name, "version", version) + + result, err := h.proxy.GetOrFetchArtifactFromURL( + r.Context(), freebsdEcosystem, name, version, cacheFilename, downloadURL) + if err != nil { + h.proxy.serveArtifactError(w, err, "failed to fetch package") + return + } + + if result.Artifact.MediaType == "" { + result.Artifact.MediaType = freebsdPackageMedia + } + ServeArtifactRequest(w, r, result) +} + +// handleMetadata serves catalogues unchanged through the metadata cache, +// which keeps their embedded signatures valid. +func (h *FreeBSDHandler) handleMetadata(w http.ResponseWriter, r *http.Request, repository, upstreamURL, path string) { + h.proxy.ProxyCached(w, r, upstreamURL+"/"+path, freebsdEcosystem, + h.metadataCacheKey(repository, upstreamURL, path), "*/*") +} + +// metadataCacheKey hashes repository, upstream URL and path, as APKHandler does. +func (h *FreeBSDHandler) metadataCacheKey(repository, upstreamURL, path string) string { + identity := repository + "\x00" + upstreamURL + "\x00" + path + digest := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(digest[:]) +} + +// proxyFile passes other files through uncached. +func (h *FreeBSDHandler) proxyFile(w http.ResponseWriter, r *http.Request, upstreamURL, path string) { + h.proxy.ProxyFile(w, r, upstreamURL+"/"+path) +} diff --git a/internal/handler/freebsd_test.go b/internal/handler/freebsd_test.go new file mode 100644 index 00000000..4702f34d --- /dev/null +++ b/internal/handler/freebsd_test.go @@ -0,0 +1,407 @@ +package handler + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/scanner" + "github.com/git-pkgs/purl" + "github.com/git-pkgs/registries/fetch" +) + +const freebsdABI = "/FreeBSD:14:amd64" + +func TestFreeBSDPathClassification(t *testing.T) { + tests := []struct { + path string + catalogue bool + hashed bool + }{ + {"FreeBSD:14:amd64/latest/meta.conf", true, false}, + {"FreeBSD:14:amd64/latest/meta", true, false}, + {"FreeBSD:14:amd64/latest/packagesite.pkg", true, false}, + {"FreeBSD:14:amd64/latest/data.pkg", true, false}, + {"FreeBSD:13:amd64/quarterly/packagesite.txz", true, false}, + {"142amd64-default/packagesite.tzst", true, false}, + {"FreeBSD:14:amd64/latest/All/Hashed/zsh-5.9_5~2$3rwh1rgp.pkg", false, true}, + {"FreeBSD:15:amd64/base_latest/Hashed/FreeBSD-acct-15.snap20261021214449~2$k6xtent4.pkg", false, true}, + {"FreeBSD:14:amd64/latest/All/zsh-5.9_5.pkg", false, false}, + {"FreeBSD:15:amd64/base_latest/FreeBSD-acct-15.snap20261021214449.pkg", false, false}, + {"FreeBSD:14:amd64/latest/Latest/pkg.pkg", false, false}, + {"FreeBSD:14:amd64/latest/Latest/pkg.pkg.sig", false, false}, + {"FreeBSD:14:amd64/latest/All/Hashed/no-hash.pkg", false, false}, + } + for _, tt := range tests { + if got := isFreeBSDCatalogue(tt.path); got != tt.catalogue { + t.Errorf("isFreeBSDCatalogue(%q) = %v, want %v", tt.path, got, tt.catalogue) + } + if got := isFreeBSDHashedPackage(tt.path); got != tt.hashed { + t.Errorf("isFreeBSDHashedPackage(%q) = %v, want %v", tt.path, got, tt.hashed) + } + } +} + +func TestParseFreeBSDPackage(t *testing.T) { + tests := []struct { + filename, name, version string + }{ + {"zsh-5.9_5~2$3rwh1rgp.pkg", "zsh", "5.9_5"}, + {"zsh-navigation-tools-2.2.7~2$imxi7k7w.pkg", "zsh-navigation-tools", "2.2.7"}, + {"py311-requests-2.32.3_1,1~2$abcdefgh.pkg", "py311-requests", "2.32.3_1,1"}, + {"FreeBSD-syslogd-man-14.snap20261019150821~2$k6xtent4.pkg", "FreeBSD-syslogd-man", "14.snap20261019150821"}, + {"nohash-1.0.pkg", "", ""}, + } + for _, tt := range tests { + name, version := parseFreeBSDPackage(tt.filename) + if name != tt.name || version != tt.version { + t.Errorf("parseFreeBSDPackage(%q) = %q, %q, want %q, %q", tt.filename, name, version, tt.name, tt.version) + } + } +} + +func TestParseFreeBSDPackageFile(t *testing.T) { + tests := []struct { + filename, name, version string + pkgfile bool + }{ + {"zsh-5.9_5~2$3rwh1rgp.pkg", "zsh", "5.9_5", true}, + {"zsh-5.9_5.pkg", "zsh", "5.9_5", true}, + {"zsh-navigation-tools-2.2.7.pkg", "zsh-navigation-tools", "2.2.7", true}, + {"py311-requests-2.32.3_1,1.txz", "py311-requests", "2.32.3_1,1", true}, + {"FreeBSD-acct-15.snap20261021214449.tzst", "FreeBSD-acct", "15.snap20261021214449", true}, + {"pkg.pkg", "", "", true}, + {"pkg.txz", "", "", true}, + {"pkg.pkg.sig", "", "", false}, + {"pkg.pkg.pub", "", "", false}, + {"zsh-5.9_5.pkg.sig", "", "", false}, + {"zsh-5.9_5.tar", "", "", false}, + } + for _, tt := range tests { + name, version := parseFreeBSDPackageFile(tt.filename) + if name != tt.name || version != tt.version { + t.Errorf("parseFreeBSDPackageFile(%q) = %q, %q, want %q, %q", tt.filename, name, version, tt.name, tt.version) + } + if got := isFreeBSDPackageFile("FreeBSD:14:amd64/latest/All/" + tt.filename); got != tt.pkgfile { + t.Errorf("isFreeBSDPackageFile(%q) = %v, want %v", tt.filename, got, tt.pkgfile) + } + } +} + +func TestFreeBSDHandler_Routes(t *testing.T) { + h := NewFreeBSDHandler(nil, "http://localhost:8080", nil) + assertRoutesBasics(t, h.Routes(), "/freebsd"+freebsdABI+"/latest/meta.conf", "/freebsd"+freebsdABI+"/../../../etc/passwd") +} + +func TestFreeBSDHandler_DefaultsToOfficialMirror(t *testing.T) { + h := NewFreeBSDHandler(nil, "http://localhost:8080", nil) + if got := h.repositories[defaultFreeBSDRepositoryName]; got != defaultFreeBSDUpstream { + t.Errorf("default repository = %q, want %q", got, defaultFreeBSDUpstream) + } +} + +func TestFreeBSDHandler_UnknownRepositoryReturns404(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewFreeBSDHandler(proxy, "http://localhost:8080", map[string]string{"freebsd": "https://example.test"}) + for _, target := range []string{"/unknown" + freebsdABI + "/latest/meta.conf", "/freebsd", "/"} { + if w := serveFreeBSDRequest(h, target); w.Code != http.StatusNotFound { + t.Errorf("%s: status = %d, want 404", target, w.Code) + } + } +} + +// fakeFreeBSDMirror serves files and counts requests per path. It answers +// 503 for everything while down is set. +type fakeFreeBSDMirror struct { + *httptest.Server + down atomic.Bool + mu sync.Mutex + requests map[string]int +} + +func newFakeFreeBSDMirror(t *testing.T, files map[string]string) *fakeFreeBSDMirror { + t.Helper() + m := &fakeFreeBSDMirror{requests: map[string]int{}} + m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if m.down.Load() { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + body, ok := files[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + m.mu.Lock() + m.requests[r.URL.Path]++ + m.mu.Unlock() + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(m.Close) + return m +} + +func (m *fakeFreeBSDMirror) count(path string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.requests[path] +} + +func TestFreeBSDHandler_CataloguesServedUnchanged(t *testing.T) { + files := map[string]string{ + freebsdABI + "/latest/meta.conf": "version = 2;\npacking_format = \"tzst\";\n", + freebsdABI + "/latest/packagesite.pkg": "\x28\xb5\x2f\xfdpackagesite.yaml and its signature", + freebsdABI + "/latest/data.pkg": "\x28\xb5\x2f\xfddata and its signature", + } + mirror := newFakeFreeBSDMirror(t, files) + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + proxy.HTTPClient = mirror.Client() + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + for round, down := range []bool{false, true} { + mirror.down.Store(down) + for path, want := range files { + w := serveFreeBSDRequest(h, "/freebsd"+path) + if w.Code != http.StatusOK || w.Body.String() != want { + t.Fatalf("round %d %s: status = %d, body %q, want 200 and the upstream bytes", round, path, w.Code, w.Body) + } + } + } + for path := range files { + if got := mirror.count(path); got != 1 { + t.Errorf("%s fetched %d times, want 1", path, got) + } + } +} + +func TestFreeBSDHandler_HashedPackagesCachedPerABI(t *testing.T) { + const file = "/latest/All/Hashed/zsh-5.9_5~2$3rwh1rgp.pkg" + files := map[string]string{ + "/FreeBSD:14:amd64" + file: "amd64 package", + "/FreeBSD:14:aarch64" + file: "aarch64 package", + } + mirror := newFakeFreeBSDMirror(t, files) + proxy, db, _, _ := setupTestProxy(t) + fetcher := fetch.NewFetcher(fetch.WithHTTPClient(mirror.Client()), fetch.WithMaxRetries(0)) + proxy.Fetcher = fetcher + t.Cleanup(func() { _ = fetcher.Close() }) + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + for round, down := range []bool{false, true} { + mirror.down.Store(down) + for path, want := range files { + w := serveFreeBSDRequest(h, "/freebsd"+path) + if w.Code != http.StatusOK || w.Body.String() != want { + t.Fatalf("round %d %s: status = %d, body %q, want 200 and %q", round, path, w.Code, w.Body, want) + } + } + } + for path := range files { + if got := mirror.count(path); got != 1 { + t.Errorf("%s fetched %d times, want 1", path, got) + } + } + + record, err := db.GetArtifact("pkg:bsd/freebsd/zsh@5.9_5", "freebsd/FreeBSD:14:amd64"+file) + if err != nil || record == nil { + t.Errorf("artifact record for pkg:bsd/freebsd/zsh@5.9_5 = %v, %v", record, err) + } +} + +// Unhashed packages can be rebuilt under the same name, and the bootstrap +// files under Latest/ change with every build, so none of them are cached. +// Without a denylist entry or a scanner they are passed through. +func TestFreeBSDHandler_UnhashedFilesPassedThrough(t *testing.T) { + files := map[string]string{ + freebsdABI + "/latest/Latest/pkg.pkg": "bootstrap package", + freebsdABI + "/latest/Latest/pkg.pkg.sig": "bootstrap signature", + freebsdABI + "/latest/All/zsh-5.9_5.pkg": "unhashed package", + } + mirror := newFakeFreeBSDMirror(t, files) + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + proxy.HTTPClient = mirror.Client() + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + for range 2 { + for path, want := range files { + w := serveFreeBSDRequest(h, "/freebsd"+path) + if w.Code != http.StatusOK || w.Body.String() != want { + t.Fatalf("%s: status = %d, body %q, want 200 and %q", path, w.Code, w.Body, want) + } + } + } + for path := range files { + if got := mirror.count(path); got != 2 { + t.Errorf("%s fetched %d times, want 2 (not cached)", path, got) + } + } +} + +// Package files without a hash in their name are checked against the +// denylist like hashed ones, before anything is fetched. +func TestFreeBSDHandler_DenylistCoversEveryPackageFile(t *testing.T) { + denied := []string{ + freebsdABI + "/latest/All/Hashed/zsh-5.9_5~2$3rwh1rgp.pkg", + freebsdABI + "/latest/All/zsh-5.9_5.pkg", + freebsdABI + "/latest/All/zsh-5.9_5~2$3rwh1rgp.pkg", + "/142amd64-default/All/zsh-5.9_5.txz", + "/142amd64-default/All/zsh-5.9_5.tzst", + } + allowed := map[string]string{ + freebsdABI + "/latest/All/zsh-5.9_4.pkg": "older build", + freebsdABI + "/latest/All/zsh-navigation-tools-5.9_5.pkg": "another package", + } + files := map[string]string{} + for _, path := range denied { + files[path] = "denied package" + } + for path, body := range allowed { + files[path] = body + } + mirror := newFakeFreeBSDMirror(t, files) + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = mirror.Client() + fetcher := fetch.NewFetcher(fetch.WithHTTPClient(mirror.Client()), fetch.WithMaxRetries(0)) + proxy.Fetcher = fetcher + t.Cleanup(func() { _ = fetcher.Close() }) + setTestDenylist(t, proxy, "pkg:bsd/freebsd/zsh@5.9_5") + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + for _, path := range denied { + if w := serveFreeBSDRequest(h, "/freebsd"+path); w.Code != http.StatusForbidden { + t.Errorf("%s: status = %d, want 403", path, w.Code) + } + if got := mirror.count(path); got != 0 { + t.Errorf("%s fetched %d times, want 0", path, got) + } + } + for path, want := range allowed { + if w := serveFreeBSDRequest(h, "/freebsd"+path); w.Code != http.StatusOK || w.Body.String() != want { + t.Errorf("%s: status = %d, body %q, want 200 and %q", path, w.Code, w.Body, want) + } + } +} + +// Package files the proxy can't cache can't be scanned either, so they are +// refused while a scanner covers freebsd. Catalogues, signatures and hashed +// packages are unaffected. +func TestFreeBSDHandler_ScanningRefusesUncachablePackages(t *testing.T) { + refused := []string{ + freebsdABI + "/latest/All/zsh-5.9_5.pkg", + freebsdABI + "/latest/Latest/pkg.pkg", + "/142amd64-default/Latest/pkg.txz", + "/142amd64-default/All/zsh-5.9_5.tzst", + } + served := map[string]string{ + freebsdABI + "/latest/meta.conf": "version = 2;\n", + freebsdABI + "/latest/Latest/pkg.pkg.sig": "bootstrap signature", + freebsdABI + "/latest/Latest/pkg.pkg.pub": "bootstrap key", + } + files := map[string]string{} + for _, path := range refused { + files[path] = "unscanned package" + } + for path, body := range served { + files[path] = body + } + + for _, ecosystems := range [][]string{nil, {"freebsd"}} { + mirror := newFakeFreeBSDMirror(t, files) + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = mirror.Client() + proxy.Scanners = newFreeBSDScanGroup(t, ecosystems) + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + for _, path := range refused { + w := serveFreeBSDRequest(h, "/freebsd"+path) + if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "scan") { + t.Errorf("ecosystems %v %s: status = %d, body %q, want 403 naming the scan", ecosystems, path, w.Code, w.Body) + } + if got := mirror.count(path); got != 0 { + t.Errorf("ecosystems %v %s fetched %d times, want 0", ecosystems, path, got) + } + } + for path, want := range served { + if w := serveFreeBSDRequest(h, "/freebsd"+path); w.Code != http.StatusOK || w.Body.String() != want { + t.Errorf("ecosystems %v %s: status = %d, body %q, want 200 and %q", ecosystems, path, w.Code, w.Body, want) + } + } + } +} + +// A scanner restricted to other ecosystems never sees FreeBSD packages, so +// unhashed files are passed through as without scanning. +func TestFreeBSDHandler_ScannerForOtherEcosystemsPassesThrough(t *testing.T) { + const path = freebsdABI + "/latest/All/zsh-5.9_5.pkg" + mirror := newFakeFreeBSDMirror(t, map[string]string{path: "unhashed package"}) + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = mirror.Client() + proxy.Scanners = newFreeBSDScanGroup(t, []string{"npm"}) + h := NewFreeBSDHandler(proxy, "http://proxy.example", map[string]string{"freebsd": mirror.URL}) + + if w := serveFreeBSDRequest(h, "/freebsd"+path); w.Code != http.StatusOK || w.Body.String() != "unhashed package" { + t.Errorf("status = %d, body %q, want 200 and the upstream bytes", w.Code, w.Body) + } +} + +func newFreeBSDScanGroup(t *testing.T, ecosystems []string) *scanner.Group { + t.Helper() + g, err := scanner.NewGroup(config.ScanningConfig{ + Enabled: true, + SigningKey: "test-signing-key", + Scanners: []config.ScannerConfig{ + {Name: "test-scanner", URL: "http://scanner.invalid/scan", Ecosystems: ecosystems}, + }, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("scanner.NewGroup() error: %v", err) + } + return g +} + +func serveFreeBSDRequest(h *FreeBSDHandler, target string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + return w +} + +func TestFreeBSDRetentionKey(t *testing.T) { + tests := []struct { + key string + want string + ok bool + }{ + {"pkg:bsd/freebsd/zsh", "pkg:bsd/freebsd/zsh", true}, + {"pkg:bsd/FreeBSD/FreeBSD-acct", "pkg:bsd/freebsd/FreeBSD-acct", true}, + {"pkg:bsd/freebsd/libstdc++", "pkg:bsd/freebsd/libstdc%2B%2B", true}, + {"pkg:bsd/netbsd/zsh", "", false}, + {"pkg:bsd/zsh", "", false}, + {"pkg:bsd/freebsd/latest%2Fzsh", "", false}, + {"pkg:apk/freebsd/zsh", "", false}, + } + for _, tt := range tests { + p, err := purl.Parse(tt.key) + if err != nil { + t.Fatalf("Parse(%s): %v", tt.key, err) + } + got, ok := freebsdRetentionKey(p) + if ok != tt.ok || (ok && got != tt.want) { + t.Errorf("freebsdRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok) + } + } + if _, ok := freebsdRetentionKey(nil); ok { + t.Error("freebsdRetentionKey(nil) accepted") + } +} diff --git a/internal/handler/notfound_ecosystems_test.go b/internal/handler/notfound_ecosystems_test.go index e8b3b9b0..cc61928e 100644 --- a/internal/handler/notfound_ecosystems_test.go +++ b/internal/handler/notfound_ecosystems_test.go @@ -23,6 +23,8 @@ func TestArtifactDownloadUpstreamNotFoundReturns404(t *testing.T) { func(p *Proxy) http.Handler { return NewAPKHandler(p, "http://localhost", nil).Routes() }}, {"arch", "/archlinux/core/os/x86_64/bash-5.2.037-1-x86_64.pkg.tar.zst", func(p *Proxy) http.Handler { return NewArchHandler(p, "http://localhost", nil).Routes() }}, + {"freebsd", "/freebsd/FreeBSD:14:amd64/latest/All/Hashed/zsh-5.9_5~2$3rwh1rgp.pkg", + func(p *Proxy) http.Handler { return NewFreeBSDHandler(p, "http://localhost", nil).Routes() }}, {"nuget", "/v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", func(p *Proxy) http.Handler { return NewNuGetHandler(p, "http://localhost").Routes() }}, {"pypi", "/packages/packages/ab/cd/ef0123456789/requests-2.31.0-py3-none-any.whl", diff --git a/internal/retention/retention.go b/internal/retention/retention.go index 465b2206..a8ff8359 100644 --- a/internal/retention/retention.go +++ b/internal/retention/retention.go @@ -22,7 +22,7 @@ import ( // outside this list is a typo; a key in it whose handler has not registered // yet is not supported by this version. var KnownKeys = []string{ - "alpine", "cargo", "composer", "conan", "conda", "cran", "deb", "gem", + "alpine", "cargo", "composer", "conan", "conda", "cran", "deb", "freebsd", "gem", "generic", "golang", "helm", "hex", "julia", "maven", "npm", "nuget", "oci", "pub", "pypi", "rpm", "swift", } diff --git a/internal/scanner/group.go b/internal/scanner/group.go index a31d3249..227a0370 100644 --- a/internal/scanner/group.go +++ b/internal/scanner/group.go @@ -86,6 +86,11 @@ func (g *Group) Enabled() bool { return g != nil && len(g.entries) > 0 } +// Covers reports whether any scanner, in either mode, applies to ecosystem. +func (g *Group) Covers(ecosystem string) bool { + return g.Enabled() && len(g.applicable(ecosystem)) > 0 +} + // Timeout returns the per-scan-call timeout used to bound the signed fetch // URL's validity. func (g *Group) Timeout() time.Duration { diff --git a/internal/scanner/group_test.go b/internal/scanner/group_test.go index e5b40eef..48cbebca 100644 --- a/internal/scanner/group_test.go +++ b/internal/scanner/group_test.go @@ -77,6 +77,33 @@ func TestGroup_Enabled(t *testing.T) { } } +func TestGroup_Covers(t *testing.T) { + var nilGroup *Group + if nilGroup.Covers("npm") { + t.Error("nil group Covers(npm) = true, want false") + } + g, err := NewGroup(config.ScanningConfig{ + Enabled: true, + SigningKey: "test-signing-key", + Scanners: []config.ScannerConfig{ + {Name: "trivy", URL: "http://trivy.invalid", Mode: "monitor", Ecosystems: []string{"npm"}}, + {Name: "clamav", URL: "http://clamav.invalid", Ecosystems: []string{"pypi"}}, + }, + }, discardLogger()) + if err != nil { + t.Fatalf("NewGroup() error: %v", err) + } + for ecosystem, want := range map[string]bool{"npm": true, "pypi": true, "freebsd": false} { + if got := g.Covers(ecosystem); got != want { + t.Errorf("Covers(%s) = %v, want %v", ecosystem, got, want) + } + } + all := newTestGroup([]entry{{mode: modeBlock}}, false) + if !all.Covers("freebsd") { + t.Error("unrestricted scanner: Covers(freebsd) = false, want true") + } +} + func TestGroup_NewGroup_InvalidMode(t *testing.T) { _, err := NewGroup(config.ScanningConfig{ Enabled: true, diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index fece033c..da866a6a 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -145,6 +145,7 @@ func supportedEcosystems() []string { "conda", "cran", "deb", + "freebsd", "gem", "golang", "hex", @@ -213,6 +214,8 @@ func ecosystemColorClasses(ecosystem string) string { return "bg-lime-100 text-lime-800 dark:bg-lime-900/50 dark:text-lime-300" case "arch": return "bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300" + case "freebsd": + return "bg-red-200 text-red-900 dark:bg-red-950/60 dark:text-red-200" default: return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" } @@ -553,5 +556,25 @@ Server = ` + baseURL + `/arch/archlinux/$repo/os/$arch # Then run: pacman -Syu`), }, + freebsdRegistryConfig(baseURL), + } +} + +// freebsdRegistryConfig describes the FreeBSD pkg endpoint for the dashboard. +func freebsdRegistryConfig(baseURL string) RegistryConfig { + return RegistryConfig{ + ID: "freebsd", + Name: "FreeBSD pkg", + Language: "FreeBSD", + Endpoint: "/freebsd/", + Instructions: template.HTML(`

Configure pkg to use the proxy. On FreeBSD 15, do the same for FreeBSD-ports-kmods and FreeBSD-base, keeping their paths; FreeBSD 14 calls the ports repository FreeBSD.

+
# /usr/local/etc/pkg/repos/FreeBSD.conf
+FreeBSD-ports: {
+  url: "` + baseURL + `/freebsd/freebsd/${ABI}/quarterly",
+  mirror_type: "none"
+}
+
+# Then run:
+pkg update
`), } } diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 0de0dba5..abcf004d 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -107,7 +107,7 @@ func requestEcosystem(path string) string { switch segment { case "npm", "cargo", "hex", "pub", "pypi", "maven", "gradle", "nuget", "conan", "chef", "conda", "cran", "julia", "debian", "rpm", "arch", - "helm", "homebrew", "generic", "swift", "git": + "freebsd", "helm", "homebrew", "generic", "swift", "git": return segment case "apk": return "alpine" diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go index b9c67c79..7bdd0f95 100644 --- a/internal/server/middleware_test.go +++ b/internal/server/middleware_test.go @@ -313,7 +313,7 @@ func TestRequestEcosystemCoversEveryMountedRoute(t *testing.T) { "/gradle/x": "gradle", "/nuget/x": "nuget", "/composer/x": "packagist", "/conan/x": "conan", "/chef/x": "chef", "/conda/x": "conda", "/cran/x": "cran", "/julia/x": "julia", "/swift/x": "swift", "/v2/x": "oci", - "/apk/x": "alpine", "/arch/x": "arch", "/debian/x": "debian", "/rpm/x": "rpm", + "/apk/x": "alpine", "/arch/x": "arch", "/freebsd/x": "freebsd", "/debian/x": "debian", "/rpm/x": "rpm", "/helm/x": "helm", "/homebrew/x": "homebrew", "/generic/x": "generic", "/git/x": "git", } for path, want := range mounts { diff --git a/internal/server/server.go b/internal/server/server.go index 8a0a683a..adbf9afe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -21,6 +21,7 @@ // - /v2/* - OCI/Docker container registry protocol // - /apk/* - Alpine APK repository protocol // - /arch/* - Arch Linux pacman repository protocol +// - /freebsd/* - FreeBSD pkg repository protocol // - /debian/* - Debian/APT repository protocol // - /rpm/* - RPM/Yum repository protocol // @@ -484,6 +485,7 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm, s.cfg.Upstream.OCIDefault, s.cfg.Upstream.OCI) apkHandler := handler.NewAPKHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.APK) archHandler := handler.NewArchHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Arch) + freebsdHandler := handler.NewFreeBSDHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.FreeBSD) debianHandler := handler.NewDebianHandler( proxy, s.cfg.BaseURL, @@ -516,6 +518,7 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes())) r.Mount("/apk", http.StripPrefix("/apk", apkHandler.Routes())) r.Mount("/arch", http.StripPrefix("/arch", archHandler.Routes())) + r.Mount("/freebsd", http.StripPrefix("/freebsd", freebsdHandler.Routes())) r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) r.Mount("/generic", http.StripPrefix("/generic", genericHandler.Routes())) diff --git a/internal/server/sources.go b/internal/server/sources.go index 2a0e47ce..90f39810 100644 --- a/internal/server/sources.go +++ b/internal/server/sources.go @@ -65,6 +65,7 @@ var knownClients = map[string]string{ "urlgrabber": "dnf", "apk": "apk", "pacman": "pacman", + "pkg": "pkg", "conda": "conda", "mamba": "conda", "conan": "conan", diff --git a/internal/server/sources_test.go b/internal/server/sources_test.go index 560ca93d..c37313c3 100644 --- a/internal/server/sources_test.go +++ b/internal/server/sources_test.go @@ -22,6 +22,7 @@ func TestClientName(t *testing.T) { {"Debian APT-HTTP/1.3 (2.6.1)", "apt"}, {"bundler/2.4.22 rubygems/3.4.22", "bundler"}, {"pacman/7.0.0 (Linux x86_64) libalpm/15.0.0", "pacman"}, + {"pkg/2.8.4", "pkg"}, // Unknown and hostile input collapses, so a User-Agent cannot mint a // new Prometheus time series. {"", unknownClient},