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")
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
ErrHostedFileNotFound = errors.New("no pushed file of this name for this version")
|
|
|
|
|
|
|
|
|
|
// ErrHostedExists refuses a second push of the same file. Clients pin
|
|
|
|
|
// pushed files by version and often by hash, so a published file is never
|
|
|
|
|
// silently replaced; delete it first to push it again.
|
|
|
|
|
ErrHostedExists = errors.New("this file was already pushed; delete it first to push it again")
|
|
|
|
|
|
|
|
|
|
// ErrHostedChecksumMismatch reports a push whose bytes don't match the
|
|
|
|
|
// sha256 the client sent along.
|
|
|
|
|
ErrHostedChecksumMismatch = errors.New("pushed bytes don't match the expected sha256")
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// hostedEcosystem is what an ecosystem defines for pushed packages. Storing,
|
|
|
|
|
// listing, deleting and the push rules are shared; adding an ecosystem means
|
|
|
|
|
// adding an entry here and, if its clients read version lists, listing pushed
|
|
|
|
|
// versions in its metadata.
|
|
|
|
|
type hostedEcosystem struct {
|
|
|
|
|
// validName reports whether a package name, split on "/", is one of the
|
|
|
|
|
// ecosystem's.
|
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
|
|
|
validName func(segments []string) bool
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
|
|
|
|
|
// filename is the name clients request the archive by, derived from the
|
|
|
|
|
// package name and version. When nil, the filename is the last segment of
|
2026-10-20 20:21:01 +02:00
|
|
|
// the push path, the package name must be a single segment so the path is
|
|
|
|
|
// unambiguous, and a version can hold several files (see multiFile).
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
filename func(name, version string) string
|
|
|
|
|
|
|
|
|
|
// readManifest reads the package manifest out of a pushed archive and
|
|
|
|
|
// checks that it describes target. When nil, pushes carry no manifest.
|
|
|
|
|
readManifest func(archive io.Reader, target HostedTarget) (json.RawMessage, error)
|
|
|
|
|
|
|
|
|
|
// hostedOnly reports whether a namespace (an npm scope, a Composer
|
|
|
|
|
// vendor) is served from pushed packages alone. When nil, the ecosystem
|
|
|
|
|
// has no such namespaces.
|
|
|
|
|
hostedOnly func(p *Proxy, namespace string) bool
|
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
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
var hostedEcosystems = map[string]hostedEcosystem{
|
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
|
|
|
"npm": {
|
|
|
|
|
validName: func(segments []string) bool {
|
|
|
|
|
return len(segments) == 1 || (len(segments) == 2 && strings.HasPrefix(segments[0], "@"))
|
|
|
|
|
},
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".tgz" },
|
|
|
|
|
readManifest: readNPMManifest,
|
|
|
|
|
hostedOnly: func(p *Proxy, scope string) bool {
|
|
|
|
|
return slices.Contains(p.HostedNPMScopes, scope)
|
|
|
|
|
},
|
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
|
|
|
},
|
|
|
|
|
"composer": {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
validName: func(segments []string) bool { return len(segments) == vendorPackageParts },
|
|
|
|
|
filename: func(name, version string) string { return lastSegment(name) + "-" + version + ".zip" },
|
|
|
|
|
readManifest: readComposerManifest,
|
|
|
|
|
hostedOnly: func(p *Proxy, vendor string) bool {
|
|
|
|
|
return slices.ContainsFunc(p.HostedComposerVendors, func(v string) bool {
|
|
|
|
|
return strings.EqualFold(v, vendor)
|
|
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
// generic holds files that aren't packages, such as prebuilt binaries.
|
|
|
|
|
// They are served at /generic/hosted/{name}/{version}/{file} and never
|
|
|
|
|
// come from an upstream.
|
|
|
|
|
genericEcosystem: {
|
|
|
|
|
validName: func(segments []string) bool { return len(segments) == 1 },
|
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-20 20:21:01 +02:00
|
|
|
// multiFile reports whether a version can hold several files: those named in
|
|
|
|
|
// the push path. Such versions get no version-level integrity, which could
|
|
|
|
|
// only describe one of them.
|
|
|
|
|
func (e hostedEcosystem) multiFile() bool {
|
|
|
|
|
return e.filename == 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
|
|
|
// HostedSupported reports whether packages can be pushed for ecosystem.
|
|
|
|
|
func HostedSupported(ecosystem string) bool {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
_, ok := hostedEcosystems[ecosystem]
|
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 ok
|
|
|
|
|
}
|
|
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// HostedFileInPath reports whether pushes for ecosystem name the file in the
|
|
|
|
|
// path, {name}/{version}/{file}, rather than deriving it from the name and
|
|
|
|
|
// version.
|
|
|
|
|
func HostedFileInPath(ecosystem string) bool {
|
|
|
|
|
eco, ok := hostedEcosystems[ecosystem]
|
2026-10-20 20:21:01 +02:00
|
|
|
return ok && eco.multiFile()
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
}
|
|
|
|
|
|
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 lastSegment(name string) string {
|
|
|
|
|
return name[strings.LastIndex(name, "/")+1:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HostedTargetFor validates a push of name@version to ecosystem and returns
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// where it is stored. filename is required for ecosystems whose pushes name
|
|
|
|
|
// the file (see HostedFileInPath) and must be empty for the others.
|
|
|
|
|
func HostedTargetFor(ecosystem, name, version, filename string) (HostedTarget, error) {
|
2026-10-20 20:21:01 +02:00
|
|
|
if err := ValidateHostedPath(ecosystem, name, version, filename); err != nil {
|
|
|
|
|
return HostedTarget{}, err
|
|
|
|
|
}
|
|
|
|
|
eco := hostedEcosystems[ecosystem]
|
|
|
|
|
if eco.filename != nil {
|
|
|
|
|
filename = eco.filename(name, version)
|
|
|
|
|
} else if filename == "" {
|
|
|
|
|
return HostedTarget{}, ErrHostedInvalidName
|
|
|
|
|
}
|
|
|
|
|
return HostedTarget{Name: name, Version: version, Filename: filename}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ValidateHostedPath checks the parts of a hosted API path: a supported
|
|
|
|
|
// ecosystem, one of its package names, and path segments that are safe to
|
|
|
|
|
// store. filename may be empty, as in a delete of a whole version, but is
|
|
|
|
|
// only allowed for ecosystems whose pushes name the file.
|
|
|
|
|
func ValidateHostedPath(ecosystem, name, version, filename string) error {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
eco, ok := hostedEcosystems[ecosystem]
|
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
|
|
|
if !ok {
|
2026-10-20 20:21:01 +02:00
|
|
|
return fmt.Errorf("%w: %s", ErrHostedUnsupported, ecosystem)
|
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
|
|
|
}
|
|
|
|
|
segments := strings.Split(name, "/")
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
if !eco.validName(segments) {
|
2026-10-20 20:21:01 +02:00
|
|
|
return ErrHostedInvalidName
|
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
|
|
|
}
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
checked := append(slices.Clone(segments), version)
|
2026-10-20 20:21:01 +02:00
|
|
|
if filename != "" {
|
|
|
|
|
if !eco.multiFile() {
|
|
|
|
|
return ErrHostedInvalidName
|
|
|
|
|
}
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
checked = append(checked, filename)
|
|
|
|
|
}
|
|
|
|
|
for _, segment := range checked {
|
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
|
|
|
if !validHostedSegment(segment) {
|
2026-10-20 20:21:01 +02:00
|
|
|
return ErrHostedInvalidName
|
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-20 20:21:01 +02:00
|
|
|
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 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.
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
// A file that was already pushed is refused with ErrHostedExists. When
|
|
|
|
|
// wantSHA256 is set, the pushed bytes must hash to it. If the ecosystem has
|
|
|
|
|
// manifests, the archive's manifest must name target; it is kept so the
|
|
|
|
|
// ecosystem's metadata can list the version with its dependencies.
|
|
|
|
|
func (p *Proxy) PushLocalArtifact(ctx context.Context, ecosystem string, target HostedTarget, body io.Reader, contentType, wantSHA256 string) (HostedArtifact, error) {
|
2026-10-20 20:03:06 +02:00
|
|
|
if err := p.CheckPush(ecosystem, target); err != 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
|
|
|
return HostedArtifact{}, err
|
|
|
|
|
}
|
2026-10-20 20:03:06 +02:00
|
|
|
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, target.Name, target.Version)
|
|
|
|
|
if err != nil {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
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 := 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
|
|
|
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
if wantSHA256 != "" && !strings.EqualFold(wantSHA256, push.sha256) {
|
|
|
|
|
p.discardStored(ctx, push.storagePath)
|
|
|
|
|
return HostedArtifact{}, fmt.Errorf("%w: got %s", ErrHostedChecksumMismatch, push.sha256)
|
|
|
|
|
}
|
|
|
|
|
|
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-20 20:03:06 +02:00
|
|
|
// CheckPush refuses a push of a denied version or of a file that was already
|
|
|
|
|
// pushed, before any bytes are read, so a client can be told without
|
2026-10-21 12:33:43 +02:00
|
|
|
// uploading the file. For ecosystems with one file per version, any pushed
|
|
|
|
|
// file of the version counts, so a differently cased alias of the name is
|
|
|
|
|
// refused too. A cached upstream copy is fine to replace. Recording the push
|
|
|
|
|
// checks again atomically, for concurrent pushes.
|
2026-10-20 20:03:06 +02:00
|
|
|
func (p *Proxy) CheckPush(ecosystem string, target HostedTarget) error {
|
|
|
|
|
_, versionPURL, err := packagePURLStrings(ecosystem, target.Name, target.Version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if p.versionDenied(ecosystem, target.Name, target.Version) {
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
|
|
|
|
|
}
|
2026-10-21 12:33:43 +02:00
|
|
|
var existing []database.Artifact
|
|
|
|
|
if hostedEcosystems[ecosystem].multiFile() {
|
|
|
|
|
record, err := p.DB.GetArtifact(versionPURL, target.Filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking existing artifact: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if record != nil {
|
|
|
|
|
existing = append(existing, *record)
|
|
|
|
|
}
|
|
|
|
|
} else if existing, err = p.DB.GetArtifactsByVersionPURL(versionPURL); err != nil {
|
|
|
|
|
return fmt.Errorf("checking existing artifacts: %w", err)
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
}
|
2026-10-21 12:33:43 +02:00
|
|
|
for _, a := range existing {
|
|
|
|
|
if a.Source == database.SourceLocal {
|
|
|
|
|
return ErrHostedExists
|
|
|
|
|
}
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
}
|
|
|
|
|
return 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 {
|
2026-10-20 20:21:01 +02:00
|
|
|
if hostedEcosystems[ecosystem].readManifest != nil {
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-10-13 13:19:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 20:03:06 +02:00
|
|
|
// recordLocalArtifact writes the package, version and artifact records in
|
|
|
|
|
// one transaction. An existing package record is kept as it is, since it may
|
|
|
|
|
// describe the upstream package. For ecosystems with one file per version,
|
|
|
|
|
// 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.
|
|
|
|
|
// Multi-file versions can't have one integrity, so an existing version record
|
|
|
|
|
// is left alone and each file is checked against its own sha256.
|
2026-10-13 13:19:58 +02:00
|
|
|
func (p *Proxy) recordLocalArtifact(ecosystem string, target HostedTarget, pkgPURL, versionPURL string, push storedPush) error {
|
2026-10-20 20:21:01 +02:00
|
|
|
oneFilePerVersion := !hostedEcosystems[ecosystem].multiFile()
|
2026-10-20 20:03:06 +02:00
|
|
|
err := p.DB.RecordLocalArtifact(
|
|
|
|
|
&database.Package{PURL: pkgPURL, Ecosystem: ecosystem, Name: target.Name},
|
|
|
|
|
&database.Version{
|
|
|
|
|
PURL: versionPURL,
|
|
|
|
|
PackagePURL: pkgPURL,
|
|
|
|
|
Integrity: sql.NullString{String: push.integrity, Valid: oneFilePerVersion},
|
|
|
|
|
},
|
|
|
|
|
oneFilePerVersion,
|
|
|
|
|
&database.Artifact{
|
|
|
|
|
VersionPURL: versionPURL,
|
|
|
|
|
Filename: target.Filename,
|
|
|
|
|
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},
|
|
|
|
|
Manifest: sql.NullString{String: string(push.manifest), Valid: push.manifest != nil},
|
|
|
|
|
})
|
|
|
|
|
if errors.Is(err, database.ErrLocalArtifactExists) {
|
|
|
|
|
return ErrHostedExists
|
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-20 20:03:06 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("recording pushed artifact: %w", 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 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, "/")
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
eco := hostedEcosystems[ecosystem]
|
|
|
|
|
return namespaced && eco.hostedOnly != nil && eco.hostedOnly(p, namespace)
|
2026-10-13 13:42:34 +02:00
|
|
|
}
|
|
|
|
|
|
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 {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
return p.deleteLocal(ecosystem, name, version, "", ErrHostedVersionNotFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DeleteLocalFile removes one pushed file of name@version.
|
|
|
|
|
func (p *Proxy) DeleteLocalFile(ecosystem, name, version, filename string) error {
|
|
|
|
|
return p.deleteLocal(ecosystem, name, version, filename, ErrHostedFileNotFound)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) deleteLocal(ecosystem, name, version, filename string, notFound error) 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
|
|
|
_, versionPURL, err := packagePURLStrings(ecosystem, name, version)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
deleted, err := p.DB.DeleteLocalArtifacts(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
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("deleting local artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if deleted == 0 {
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
return notFound
|
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 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.
|
2026-10-25 17:32:22 +01:00
|
|
|
//
|
|
|
|
|
// With hosted packages disabled it returns nil without a lookup, as
|
|
|
|
|
// localVersions does: pushes are then left out of metadata, so they are not
|
|
|
|
|
// served ahead of the upstream either.
|
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) {
|
2026-10-25 17:32:22 +01:00
|
|
|
if !p.HostedEnabled {
|
|
|
|
|
return nil, 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
|
|
|
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
|
|
|
}
|