2026-08-16 22:42:55 +05:30
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"path"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"gopkg.in/yaml.v3"
|
2026-10-11 01:55:16 +02:00
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
2026-08-16 22:42:55 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
helmMetadataEcosystem = "helm"
|
|
|
|
|
helmIndexFilename = "index.yaml"
|
|
|
|
|
sha256HexLength = 64
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-11 01:55:16 +02:00
|
|
|
// Helm takes no package rules: charts are cached under the configured
|
|
|
|
|
// repository's name and the chart digest, not under the chart name, so a key
|
|
|
|
|
// like pkg:helm/nginx would name a repository. The helm rule and the default
|
|
|
|
|
// cover charts from HTTP repositories; charts pulled over OCI go through the
|
|
|
|
|
// container handler and fall under oci.
|
|
|
|
|
func init() {
|
|
|
|
|
retention.Default.Register(retention.Spec{Key: "helm"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 22:42:55 +05:30
|
|
|
// HelmHandler serves read-only HTTP Helm chart repositories. Each configured
|
|
|
|
|
// repository is mounted at /helm/{repository}/.
|
|
|
|
|
type HelmHandler struct {
|
2026-09-19 14:39:29 +05:30
|
|
|
proxy *Proxy
|
|
|
|
|
proxyURL string
|
|
|
|
|
repositories map[string]string
|
|
|
|
|
ociRegistries []helmOCIRegistry
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewHelmHandler creates a Helm chart repository protocol handler.
|
|
|
|
|
func NewHelmHandler(proxy *Proxy, proxyURL string, repositories map[string]string) *HelmHandler {
|
|
|
|
|
h := &HelmHandler{
|
|
|
|
|
proxyURL: strings.TrimSuffix(proxyURL, "/"),
|
|
|
|
|
repositories: make(map[string]string, len(repositories)),
|
|
|
|
|
proxy: proxy,
|
|
|
|
|
}
|
|
|
|
|
for name, repositoryURL := range repositories {
|
|
|
|
|
h.repositories[name] = strings.TrimSuffix(repositoryURL, "/")
|
|
|
|
|
}
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Routes returns the HTTP handler for Helm chart repository requests.
|
|
|
|
|
func (h *HelmHandler) Routes() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("GET /{repository}/index.yaml", h.handleIndex)
|
|
|
|
|
mux.HandleFunc("GET /{repository}/charts/{digest}/{filename}", h.handleChart)
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
repository, upstreamURL, ok := h.repositoryForRequest(r)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, contentType, err := h.fetchIndex(r, repository, upstreamURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.serveIndexError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rewritten, err := h.rewriteIndex(repository, upstreamURL, body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Warn("failed to rewrite Helm index", "repository", repository, "error", err)
|
|
|
|
|
http.Error(w, "invalid Helm repository index", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.proxy.writeMetadataCachedResponse(w, r, helmMetadataEcosystem, h.indexCacheKey(repository, upstreamURL), rewritten, contentType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) handleChart(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
repository, upstreamURL, ok := h.repositoryForRequest(r)
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
digest, ok := normalizeHelmDigest(r.PathValue("digest"))
|
|
|
|
|
filename := r.PathValue("filename")
|
|
|
|
|
if !ok || filename == "" || strings.Contains(filename, "/") || containsPathTraversal(filename) {
|
|
|
|
|
http.Error(w, "invalid chart request", http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cached, err := h.proxy.GetCachedArtifact(r.Context(), helmMetadataEcosystem, repository, digest, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.Logger.Error("failed to check Helm chart cache", "error", err)
|
|
|
|
|
http.Error(w, "failed to check chart cache", http.StatusInternalServerError)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if cached != nil {
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
h.serveChart(w, r, repository, digest, filename, cached)
|
2026-08-16 22:42:55 +05:30
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, _, err := h.fetchIndex(r, repository, upstreamURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
h.serveIndexError(w, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
downloadURL, err := h.findChartDownload(upstreamURL, body, digest, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, errHelmChartNotFound) {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.proxy.Logger.Warn("failed to read Helm index", "repository", repository, "error", err)
|
|
|
|
|
http.Error(w, "invalid Helm repository index", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
Add storage.cache_artifacts to serve artifacts without storing them (#387)
* Add storage.passthrough to serve artifacts without storing them
With storage.passthrough enabled the proxy streams every artifact from
upstream to the client and never writes it to storage or the cache
database. Metadata filtering, cooldown and the denylist work as before,
so the proxy can sit behind another cache (e.g. an Artifactory remote)
purely as a policy layer without holding a second copy of every package.
Artifacts whose digest is known up front (OCI, Swift, Helm) are verified
while streaming. Their responses are sent chunked and the connection is
aborted on a mismatch, so a client never receives a tampered artifact as
a complete response.
Passthrough is rejected together with scanning, direct_serve and
mirror_api, and the mirror command refuses to run with it, since all of
them depend on stored artifacts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Address review: abort truncated streams, guard every cache read
- serveArtifact aborts the response on any body read error, and when fewer
bytes than the declared size were written, not only on a digest mismatch.
A failed or short upstream body in passthrough mode no longer reaches the
client as a complete 200.
- Streamed upstream read failures are logged and counted as
stream_failed upstream errors before the response is aborted.
- checkCache reports a miss when artifact caching is off, so every cache
read path, including Swift archive HEAD requests, ignores entries stored
before the mode was enabled. Denylisted versions are still rejected.
- Rename storage.passthrough to storage.cache_artifacts (default true,
PROXY_STORAGE_CACHE_ARTIFACTS), which names what actually changes.
Tests cover an OCI blob shorter than its Content-Length and an npm tarball
whose chunked response ends without the final chunk, both served through
the HTTP handlers, plus a Swift HEAD request against a cached archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename Proxy.Passthrough to StreamArtifacts
Match the storage.cache_artifacts option: the field says what changes
(artifacts are streamed instead of stored) and its zero value keeps the
usual caching behaviour for every Proxy built without the server config,
such as the mirror command and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:28:20 +07:00
|
|
|
// A streamed fetch is never stored, so serveChart's digest check has
|
|
|
|
|
// nothing to compare against: verify the stream itself instead.
|
|
|
|
|
expectedDigest := ""
|
|
|
|
|
if h.proxy.StreamArtifacts {
|
|
|
|
|
expectedDigest = "sha256:" + digest
|
|
|
|
|
}
|
|
|
|
|
result, err := h.proxy.GetOrFetchArtifactFromURLWithDigest(
|
|
|
|
|
r.Context(), helmMetadataEcosystem, repository, digest, filename, downloadURL, expectedDigest)
|
2026-08-16 22:42:55 +05:30
|
|
|
if err != nil {
|
|
|
|
|
h.proxy.serveArtifactError(w, err, "failed to fetch chart")
|
|
|
|
|
return
|
|
|
|
|
}
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
h.serveChart(w, r, repository, digest, filename, result)
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
func (h *HelmHandler) serveChart(w http.ResponseWriter, r *http.Request, repository, digest, filename string, result *CacheResult) {
|
2026-09-04 10:58:22 +01:00
|
|
|
if !strings.EqualFold(result.Artifact.Digest.Encoded(), digest) {
|
2026-08-16 22:42:55 +05:30
|
|
|
if result.Reader != nil {
|
|
|
|
|
_ = result.Reader.Close()
|
|
|
|
|
}
|
2026-09-29 10:24:22 -07:00
|
|
|
if clearErr := h.proxy.ClearCachedArtifact(helmMetadataEcosystem, repository, digest, filename); clearErr != nil {
|
2026-08-16 22:42:55 +05:30
|
|
|
h.proxy.Logger.Warn("failed to clear Helm chart with invalid digest", "error", clearErr)
|
|
|
|
|
}
|
|
|
|
|
http.Error(w, "chart digest verification failed", http.StatusBadGateway)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 10:58:22 +01:00
|
|
|
if result.Artifact.MediaType == "" {
|
2026-09-02 13:22:02 +01:00
|
|
|
w.Header().Set(headerContentType, "application/gzip")
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
ServeArtifactRequest(w, r, result)
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) repositoryForRequest(r *http.Request) (name, upstreamURL string, ok bool) {
|
|
|
|
|
name = r.PathValue("repository")
|
|
|
|
|
upstreamURL, ok = h.repositories[name]
|
|
|
|
|
return name, upstreamURL, ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) fetchIndex(r *http.Request, repository, upstreamURL string) ([]byte, string, error) {
|
|
|
|
|
return h.proxy.FetchOrCacheMetadata(
|
|
|
|
|
r.Context(),
|
|
|
|
|
helmMetadataEcosystem,
|
|
|
|
|
h.indexCacheKey(repository, upstreamURL),
|
|
|
|
|
upstreamURL+"/"+helmIndexFilename,
|
|
|
|
|
"application/x-yaml, text/yaml;q=0.9, */*;q=0.1",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) indexCacheKey(repository, upstreamURL string) string {
|
|
|
|
|
identity := repository + "\x00" + upstreamURL
|
|
|
|
|
digest := sha256.Sum256([]byte(identity))
|
|
|
|
|
return hex.EncodeToString(digest[:])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) serveIndexError(w http.ResponseWriter, err error) {
|
|
|
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
|
|
|
http.Error(w, "Helm repository not found", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-15 13:35:12 +01:00
|
|
|
if writeUpstreamHeld(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-16 22:42:55 +05:30
|
|
|
h.proxy.Logger.Error("failed to fetch Helm index", "error", err)
|
|
|
|
|
http.Error(w, "failed to fetch Helm repository index", http.StatusBadGateway)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) rewriteIndex(repository, upstreamURL string, body []byte) ([]byte, error) {
|
|
|
|
|
document, entries, err := parseHelmIndex(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < len(entries.Content); i += 2 {
|
|
|
|
|
chartName := entries.Content[i].Value
|
|
|
|
|
releases := entries.Content[i+1]
|
|
|
|
|
if releases.Kind != yaml.SequenceNode {
|
|
|
|
|
return nil, fmt.Errorf("chart %q releases must be a sequence", chartName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filtered := make([]*yaml.Node, 0, len(releases.Content))
|
|
|
|
|
for _, release := range releases.Content {
|
|
|
|
|
chart, err := h.parseChartRelease(chartName, upstreamURL, release)
|
|
|
|
|
if err != nil {
|
2026-09-21 12:01:37 +01:00
|
|
|
h.proxy.Logger.Warn("omitting malformed Helm chart release",
|
|
|
|
|
"repository", repository, "chart", chartName, "error", err)
|
|
|
|
|
continue
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
if h.chartOnCooldown(chartName, chart.created) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, download := range chart.downloads {
|
2026-09-19 14:39:29 +05:30
|
|
|
if download.oci {
|
|
|
|
|
download.node.Value = h.ociChartProxyURL(download.url)
|
|
|
|
|
} else {
|
|
|
|
|
download.node.Value = h.chartProxyURL(repository, chart.digest, download.filename)
|
|
|
|
|
}
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
filtered = append(filtered, release)
|
|
|
|
|
}
|
|
|
|
|
releases.Content = filtered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return yaml.Marshal(document)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) findChartDownload(upstreamURL string, body []byte, digest, filename string) (string, error) {
|
|
|
|
|
_, entries, err := parseHelmIndex(body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for i := 0; i < len(entries.Content); i += 2 {
|
|
|
|
|
chartName := entries.Content[i].Value
|
|
|
|
|
releases := entries.Content[i+1]
|
|
|
|
|
if releases.Kind != yaml.SequenceNode {
|
|
|
|
|
return "", fmt.Errorf("chart %q releases must be a sequence", chartName)
|
|
|
|
|
}
|
|
|
|
|
for _, release := range releases.Content {
|
|
|
|
|
chart, err := h.parseChartRelease(chartName, upstreamURL, release)
|
|
|
|
|
if err != nil {
|
2026-09-21 12:01:37 +01:00
|
|
|
continue
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
if chart.digest != digest || h.chartOnCooldown(chartName, chart.created) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, download := range chart.downloads {
|
2026-09-19 14:39:29 +05:30
|
|
|
if !download.oci && download.filename == filename {
|
2026-08-16 22:42:55 +05:30
|
|
|
return download.url, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", errHelmChartNotFound
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) chartOnCooldown(chartName string, created time.Time) bool {
|
|
|
|
|
return !created.IsZero() && h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() &&
|
|
|
|
|
!h.proxy.Cooldown.IsAllowed(helmMetadataEcosystem, canonicalPackagePURL(helmMetadataEcosystem, chartName), created)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type helmChartDownload struct {
|
|
|
|
|
node *yaml.Node
|
|
|
|
|
url string
|
|
|
|
|
filename string
|
2026-09-19 14:39:29 +05:30
|
|
|
oci bool
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type helmChartRelease struct {
|
|
|
|
|
created time.Time
|
|
|
|
|
digest string
|
|
|
|
|
downloads []helmChartDownload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var errHelmChartNotFound = errors.New("chart not found in Helm index")
|
|
|
|
|
|
|
|
|
|
func (h *HelmHandler) parseChartRelease(chartName, upstreamURL string, release *yaml.Node) (helmChartRelease, error) {
|
|
|
|
|
digestNode := helmMappingValue(release, "digest")
|
|
|
|
|
urlsNode := helmMappingValue(release, "urls")
|
|
|
|
|
if digestNode == nil || urlsNode == nil || urlsNode.Kind != yaml.SequenceNode || len(urlsNode.Content) == 0 {
|
|
|
|
|
return helmChartRelease{}, fmt.Errorf("chart %q has no digest or URLs", chartName)
|
|
|
|
|
}
|
|
|
|
|
digest, ok := normalizeHelmDigest(digestNode.Value)
|
|
|
|
|
if !ok {
|
|
|
|
|
return helmChartRelease{}, fmt.Errorf("chart %q has invalid digest", chartName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
baseURL, err := url.Parse(upstreamURL + "/" + helmIndexFilename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return helmChartRelease{}, fmt.Errorf("parsing Helm repository URL: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chart := helmChartRelease{digest: digest}
|
|
|
|
|
if createdNode := helmMappingValue(release, "created"); createdNode != nil && createdNode.Value != "" {
|
|
|
|
|
chart.created, err = time.Parse(time.RFC3339Nano, createdNode.Value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return helmChartRelease{}, fmt.Errorf("chart %q has invalid creation time: %w", chartName, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, urlNode := range urlsNode.Content {
|
2026-09-19 14:39:29 +05:30
|
|
|
download, err := parseHelmChartDownload(chartName, baseURL, urlNode)
|
2026-08-16 22:42:55 +05:30
|
|
|
if err != nil {
|
2026-09-19 14:39:29 +05:30
|
|
|
return helmChartRelease{}, err
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
2026-09-19 14:39:29 +05:30
|
|
|
chart.downloads = append(chart.downloads, download)
|
2026-08-16 22:42:55 +05:30
|
|
|
}
|
|
|
|
|
return chart, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 14:39:29 +05:30
|
|
|
func parseHelmChartDownload(chartName string, baseURL *url.URL, node *yaml.Node) (helmChartDownload, error) {
|
|
|
|
|
if node.Kind != yaml.ScalarNode {
|
|
|
|
|
return helmChartDownload{}, fmt.Errorf("chart %q has invalid URL", chartName)
|
|
|
|
|
}
|
|
|
|
|
reference, err := url.Parse(node.Value)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return helmChartDownload{}, fmt.Errorf("parsing chart %q URL: %w", chartName, err)
|
|
|
|
|
}
|
|
|
|
|
if reference.Scheme == "oci" {
|
|
|
|
|
if reference.Hostname() == "" || strings.Trim(reference.Path, "/") == "" ||
|
|
|
|
|
reference.User != nil || reference.RawQuery != "" || reference.ForceQuery || reference.Fragment != "" ||
|
|
|
|
|
containsPathTraversal(reference.Path) {
|
|
|
|
|
return helmChartDownload{}, fmt.Errorf("chart %q has invalid OCI reference", chartName)
|
|
|
|
|
}
|
|
|
|
|
return helmChartDownload{node: node, url: node.Value, oci: true}, nil
|
|
|
|
|
}
|
|
|
|
|
downloadURL := baseURL.ResolveReference(reference)
|
|
|
|
|
if (downloadURL.Scheme != "http" && downloadURL.Scheme != "https") || downloadURL.Host == "" {
|
|
|
|
|
return helmChartDownload{}, fmt.Errorf("chart %q URL must be HTTP(S) or OCI", chartName)
|
|
|
|
|
}
|
|
|
|
|
filename := path.Base(downloadURL.Path)
|
|
|
|
|
if filename == "." || filename == "/" || filename == "" || !strings.HasSuffix(filename, ".tgz") {
|
|
|
|
|
return helmChartDownload{}, fmt.Errorf("chart %q URL must point to a .tgz file", chartName)
|
|
|
|
|
}
|
|
|
|
|
return helmChartDownload{node: node, url: downloadURL.String(), filename: filename}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 22:42:55 +05:30
|
|
|
func (h *HelmHandler) chartProxyURL(repository, digest, filename string) string {
|
|
|
|
|
return fmt.Sprintf("%s/helm/%s/charts/%s/%s", h.proxyURL,
|
|
|
|
|
url.PathEscape(repository), digest, url.PathEscape(filename))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func parseHelmIndex(body []byte) (*yaml.Node, *yaml.Node, error) {
|
|
|
|
|
var document yaml.Node
|
|
|
|
|
if err := yaml.Unmarshal(body, &document); err != nil {
|
|
|
|
|
return nil, nil, fmt.Errorf("parsing Helm index: %w", err)
|
|
|
|
|
}
|
|
|
|
|
entries, err := helmIndexEntries(&document)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, nil, err
|
|
|
|
|
}
|
|
|
|
|
return &document, entries, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func helmIndexEntries(document *yaml.Node) (*yaml.Node, error) {
|
|
|
|
|
if document == nil {
|
|
|
|
|
return nil, errors.New("helm index is empty")
|
|
|
|
|
}
|
|
|
|
|
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
|
|
|
|
return nil, errors.New("helm index must be a mapping")
|
|
|
|
|
}
|
|
|
|
|
entries := helmMappingValue(document.Content[0], "entries")
|
|
|
|
|
if entries == nil || entries.Kind != yaml.MappingNode {
|
|
|
|
|
return nil, errors.New("helm index has no entries mapping")
|
|
|
|
|
}
|
|
|
|
|
if len(entries.Content)%2 != 0 {
|
|
|
|
|
return nil, errors.New("helm index entries mapping has an incomplete key-value pair")
|
|
|
|
|
}
|
|
|
|
|
return entries, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func helmMappingValue(mapping *yaml.Node, key string) *yaml.Node {
|
|
|
|
|
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
for i := 0; i+1 < len(mapping.Content); i += 2 {
|
|
|
|
|
if mapping.Content[i].Value == key {
|
|
|
|
|
return mapping.Content[i+1]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeHelmDigest(value string) (string, bool) {
|
|
|
|
|
digest := strings.TrimPrefix(strings.ToLower(value), "sha256:")
|
|
|
|
|
if len(digest) != sha256HexLength {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
for _, char := range digest {
|
|
|
|
|
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return digest, true
|
|
|
|
|
}
|