Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Currently works with npm, PyPI, pub.dev, Composer, and Cargo, which all include
| NuGet | .NET | Yes | ✓ |
| Composer | PHP | Yes | ✓ |
| Conan | C/C++ | | ✓ |
| Conda | Python/R | | ✓ |
| Conda | Python/R | Yes | ✓ |
| CRAN | R | | ✓ |
| Container | Docker/OCI | | ✓ |
| Debian | Debian/Ubuntu | | ✓ |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Durations support days (`7d`), hours (`48h`), and minutes (`30m`). Set to `0` to

Resolution order: package override, then ecosystem override, then global default. This lets you set a conservative default while exempting trusted packages.

Currently supported for npm, PyPI, pub.dev, Composer, Cargo, and NuGet. These ecosystems include publish timestamps in their metadata.
Currently supported for npm, PyPI, pub.dev, Composer, Cargo, NuGet, and Conda. These ecosystems include publish timestamps in their metadata.

## Docker

Expand Down
117 changes: 115 additions & 2 deletions internal/handler/conda.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package handler

import (
"encoding/json"
"io"
"net/http"
"strings"
"time"

"github.com/git-pkgs/purl"
)

const (
Expand Down Expand Up @@ -31,9 +36,9 @@ func (h *CondaHandler) Routes() http.Handler {
mux := http.NewServeMux()

// Channel index (repodata)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.proxyUpstream)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json", h.handleRepodata)
mux.HandleFunc("GET /{channel}/{arch}/repodata.json.bz2", h.proxyUpstream)
mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.proxyUpstream)
mux.HandleFunc("GET /{channel}/{arch}/current_repodata.json", h.handleRepodata)

// Package downloads (cache these)
mux.HandleFunc("GET /{channel}/{arch}/{filename}", h.handleDownload)
Expand Down Expand Up @@ -119,6 +124,114 @@ func (h *CondaHandler) parseFilename(filename string) (name, version string) {
return name, version
}

// handleRepodata proxies repodata.json, applying cooldown filtering when enabled.
func (h *CondaHandler) handleRepodata(w http.ResponseWriter, r *http.Request) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
h.proxyUpstream(w, r)
return
}

upstreamURL := h.upstreamURL + r.URL.Path

h.proxy.Logger.Debug("fetching repodata for cooldown filtering", "url", upstreamURL)

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
req.Header.Set("Accept-Encoding", "gzip")

resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}

body, err := ReadMetadata(resp.Body)
if err != nil {
http.Error(w, "failed to read response", http.StatusInternalServerError)
return
}

filtered, err := h.applyCooldownFiltering(body)
if err != nil {
h.proxy.Logger.Warn("failed to filter repodata, proxying original", "error", err)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
return
}

w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(filtered)
}

// condaTimestampDivisor converts Conda's millisecond timestamps to seconds.
const condaTimestampDivisor = 1000

// applyCooldownFiltering removes entries from repodata.json that were
// published too recently based on their timestamp field.
func (h *CondaHandler) applyCooldownFiltering(body []byte) ([]byte, error) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
return body, nil
}

var repodata map[string]any
if err := json.Unmarshal(body, &repodata); err != nil {
return nil, err
}

for _, key := range []string{"packages", "packages.conda"} {
packages, ok := repodata[key].(map[string]any)
if !ok {
continue
}

for filename, entry := range packages {
entryMap, ok := entry.(map[string]any)
if !ok {
continue
}

ts, ok := entryMap["timestamp"].(float64)
if !ok || ts == 0 {
continue
}

publishedAt := time.Unix(int64(ts)/condaTimestampDivisor, 0)

name, _ := entryMap["name"].(string)
if name == "" {
continue
}

packagePURL := purl.MakePURLString("conda", name, "")

if !h.proxy.Cooldown.IsAllowed("conda", packagePURL, publishedAt) {
version, _ := entryMap["version"].(string)
h.proxy.Logger.Info("cooldown: filtering conda package",
"name", name, "version", version, "filename", filename)
delete(packages, filename)
}
}
}

return json.Marshal(repodata)
}

// proxyUpstream forwards a request to Anaconda without caching.
func (h *CondaHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
h.proxy.ProxyUpstream(w, r, h.upstreamURL+r.URL.Path, []string{"Accept-Encoding"})
Expand Down
Loading