Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-10-13 13:19:58 +02:00
|
|
|
"crypto/sha512"
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
"database/sql"
|
|
|
|
|
"encoding/base64"
|
2026-10-13 13:19:58 +02:00
|
|
|
"encoding/json"
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-10-13 13:42:34 +02:00
|
|
|
"slices"
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/database"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/metrics"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/packageurl"
|
|
|
|
|
"github.com/git-pkgs/proxy/internal/storage"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ErrHostedUnsupported = errors.New("pushing packages is not supported for this ecosystem")
|
|
|
|
|
ErrHostedInvalidName = errors.New("invalid package name or version")
|
|
|
|
|
ErrHostedVersionNotFound = errors.New("no pushed artifacts for this version")
|
2026-10-13 15:16:55 +02:00
|
|
|
|
|
|
|
|
// ErrHostedArtifactUnavailable and ErrHostedArtifactMissing report a
|
|
|
|
|
// pushed artifact the cache can't serve. They stop the caller from
|
|
|
|
|
// fetching the upstream copy, which would replace the pushed one.
|
|
|
|
|
ErrHostedArtifactUnavailable = errors.New("pushed artifact is stored but can't be read")
|
|
|
|
|
ErrHostedArtifactMissing = errors.New("pushed artifact is no longer stored")
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// HostedTarget is where a pushed archive is stored: the package name and
|
|
|
|
|
// version it is cached under and the filename clients request it by. Download
|
|
|
|
|
// handlers look artifacts up by exactly these values, so a push under any
|
|
|
|
|
// other name would never be served.
|
|
|
|
|
type HostedTarget struct {
|
|
|
|
|
Name string
|
|
|
|
|
Version string
|
|
|
|
|
Filename string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HostedArtifact describes one pushed archive.
|
|
|
|
|
type HostedArtifact struct {
|
|
|
|
|
Version string `json:"version"`
|
|
|
|
|
Filename string `json:"filename"`
|
|
|
|
|
SHA256 string `json:"sha256"`
|
|
|
|
|
Size int64 `json:"size"`
|
|
|
|
|
PushedAt time.Time `json:"pushed_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// hostedNaming describes how an ecosystem names pushed archives: which
|
|
|
|
|
// package names it has, split on "/", and the filename its clients request.
|
|
|
|
|
type hostedNaming struct {
|
|
|
|
|
validName func(segments []string) bool
|
|
|
|
|
filename func(name, version string) string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var hostedNamings = map[string]hostedNaming{
|
|
|
|
|
"npm": {
|
|
|
|
|
validName: func(segments []string) bool {
|
|
|
|
|
return len(segments) == 1 || (len(segments) == 2 && strings.HasPrefix(segments[0], "@"))
|
|
|
|
|
},
|
|
|
|
|
filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".tgz" },
|
|
|
|
|
},
|
|
|
|
|
"composer": {
|
|
|
|
|
validName: func(segments []string) bool { return len(segments) == vendorPackageParts },
|
|
|
|
|
filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".zip" },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HostedSupported reports whether packages can be pushed for ecosystem.
|
|
|
|
|
func HostedSupported(ecosystem string) bool {
|
|
|
|
|
_, ok := hostedNamings[ecosystem]
|
|
|
|
|
return ok
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func lastSegment(name string) string {
|
|
|
|
|
return name[strings.LastIndex(name, "/")+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HostedTargetFor validates a push of name@version to ecosystem and returns
|
|
|
|
|
// where it is stored.
|
|
|
|
|
func HostedTargetFor(ecosystem, name, version string) (HostedTarget, error) {
|
|
|
|
|
naming, ok := hostedNamings[ecosystem]
|
|
|
|
|
if !ok {
|
|
|
|
|
return HostedTarget{}, fmt.Errorf("%w: %s", ErrHostedUnsupported, ecosystem)
|
|
|
|
|
}
|
|
|
|
|
segments := strings.Split(name, "/")
|
|
|
|
|
if !naming.validName(segments) {
|
|
|
|
|
return HostedTarget{}, ErrHostedInvalidName
|
|
|
|
|
}
|
|
|
|
|
for _, segment := range append(segments, version) {
|
|
|
|
|
if !validHostedSegment(segment) {
|
|
|
|
|
return HostedTarget{}, ErrHostedInvalidName
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return HostedTarget{Name: name, Version: version, Filename: naming.filename(name, version)}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validHostedSegment(s string) bool {
|
|
|
|
|
if s == "" || s == "." || s == ".." || strings.ContainsAny(s, `/\`) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, c := range s {
|
|
|
|
|
if c < ' ' || c == 0x7f {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PushLocalArtifact stores body as the artifact for target and records it as
|
|
|
|
|
// local, replacing any cached upstream copy. Download handlers serve it from
|
|
|
|
|
// the cache without contacting the upstream, and eviction never removes it.
|
2026-10-13 13:19:58 +02:00
|
|
|
// The archive's manifest must name target; it is kept so the ecosystem's
|
|
|
|
|
// metadata can list the version with its dependencies.
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
func (p *Proxy) PushLocalArtifact(ctx context.Context, ecosystem string, target HostedTarget, body io.Reader, contentType string) (HostedArtifact, error) {
|
|
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, target.Name, target.Version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return HostedArtifact{}, err
|
|
|
|
|
}
|
|
|
|
|
if p.versionDenied(ecosystem, target.Name, target.Version) {
|
|
|
|
|
return HostedArtifact{}, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
push := storedPush{
|
|
|
|
|
storagePath: storage.FetchPath(ecosystem, target.Name, target.Version, storage.NewFetchID(), target.Filename),
|
|
|
|
|
contentType: contentType,
|
|
|
|
|
}
|
|
|
|
|
sha512Hash := sha512.New()
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
storeStart := time.Now()
|
2026-10-13 13:19:58 +02:00
|
|
|
push.size, push.sha256, err = p.Storage.Store(ctx, push.storagePath, io.TeeReader(body, sha512Hash))
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
metrics.RecordStorageOperation("write", time.Since(storeStart))
|
|
|
|
|
if err != nil {
|
|
|
|
|
metrics.RecordStorageError("write", metrics.FailureDetail{
|
2026-10-13 13:19:58 +02:00
|
|
|
PURL: versionPURL, Filename: target.Filename, Path: push.storagePath, Err: err})
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
return HostedArtifact{}, fmt.Errorf("storing artifact: %w", err)
|
|
|
|
|
}
|
2026-10-13 13:19:58 +02:00
|
|
|
push.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha512Hash.Sum(nil))
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
if err := p.checkPush(ctx, ecosystem, target, versionPURL, &push); err != nil {
|
|
|
|
|
p.discardStored(ctx, push.storagePath)
|
|
|
|
|
return HostedArtifact{}, err
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
push.pushedAt = time.Now()
|
|
|
|
|
if err := p.recordLocalArtifact(ecosystem, target, pkgPURL, versionPURL, push); err != nil {
|
|
|
|
|
p.discardStored(ctx, push.storagePath)
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
return HostedArtifact{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HostedArtifact{
|
|
|
|
|
Version: target.Version,
|
|
|
|
|
Filename: target.Filename,
|
2026-10-13 13:19:58 +02:00
|
|
|
SHA256: push.sha256,
|
|
|
|
|
Size: push.size,
|
|
|
|
|
PushedAt: push.pushedAt.UTC(),
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
// storedPush is a pushed archive once it is in storage.
|
|
|
|
|
type storedPush struct {
|
|
|
|
|
storagePath string
|
|
|
|
|
sha256 string
|
|
|
|
|
integrity string
|
|
|
|
|
size int64
|
|
|
|
|
contentType string
|
|
|
|
|
manifest json.RawMessage
|
|
|
|
|
pushedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// checkPush reads the manifest of a stored push and runs the scanners on it.
|
|
|
|
|
func (p *Proxy) checkPush(ctx context.Context, ecosystem string, target HostedTarget, versionPURL string, push *storedPush) error {
|
|
|
|
|
stored, err := p.Storage.Open(ctx, push.storagePath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("reading pushed archive: %w", err)
|
|
|
|
|
}
|
|
|
|
|
push.manifest, err = readHostedManifest(ecosystem, stored, target)
|
|
|
|
|
_ = stored.Close()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if p.Scanners != nil && p.Scanners.Enabled() {
|
|
|
|
|
return p.runScan(ctx, ecosystem, target.Name, target.Version, target.Filename, versionPURL, push.storagePath, push.size, push.contentType)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
func (p *Proxy) discardStored(ctx context.Context, storagePath string) {
|
|
|
|
|
if err := p.Storage.Delete(context.WithoutCancel(ctx), storagePath); err != nil {
|
|
|
|
|
p.Logger.Warn("failed to delete pushed artifact from storage", "path", storagePath, "error", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recordLocalArtifact writes the package, version and artifact records. The
|
|
|
|
|
// version's integrity is set to the pushed bytes, so the cache's integrity
|
|
|
|
|
// check verifies them rather than an upstream archive's digest. An existing
|
|
|
|
|
// package record is kept as it is, since it may describe the upstream package.
|
2026-10-13 13:19:58 +02:00
|
|
|
func (p *Proxy) recordLocalArtifact(ecosystem string, target HostedTarget, pkgPURL, versionPURL string, push storedPush) error {
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
existing, err := p.DB.GetPackageByPURL(pkgPURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("reading package: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if existing == nil {
|
|
|
|
|
if err := p.DB.UpsertPackage(&database.Package{PURL: pkgPURL, Ecosystem: ecosystem, Name: target.Name}); err != nil {
|
|
|
|
|
return fmt.Errorf("upserting package: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := p.DB.UpsertVersion(&database.Version{
|
|
|
|
|
PURL: versionPURL,
|
|
|
|
|
PackagePURL: pkgPURL,
|
2026-10-13 13:19:58 +02:00
|
|
|
Integrity: sql.NullString{String: push.integrity, Valid: true},
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
}); err != nil {
|
|
|
|
|
return fmt.Errorf("upserting version: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := p.DB.UpsertArtifact(&database.Artifact{
|
|
|
|
|
VersionPURL: versionPURL,
|
|
|
|
|
Filename: target.Filename,
|
2026-10-13 13:19:58 +02:00
|
|
|
StoragePath: sql.NullString{String: push.storagePath, Valid: true},
|
|
|
|
|
ContentHash: sql.NullString{String: push.sha256, Valid: true},
|
|
|
|
|
Size: sql.NullInt64{Int64: push.size, Valid: true},
|
|
|
|
|
ContentType: sql.NullString{String: push.contentType, Valid: push.contentType != ""},
|
|
|
|
|
FetchedAt: sql.NullTime{Time: push.pushedAt, Valid: true},
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
Source: database.SourceLocal,
|
2026-10-13 13:19:58 +02:00
|
|
|
Manifest: sql.NullString{String: string(push.manifest), Valid: true},
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
}); err != nil {
|
|
|
|
|
return fmt.Errorf("upserting artifact: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LocalArtifacts lists the archives pushed for a package.
|
|
|
|
|
func (p *Proxy) LocalArtifacts(ecosystem, name string) ([]HostedArtifact, error) {
|
|
|
|
|
pkgPURL := packageurl.MakeString(ecosystem, name, "")
|
|
|
|
|
if pkgPURL == "" {
|
|
|
|
|
return nil, fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
|
|
|
|
|
}
|
|
|
|
|
rows, err := p.DB.GetLocalArtifacts(pkgPURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("listing local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
listed := make([]HostedArtifact, 0, len(rows))
|
|
|
|
|
for _, row := range rows {
|
|
|
|
|
listed = append(listed, HostedArtifact{
|
|
|
|
|
Version: database.VersionFromPURL(row.VersionPURL),
|
|
|
|
|
Filename: row.Filename,
|
|
|
|
|
SHA256: row.ContentHash.String,
|
|
|
|
|
Size: row.Size.Int64,
|
|
|
|
|
PushedAt: row.FetchedAt.Time.UTC(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return listed, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:42:34 +02:00
|
|
|
// hostedOnly reports whether a package is in an npm scope or Composer vendor
|
|
|
|
|
// that is served from pushed packages alone. The upstream is never asked
|
|
|
|
|
// about such a package, so nobody can mix in versions by publishing the same
|
|
|
|
|
// name there.
|
|
|
|
|
func (p *Proxy) hostedOnly(ecosystem, name string) bool {
|
|
|
|
|
if !p.HostedEnabled {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
namespace, _, namespaced := strings.Cut(name, "/")
|
|
|
|
|
if !namespaced {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
switch ecosystem {
|
|
|
|
|
case "npm":
|
|
|
|
|
return slices.Contains(p.HostedNPMScopes, namespace)
|
|
|
|
|
case "composer":
|
|
|
|
|
return slices.ContainsFunc(p.HostedComposerVendors, func(vendor string) bool {
|
|
|
|
|
return strings.EqualFold(vendor, namespace)
|
|
|
|
|
})
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 13:19:58 +02:00
|
|
|
// localVersion is a pushed version as ecosystem metadata lists it.
|
|
|
|
|
type localVersion struct {
|
|
|
|
|
Version string
|
|
|
|
|
Filename string
|
|
|
|
|
SHA256 string
|
|
|
|
|
Integrity string
|
|
|
|
|
Manifest json.RawMessage
|
|
|
|
|
PushedAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// localVersions returns the versions pushed for a package, or nil when hosted
|
|
|
|
|
// packages are disabled, so metadata requests cost no extra lookup then.
|
2026-10-13 15:18:49 +02:00
|
|
|
// Denied versions are left out, so metadata never lists a version whose
|
|
|
|
|
// download is refused.
|
2026-10-13 13:19:58 +02:00
|
|
|
func (p *Proxy) localVersions(ecosystem, name string) ([]localVersion, error) {
|
|
|
|
|
if !p.HostedEnabled {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
pkgPURL := packageurl.MakeString(ecosystem, name, "")
|
|
|
|
|
if pkgPURL == "" {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
rows, err := p.DB.GetLocalArtifacts(pkgPURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("listing local versions: %w", err)
|
|
|
|
|
}
|
|
|
|
|
versions := make([]localVersion, 0, len(rows))
|
|
|
|
|
for _, row := range rows {
|
2026-10-13 15:18:49 +02:00
|
|
|
version := database.VersionFromPURL(row.VersionPURL)
|
|
|
|
|
if !row.Manifest.Valid || p.versionDenied(ecosystem, name, version) {
|
2026-10-13 13:19:58 +02:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
versions = append(versions, localVersion{
|
2026-10-13 15:18:49 +02:00
|
|
|
Version: version,
|
2026-10-13 13:19:58 +02:00
|
|
|
Filename: row.Filename,
|
|
|
|
|
SHA256: row.ContentHash.String,
|
|
|
|
|
Integrity: row.Integrity.String,
|
|
|
|
|
Manifest: json.RawMessage(row.Manifest.String),
|
|
|
|
|
PushedAt: row.FetchedAt.Time.UTC(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return versions, nil
|
|
|
|
|
}
|
|
|
|
|
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
// DeleteLocalVersion removes the archives pushed for name@version. If the
|
|
|
|
|
// upstream has the version, clients get the upstream archive again.
|
|
|
|
|
func (p *Proxy) DeleteLocalVersion(ecosystem, name, version string) error {
|
|
|
|
|
_, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
deleted, err := p.DB.DeleteLocalArtifacts(versionPURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("deleting local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if deleted == 0 {
|
|
|
|
|
return ErrHostedVersionNotFound
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-14 13:21:57 +02:00
|
|
|
// GetLocalArtifact serves a pushed artifact from storage, or returns nil
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
// when the artifact was not pushed. Handlers that must consult the upstream
|
|
|
|
|
// before serving a cached copy call it first, so pushed versions the upstream
|
2026-10-14 13:21:57 +02:00
|
|
|
// does not list are still served. Pushed artifacts are not a cache, so they
|
|
|
|
|
// are served even when StreamArtifacts is set.
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
func (p *Proxy) GetLocalArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
|
|
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
record, err := p.DB.GetArtifact(versionPURL, filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("checking artifact source: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if record == nil || record.Source != database.SourceLocal {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
2026-10-14 13:21:57 +02:00
|
|
|
if p.Denylist.Denied(versionPURL) {
|
|
|
|
|
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
|
|
|
|
|
}
|
|
|
|
|
result, err := p.readStored(ctx, pkgPURL, versionPURL, filename)
|
|
|
|
|
if err != nil || result != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
return nil, p.localArtifactMiss(versionPURL, filename)
|
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
|
|
|
}
|