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 server
import (
"context"
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
"crypto/sha256"
"encoding/hex"
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
"encoding/json"
2026-10-23 09:57:37 +02:00
"errors"
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
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/handler"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/registries/fetch"
"github.com/go-chi/chi/v5"
)
const (
testHostedToken = "push-token"
2026-10-13 13:19:58 +02:00
testHostedMaxUpload = 4096
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
)
type hostedTestServer struct {
router http . Handler
fetches * int
upstreamRequests * int
}
// countingFetcher fails every fetch, so a test fails loudly if the proxy goes
// upstream for something it should serve from the pushed copy.
type countingFetcher struct { calls * int }
func ( f countingFetcher ) Fetch ( ctx context . Context , url string ) ( * fetch . Artifact , error ) {
return f . FetchWithHeaders ( ctx , url , nil )
}
func ( f countingFetcher ) FetchWithHeaders ( context . Context , string , http . Header ) ( * fetch . Artifact , error ) {
* f . calls ++
return nil , fetch . ErrNotFound
}
func ( f countingFetcher ) Head ( context . Context , string ) ( int64 , string , error ) {
* f . calls ++
return 0 , "" , fetch . ErrNotFound
}
func newHostedTestServer ( t * testing . T ) hostedTestServer {
t . Helper ( )
dir := t . TempDir ( )
db , err := database . Create ( filepath . Join ( dir , "test.db" ) )
if err != nil {
t . Fatalf ( "database.Create: %v" , err )
}
t . Cleanup ( func ( ) { _ = db . Close ( ) } )
store , err := storage . OpenBucket ( context . Background ( ) , "file://" + filepath . Join ( dir , "artifacts" ) )
if err != nil {
t . Fatalf ( "storage.OpenBucket: %v" , err )
}
calls := 0
logger := slog . New ( slog . NewTextHandler ( io . Discard , nil ) )
proxy := handler . NewProxy ( db , store , countingFetcher { calls : & calls } , fetch . NewResolver ( ) , logger )
2026-10-13 13:27:13 +02:00
proxy . HostedEnabled = true
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
upstreamRequests := 0
upstream := httptest . NewServer ( http . HandlerFunc ( func ( w http . ResponseWriter , _ * http . Request ) {
upstreamRequests ++
http . NotFound ( w , nil )
} ) )
t . Cleanup ( upstream . Close )
r := chi . NewRouter ( )
registerHostedRoutes ( r , NewHostedAPIHandler ( proxy , testHostedMaxUpload , logger ) , testHostedToken )
r . Mount ( "/npm" , http . StripPrefix ( "/npm" , handler . NewNPMHandler ( proxy , "http://proxy.test" , upstream . URL ) . Routes ( ) ) )
composer := handler . NewComposerHandlerWithUpstreams ( proxy , "http://proxy.test" , upstream . URL , upstream . URL )
r . Mount ( "/composer" , http . StripPrefix ( "/composer" , composer . Routes ( ) ) )
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
r . Mount ( "/generic" , http . StripPrefix ( "/generic" , handler . NewGenericHandler ( proxy , nil ) . Routes ( ) ) )
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 hostedTestServer { router : r , fetches : & calls , upstreamRequests : & upstreamRequests }
}
func ( s hostedTestServer ) do ( t * testing . T , method , target , token , body string ) * httptest . ResponseRecorder {
t . Helper ( )
var reader io . Reader
if body != "" {
reader = strings . NewReader ( body )
}
req := httptest . NewRequest ( method , target , reader )
if token != "" {
req . Header . Set ( "Authorization" , "Bearer " + token )
}
rec := httptest . NewRecorder ( )
s . router . ServeHTTP ( rec , req )
return rec
}
func TestHostedPushIsServedByTheEcosystemHandler ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/npm/request/2.88.3" , testHostedToken , requestTarball )
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 rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
var pushed HostedPushResponse
if err := json . NewDecoder ( rec . Body ) . Decode ( & pushed ) ; err != nil {
t . Fatalf ( "decoding PUT response: %v" , err )
}
if pushed . Ecosystem != "npm" || pushed . Name != "request" || pushed . Version != "2.88.3" ||
2026-10-13 13:19:58 +02:00
pushed . Filename != "request-2.88.3.tgz" || pushed . Size != int64 ( len ( requestTarball ) ) {
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
t . Errorf ( "PUT response = %+v" , pushed )
}
rec = s . do ( t , http . MethodGet , "/npm/request/-/request-2.88.3.tgz" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != requestTarball {
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
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed tarball" , rec . Code , rec . Body )
}
if * s . fetches != 0 {
t . Errorf ( "download contacted the upstream %d times" , * s . fetches )
}
}
func TestHostedPushIsServedByTheComposerHandler ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/composer/monolog/monolog/3.5.0" , testHostedToken , monologZip )
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 rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec = s . do ( t , http . MethodGet , "/composer/files/monolog/monolog/3.5.0/monolog-3.5.0.zip" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != monologZip {
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
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed archive" , rec . Code , rec . Body )
}
if * s . upstreamRequests != 0 || * s . fetches != 0 {
t . Errorf ( "download contacted the upstream (%d metadata requests, %d fetches)" , * s . upstreamRequests , * s . fetches )
}
}
func TestComposerDownloadOfUnpushedFileStillNeedsUpstreamMetadata ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/monolog/monolog/3.5.0" , testHostedToken , monologZip ) ; rec . Code != http . StatusCreated {
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
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/composer/files/monolog/monolog/3.5.0/other.zip" , "" , "" )
if rec . Code != http . StatusNotFound {
t . Errorf ( "download of another filename status = %d, want 404 from the upstream lookup" , rec . Code )
}
if * s . upstreamRequests == 0 {
t . Error ( "a filename that was not pushed was served without the upstream metadata lookup" )
}
}
func TestHostedPushScopedNpmPackage ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
rec := s . do ( t , http . MethodPut , "/api/hosted/npm/@types/node/20.1.0" , testHostedToken , nodeTarball )
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 rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec = s . do ( t , http . MethodGet , "/npm/@types/node/-/node-20.1.0.tgz" , "" , "" )
2026-10-13 13:19:58 +02:00
if rec . Code != http . StatusOK || rec . Body . String ( ) != nodeTarball {
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
t . Fatalf ( "download status = %d, body %q, want 200 and the pushed tarball" , rec . Code , rec . Body )
}
}
func TestHostedListAndDelete ( t * testing . T ) {
s := newHostedTestServer ( t )
2026-10-13 13:19:58 +02:00
if rec := s . do ( t , http . MethodPut , "/api/hosted/npm/request/2.88.3" , testHostedToken , requestTarball ) ; rec . Code != http . StatusCreated {
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
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/api/hosted/npm/request" , "" , "" )
if rec . Code != http . StatusOK {
t . Fatalf ( "GET status = %d, body %s" , rec . Code , rec . Body )
}
var listed HostedListResponse
if err := json . NewDecoder ( rec . Body ) . Decode ( & listed ) ; err != nil {
t . Fatalf ( "decoding GET response: %v" , err )
}
if listed . Ecosystem != "npm" || listed . Name != "request" || len ( listed . Artifacts ) != 1 || listed . Artifacts [ 0 ] . Version != "2.88.3" {
t . Errorf ( "GET response = %+v" , listed )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , "" , "" ) ; rec . Code != http . StatusUnauthorized {
t . Errorf ( "DELETE without token status = %d, want 401" , rec . Code )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , testHostedToken , "" ) ; rec . Code != http . StatusNoContent {
t . Errorf ( "DELETE status = %d, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/npm/request/2.88.3" , testHostedToken , "" ) ; rec . Code != http . StatusNotFound {
t . Errorf ( "second DELETE status = %d, want 404" , rec . Code )
}
}
func TestHostedPushRejections ( t * testing . T ) {
tests := [ ] struct {
name string
target string
token string
body string
wantStatus int
} {
2026-10-13 13:19:58 +02:00
{ name : "no token" , target : "/api/hosted/npm/request/2.88.3" , body : requestTarball , wantStatus : http . StatusUnauthorized } ,
{ name : "wrong token" , target : "/api/hosted/npm/request/2.88.3" , token : "guess" , body : requestTarball , wantStatus : http . StatusUnauthorized } ,
{ name : "unsupported ecosystem" , target : "/api/hosted/cargo/serde/1.0.200" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "composer name without vendor" , target : "/api/hosted/composer/monolog/3.5.0" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "missing version" , target : "/api/hosted/npm/request" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "not an archive" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , body : "plain text" , wantStatus : http . StatusBadRequest } ,
{ name : "manifest of another version" , target : "/api/hosted/npm/request/2.88.4" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
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
{ name : "empty body" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , wantStatus : http . StatusBadRequest } ,
{ name : "too large" , target : "/api/hosted/npm/request/2.88.3" , token : testHostedToken , body : strings . Repeat ( "x" , testHostedMaxUpload + 1 ) , wantStatus : http . StatusRequestEntityTooLarge } ,
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
{ name : "sha256 mismatch" , target : "/api/hosted/npm/request/2.88.3?sha256=" + strings . Repeat ( "0" , 64 ) , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "malformed sha256" , target : "/api/hosted/npm/request/2.88.3?sha256=abc" , token : testHostedToken , body : requestTarball , wantStatus : http . StatusBadRequest } ,
{ name : "generic without file" , target : "/api/hosted/generic/request/2.88.3" , token : testHostedToken , body : "binary" , wantStatus : http . StatusBadRequest } ,
{ name : "generic name with two segments" , target : "/api/hosted/generic/acme/tools/1.0.0/tool.bin" , token : testHostedToken , body : "binary" , wantStatus : http . StatusBadRequest } ,
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
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
s := newHostedTestServer ( t )
rec := s . do ( t , http . MethodPut , tt . target , tt . token , tt . body )
if rec . Code != tt . wantStatus {
t . Fatalf ( "PUT status = %d, want %d, body %s" , rec . Code , tt . wantStatus , rec . Body )
}
listed := s . do ( t , http . MethodGet , "/api/hosted/npm/request" , "" , "" )
if strings . Contains ( listed . Body . String ( ) , "2.88.3" ) {
t . Error ( "rejected push was stored" )
}
} )
}
}
func TestHostedListUnsupportedEcosystem ( t * testing . T ) {
s := newHostedTestServer ( t )
if rec := s . do ( t , http . MethodGet , "/api/hosted/maven/junit" , "" , "" ) ; rec . Code != http . StatusBadRequest {
t . Errorf ( "GET status = %d, want 400" , rec . Code )
}
}
func startHostedServer ( t * testing . T , hosted config . HostedConfig ) string {
t . Helper ( )
listener , err := net . Listen ( "tcp" , "127.0.0.1:0" )
if err != nil {
t . Fatalf ( "reserving proxy address: %v" , err )
}
t . Cleanup ( func ( ) { _ = listener . Close ( ) } )
tempDir := t . TempDir ( )
cfg := config . Default ( )
cfg . Listen = listener . Addr ( ) . String ( )
cfg . BaseURL = "http://" + cfg . Listen
cfg . Database . Path = filepath . Join ( tempDir , "proxy.db" )
cfg . Storage . URL = "file://" + filepath . Join ( tempDir , "artifacts" )
cfg . Hosted = hosted
2026-10-13 13:23:29 +02:00
upstream := httptest . NewServer ( http . NotFoundHandler ( ) )
t . Cleanup ( upstream . Close )
cfg . Upstream . NPM = upstream . URL
cfg . Upstream . AllowLoopback = true
Push, list and delete npm and Composer archives through /api/hosted
With hosted.enabled, PUT /api/hosted/{ecosystem}/{name}/{version} stores the request body as the archive for that version, marked local. It replaces any cached upstream copy and is served from the cache without contacting the upstream. GET /api/hosted/{ecosystem}/{name} lists what was pushed and DELETE removes a version again, after which clients get the upstream archive if there is one. Pushing and deleting need the bearer token; listing is open like the rest of the API.
Download handlers look archives up by the filename clients request, so a push is stored under the name its ecosystem derives from name and version: {name}-{version}.tgz for npm, without the scope, and {package}-{version}.zip for Composer. Other ecosystems are refused for now.
The Composer download handler resolves the upstream metadata before it looks at the cache and answers 404 for versions the upstream doesn't list. It now serves a pushed archive first. Only artifacts marked local take that path, so cached upstream archives still go through the metadata and commit checks.
The version's integrity is set to the pushed bytes, so the cache's integrity check verifies them rather than an upstream digest. Names and versions with path separators, dot segments or control characters are refused before anything is stored.
Refs #133
2026-10-13 13:11:49 +02:00
if err := cfg . Validate ( ) ; err != nil {
t . Fatalf ( "validating config: %v" , err )
}
proxyServer , err := New ( cfg , slog . New ( slog . NewTextHandler ( io . Discard , nil ) ) , BuildInfo { Version : "test" , Commit : "test" } )
if err != nil {
t . Fatalf ( "creating server: %v" , err )
}
startErr := make ( chan error , 1 )
go func ( ) { startErr <- proxyServer . Start ( listener ) } ( )
t . Cleanup ( func ( ) {
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
_ = proxyServer . Shutdown ( ctx )
2026-10-23 09:57:37 +02:00
if err := <- startErr ; ! errors . Is ( err , http . ErrServerClosed ) {
t . Errorf ( "server stopped with %v" , 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 cfg . BaseURL
}
func putArchive ( t * testing . T , url , token string ) int {
t . Helper ( )
2026-10-13 13:23:29 +02:00
return putBody ( t , url , token , requestTarball )
}
func putBody ( t * testing . T , url , token , body string ) int {
t . Helper ( )
req , err := http . NewRequest ( http . MethodPut , url , strings . NewReader ( body ) )
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 {
t . Fatalf ( "building request: %v" , err )
}
if token != "" {
req . Header . Set ( "Authorization" , "Bearer " + token )
}
resp , err := http . DefaultClient . Do ( req )
if err != nil {
t . Fatalf ( "PUT %s: %v" , url , err )
}
_ = resp . Body . Close ( )
return resp . StatusCode
}
func TestHostedRoutesFollowConfig ( t * testing . T ) {
disabled := startHostedServer ( t , config . HostedConfig { } )
if status := putArchive ( t , disabled + "/api/hosted/npm/request/2.88.3" , testHostedToken ) ; status == http . StatusCreated {
t . Error ( "push accepted with hosted.enabled false" )
}
enabled := startHostedServer ( t , config . HostedConfig { Enabled : true , Token : testHostedToken } )
if status := putArchive ( t , enabled + "/api/hosted/npm/request/2.88.3" , "" ) ; status != http . StatusUnauthorized {
t . Errorf ( "push without token status = %d, want 401" , status )
}
if status := putArchive ( t , enabled + "/api/hosted/npm/request/2.88.3" , testHostedToken ) ; status != http . StatusCreated {
t . Errorf ( "push with token status = %d, want 201" , status )
}
}
2026-10-13 13:23:29 +02:00
func TestHostedNpmPackageIsListedByTheRunningServer ( t * testing . T ) {
base := startHostedServer ( t , config . HostedConfig { Enabled : true , Token : testHostedToken } )
private := npmTarball ( ` { "name":"@acme/private","version":"1.0.0"} ` )
if status := putBody ( t , base + "/api/hosted/npm/@acme/private/1.0.0" , testHostedToken , private ) ; status != http . StatusCreated {
t . Fatalf ( "push status = %d, want 201" , status )
}
resp , err := http . Get ( base + "/npm/@acme%2fprivate" )
if err != nil {
t . Fatalf ( "GET packument: %v" , err )
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
body , _ := io . ReadAll ( resp . Body )
if resp . StatusCode != http . StatusOK || ! strings . Contains ( string ( body ) , ` "1.0.0" ` ) {
t . Errorf ( "packument status = %d, body %s" , resp . StatusCode , body )
}
}
2026-10-13 13:27:13 +02:00
func TestHostedComposerVersionIsInstallableFromMetadata ( t * testing . T ) {
s := newHostedTestServer ( t )
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/acme/private/1.0.0" , testHostedToken ,
composerZip ( ` { "name":"acme/private","require": { "php":">=8.1"}} ` ) ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/composer/p2/acme/private.json" , "" , "" )
if rec . Code != http . StatusOK {
t . Fatalf ( "metadata status = %d, body %s" , rec . Code , rec . Body )
}
var doc struct {
Packages map [ string ] [ ] struct {
Dist struct {
URL string ` json:"url" `
} ` json:"dist" `
} ` json:"packages" `
}
if err := json . Unmarshal ( rec . Body . Bytes ( ) , & doc ) ; err != nil {
t . Fatalf ( "decoding metadata: %v" , err )
}
versions := doc . Packages [ "acme/private" ]
if len ( versions ) != 1 {
t . Fatalf ( "metadata = %s" , rec . Body )
}
distPath := strings . TrimPrefix ( versions [ 0 ] . Dist . URL , "http://proxy.test" )
if rec := s . do ( t , http . MethodGet , distPath , "" , "" ) ; rec . Code != http . StatusOK {
t . Errorf ( "download of %s status = %d" , distPath , rec . Code )
}
}
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
func TestHostedRepushIsRefused ( t * testing . T ) {
s := newHostedTestServer ( t )
const target = "/api/hosted/npm/request/2.88.3"
if rec := s . do ( t , http . MethodPut , target , testHostedToken , requestTarball ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "first PUT status = %d, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodPut , target , testHostedToken , requestTarball ) ; rec . Code != http . StatusConflict {
t . Fatalf ( "second PUT status = %d, want 409, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodDelete , target , testHostedToken , "" ) ; rec . Code != http . StatusNoContent {
t . Fatalf ( "DELETE status = %d" , rec . Code )
}
if rec := s . do ( t , http . MethodPut , target , testHostedToken , requestTarball ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "PUT after DELETE status = %d, want 201, body %s" , rec . Code , rec . Body )
}
}
2026-10-21 12:33:43 +02:00
func TestHostedComposerRepushUnderAnotherCaseIsRefused ( t * testing . T ) {
s := newHostedTestServer ( t )
original := composerZip ( ` { "name":"acme/private","description":"first"} ` )
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/acme/private/1.0.0" , testHostedToken , original ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "first PUT status = %d, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodPut , "/api/hosted/composer/acme/Private/1.0.0" , testHostedToken ,
composerZip ( ` { "name":"acme/private","description":"second"} ` ) ) ; rec . Code != http . StatusConflict {
t . Fatalf ( "PUT of acme/Private status = %d, want 409, body %s" , rec . Code , rec . Body )
}
rec := s . do ( t , http . MethodGet , "/composer/files/acme/private/1.0.0/private-1.0.0.zip" , "" , "" )
if rec . Code != http . StatusOK || rec . Body . String ( ) != original {
t . Fatalf ( "download status = %d, want 200 and the first archive" , rec . Code )
}
}
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
func TestHostedPushWithMatchingSHA256 ( t * testing . T ) {
s := newHostedTestServer ( t )
digest := sha256 . Sum256 ( [ ] byte ( requestTarball ) )
target := "/api/hosted/npm/request/2.88.3?sha256=" + strings . ToUpper ( hex . EncodeToString ( digest [ : ] ) )
if rec := s . do ( t , http . MethodPut , target , testHostedToken , requestTarball ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "PUT status = %d, body %s" , rec . Code , rec . Body )
}
}
func TestHostedGenericFiles ( t * testing . T ) {
s := newHostedTestServer ( t )
const (
arm64 = "tool-linux-arm64.tar.gz"
amd64 = "tool-linux-amd64.tar.gz"
)
for _ , file := range [ ] string { arm64 , amd64 } {
rec := s . do ( t , http . MethodPut , "/api/hosted/generic/tools/1.2.0/" + file , testHostedToken , "bytes of " + file )
if rec . Code != http . StatusCreated {
t . Fatalf ( "PUT %s status = %d, body %s" , file , rec . Code , rec . Body )
}
var pushed HostedPushResponse
if err := json . NewDecoder ( rec . Body ) . Decode ( & pushed ) ; err != nil {
t . Fatalf ( "decoding PUT response: %v" , err )
}
digest := sha256 . Sum256 ( [ ] byte ( "bytes of " + file ) )
if pushed . Filename != file || pushed . Version != "1.2.0" || pushed . SHA256 != hex . EncodeToString ( digest [ : ] ) {
t . Errorf ( "PUT response = %+v" , pushed )
}
}
rec := s . do ( t , http . MethodGet , "/generic/hosted/tools/1.2.0/" + arm64 , "" , "" )
if rec . Code != http . StatusOK || rec . Body . String ( ) != "bytes of " + arm64 {
t . Fatalf ( "download status = %d, body %q" , rec . Code , rec . Body )
}
digest := sha256 . Sum256 ( [ ] byte ( "bytes of " + arm64 ) )
if etag := rec . Header ( ) . Get ( "ETag" ) ; etag != ` " ` + hex . EncodeToString ( digest [ : ] ) + ` " ` {
t . Errorf ( "ETag = %q, want the sha256" , etag )
}
if rec := s . do ( t , http . MethodHead , "/generic/hosted/tools/1.2.0/" + arm64 , "" , "" ) ; rec . Code != http . StatusOK {
t . Errorf ( "HEAD status = %d" , rec . Code )
}
listed := s . do ( t , http . MethodGet , "/api/hosted/generic/tools" , "" , "" )
var list HostedListResponse
if err := json . NewDecoder ( listed . Body ) . Decode ( & list ) ; err != nil {
t . Fatalf ( "decoding list: %v" , err )
}
if len ( list . Artifacts ) != 2 {
t . Fatalf ( "listed %d files, want 2: %+v" , len ( list . Artifacts ) , list . Artifacts )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/generic/tools/1.2.0/" + arm64 , testHostedToken , "" ) ; rec . Code != http . StatusNoContent {
t . Fatalf ( "DELETE file status = %d, body %s" , rec . Code , rec . Body )
}
if rec := s . do ( t , http . MethodGet , "/generic/hosted/tools/1.2.0/" + arm64 , "" , "" ) ; rec . Code != http . StatusNotFound {
t . Errorf ( "download of deleted file status = %d, want 404" , rec . Code )
}
if rec := s . do ( t , http . MethodGet , "/generic/hosted/tools/1.2.0/" + amd64 , "" , "" ) ; rec . Code != http . StatusOK {
t . Errorf ( "download of the other file status = %d, want 200" , rec . Code )
}
if rec := s . do ( t , http . MethodDelete , "/api/hosted/generic/tools/1.2.0/" + arm64 , testHostedToken , "" ) ; rec . Code != http . StatusNotFound {
t . Errorf ( "second DELETE status = %d, want 404" , rec . Code )
}
for _ , path := range [ ] string {
"/generic/hosted/tools/1.2.0/missing.bin" ,
"/generic/hosted/tools/1.2.0" ,
"/generic/hosted/tools/1.2.0/a/b" ,
} {
if rec := s . do ( t , http . MethodGet , path , "" , "" ) ; rec . Code != http . StatusNotFound {
t . Errorf ( "GET %s status = %d, want 404" , path , rec . Code )
}
}
if * s . fetches != 0 || * s . upstreamRequests != 0 {
t . Errorf ( "hosted generic files went upstream: %d fetches, %d requests" , * s . fetches , * s . upstreamRequests )
}
}
func TestHostedGenericFileIsServedByTheRunningServer ( t * testing . T ) {
base := startHostedServer ( t , config . HostedConfig { Enabled : true , Token : testHostedToken } )
if status := putBody ( t , base + "/api/hosted/generic/tools/1.2.0/tool.tar.gz" , testHostedToken , "tool bytes" ) ; status != http . StatusCreated {
t . Fatalf ( "push status = %d, want 201" , status )
}
resp , err := http . Get ( base + "/generic/hosted/tools/1.2.0/tool.tar.gz" )
if err != nil {
t . Fatalf ( "GET file: %v" , err )
}
defer func ( ) { _ = resp . Body . Close ( ) } ( )
body , _ := io . ReadAll ( resp . Body )
if resp . StatusCode != http . StatusOK || string ( body ) != "tool bytes" {
t . Errorf ( "download status = %d, body %q" , resp . StatusCode , body )
}
}
2026-10-20 20:03:06 +02:00
// unreadBody fails the test if the handler reads it.
type unreadBody struct { t * testing . T }
func ( b unreadBody ) Read ( [ ] byte ) ( int , error ) {
b . t . Error ( "body of a refused push was read" )
return 0 , io . EOF
}
func TestHostedRepushIsRefusedBeforeTheBodyIsRead ( t * testing . T ) {
s := newHostedTestServer ( t )
const target = "/api/hosted/generic/tools/1.2.0/tool.tar.gz"
if rec := s . do ( t , http . MethodPut , target , testHostedToken , "first" ) ; rec . Code != http . StatusCreated {
t . Fatalf ( "first PUT status = %d, body %s" , rec . Code , rec . Body )
}
req := httptest . NewRequest ( http . MethodPut , target , unreadBody { t } )
req . Header . Set ( "Authorization" , "Bearer " + testHostedToken )
rec := httptest . NewRecorder ( )
s . router . ServeHTTP ( rec , req )
if rec . Code != http . StatusConflict {
t . Fatalf ( "second PUT status = %d, want 409, body %s" , rec . Code , rec . Body )
}
}
2026-10-20 20:21:01 +02:00
func TestHostedDeleteRejectsMalformedPaths ( t * testing . T ) {
s := newHostedTestServer ( t )
for _ , target := range [ ] string {
"/api/hosted/generic/tools/1.2.0/%2e%2e" ,
"/api/hosted/generic/tools/1.2.0/tool%0a" ,
"/api/hosted/npm/request/2.88.3/request-2.88.3.tgz/extra" ,
"/api/hosted/composer/monolog/3.5.0" ,
"/api/hosted/cargo/serde/1.0.200" ,
} {
if rec := s . do ( t , http . MethodDelete , target , testHostedToken , "" ) ; rec . Code != http . StatusBadRequest {
t . Errorf ( "DELETE %s status = %d, want 400, body %s" , target , rec . Code , rec . Body )
}
}
}