2026-10-13 12:35:22 +02:00
|
|
|
package database
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"database/sql"
|
2026-10-20 20:03:06 +02:00
|
|
|
"errors"
|
2026-10-20 20:07:04 +02:00
|
|
|
"fmt"
|
2026-10-13 12:35:22 +02:00
|
|
|
"path/filepath"
|
2026-10-20 20:07:04 +02:00
|
|
|
"sync"
|
2026-10-13 12:35:22 +02:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func seedSourcedArtifact(t *testing.T, db *DB, name, source string, size int64, fetched time.Time) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
pkgPURL := "pkg:npm/" + name
|
|
|
|
|
versionPURL := pkgPURL + "@1.0.0"
|
|
|
|
|
if err := db.UpsertPackage(&Package{PURL: pkgPURL, Ecosystem: "npm", Name: name}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertPackage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertVersion(&Version{PURL: versionPURL, PackagePURL: pkgPURL}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertVersion: %v", err)
|
|
|
|
|
}
|
|
|
|
|
art := &Artifact{
|
|
|
|
|
VersionPURL: versionPURL,
|
|
|
|
|
Filename: name + "-1.0.0.tgz",
|
|
|
|
|
StoragePath: sql.NullString{String: "npm/" + name + "/1.0.0/" + name + "-1.0.0.tgz", Valid: true},
|
|
|
|
|
Size: sql.NullInt64{Int64: size, Valid: true},
|
|
|
|
|
FetchedAt: sql.NullTime{Time: fetched, Valid: true},
|
|
|
|
|
Source: source,
|
|
|
|
|
}
|
|
|
|
|
if source != SourceLocal {
|
|
|
|
|
art.UpstreamURL = "https://registry.example/" + name + "-1.0.0.tgz"
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertArtifact(art); err != nil {
|
|
|
|
|
t.Fatalf("UpsertArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestArtifactSourceDefaultsToUpstream(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
seedSourcedArtifact(t, db, "cached", "", 100, time.Now())
|
|
|
|
|
seedSourcedArtifact(t, db, "pushed", SourceLocal, 100, time.Now())
|
|
|
|
|
|
|
|
|
|
cached, err := db.GetArtifact("pkg:npm/cached@1.0.0", "cached-1.0.0.tgz")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetArtifact(cached): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if cached.Source != SourceUpstream {
|
|
|
|
|
t.Errorf("cached artifact source = %q, want %q", cached.Source, SourceUpstream)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pushed, err := db.GetArtifact("pkg:npm/pushed@1.0.0", "pushed-1.0.0.tgz")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetArtifact(pushed): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if pushed.Source != SourceLocal {
|
|
|
|
|
t.Errorf("pushed artifact source = %q, want %q", pushed.Source, SourceLocal)
|
|
|
|
|
}
|
|
|
|
|
if pushed.UpstreamURL != "" {
|
|
|
|
|
t.Errorf("pushed artifact upstream_url = %q, want empty", pushed.UpstreamURL)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLocalArtifactsAreNotEvictable(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
seedSourcedArtifact(t, db, "cached", SourceUpstream, 1000, time.Now())
|
|
|
|
|
seedSourcedArtifact(t, db, "pushed", SourceLocal, 5000, time.Now())
|
|
|
|
|
|
|
|
|
|
total, err := db.GetTotalCacheSize()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetTotalCacheSize: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if total != 1000 {
|
|
|
|
|
t.Errorf("total cache size = %d, want 1000 (local artifacts excluded)", total)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lru, err := db.GetLeastRecentlyUsedArtifacts(10)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetLeastRecentlyUsedArtifacts: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(lru) != 1 || lru[0].Filename != "cached-1.0.0.tgz" {
|
|
|
|
|
t.Errorf("LRU artifacts = %+v, want only cached-1.0.0.tgz", lru)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLocalArtifactsAreNotRetentionCandidates(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
old := time.Now().Add(-48 * time.Hour)
|
|
|
|
|
cutoff := time.Now().Add(-24 * time.Hour)
|
|
|
|
|
seedSourcedArtifact(t, db, "cached", SourceUpstream, 100, old)
|
|
|
|
|
seedSourcedArtifact(t, db, "pushed", SourceLocal, 100, old)
|
|
|
|
|
|
|
|
|
|
maxID, err := db.MaxArtifactID()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("MaxArtifactID: %v", err)
|
|
|
|
|
}
|
|
|
|
|
candidates, err := db.GetRetentionCandidates(0, maxID, cutoff)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetRetentionCandidates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
got := candidateNames(candidates)
|
|
|
|
|
if _, ok := got["pushed-1.0.0.tgz"]; ok {
|
|
|
|
|
t.Error("local artifact returned as a retention candidate")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := got["cached-1.0.0.tgz"]; !ok {
|
|
|
|
|
t.Error("upstream artifact missing from retention candidates")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cleared, err := db.ClearExpiredArtifact("pkg:npm/pushed@1.0.0", "pushed-1.0.0.tgz", "npm/pushed/1.0.0/pushed-1.0.0.tgz", cutoff)
|
|
|
|
|
if err != nil || cleared {
|
|
|
|
|
t.Errorf("ClearExpiredArtifact(local) = %v, %v, want false, nil", cleared, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMigrationAddsArtifactSource(t *testing.T) {
|
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
|
|
|
|
sqlDB, err := sql.Open("sqlite", dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(schemaSQLite); err != nil {
|
|
|
|
|
t.Fatalf("create schema: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(`ALTER TABLE artifacts DROP COLUMN source`); err != nil {
|
|
|
|
|
t.Fatalf("drop source column: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(`DELETE FROM migrations WHERE name = '012_add_artifact_source'`); err != nil {
|
|
|
|
|
t.Fatalf("forget migration: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(`INSERT INTO artifacts (version_purl, filename, upstream_url, created_at, updated_at)
|
|
|
|
|
VALUES ('pkg:npm/old@1.0.0', 'old-1.0.0.tgz', 'https://registry.example/old-1.0.0.tgz', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`); err != nil {
|
|
|
|
|
t.Fatalf("seed artifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := sqlDB.Close(); err != nil {
|
|
|
|
|
t.Fatalf("close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db, err := Open(dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
|
if err := db.MigrateSchema(); err != nil {
|
|
|
|
|
t.Fatalf("MigrateSchema: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
art, err := db.GetArtifact("pkg:npm/old@1.0.0", "old-1.0.0.tgz")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if art.Source != SourceUpstream {
|
|
|
|
|
t.Errorf("existing artifact source = %q, want %q", art.Source, SourceUpstream)
|
|
|
|
|
}
|
|
|
|
|
}
|
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 TestGetLocalArtifacts(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
seedSourcedArtifact(t, db, "request", SourceLocal, 100, time.Now())
|
|
|
|
|
seedSourcedArtifact(t, db, "lodash", SourceLocal, 100, time.Now())
|
|
|
|
|
seedSourcedArtifact(t, db, "express", SourceUpstream, 100, time.Now())
|
|
|
|
|
|
|
|
|
|
local, err := db.GetLocalArtifacts("pkg:npm/request")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetLocalArtifacts: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(local) != 1 || local[0].Filename != "request-1.0.0.tgz" {
|
|
|
|
|
t.Fatalf("local artifacts of request = %+v, want only request-1.0.0.tgz", local)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
upstreamOnly, err := db.GetLocalArtifacts("pkg:npm/express")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetLocalArtifacts(express): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(upstreamOnly) != 0 {
|
|
|
|
|
t.Errorf("local artifacts of express = %+v, want none", upstreamOnly)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDeleteLocalArtifacts(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
seedSourcedArtifact(t, db, "request", SourceLocal, 100, time.Now())
|
|
|
|
|
seedSourcedArtifact(t, db, "express", SourceUpstream, 100, time.Now())
|
|
|
|
|
const pushedPath = "npm/request/1.0.0/request-1.0.0.tgz"
|
|
|
|
|
|
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 := db.DeleteLocalArtifacts("pkg:npm/request@1.0.0", "")
|
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 || deleted != 1 {
|
|
|
|
|
t.Fatalf("DeleteLocalArtifacts(request) = %d, %v, want 1, nil", deleted, err)
|
|
|
|
|
}
|
|
|
|
|
art, err := db.GetArtifact("pkg:npm/request@1.0.0", "request-1.0.0.tgz")
|
|
|
|
|
if err != nil || art != nil {
|
|
|
|
|
t.Errorf("GetArtifact after delete = %+v, %v, want nil, nil", art, err)
|
|
|
|
|
}
|
|
|
|
|
var queued int
|
|
|
|
|
if err := db.Get(&queued, db.Rebind(`SELECT COUNT(*) FROM pending_deletes WHERE path = ?`), pushedPath); err != nil || queued != 1 {
|
|
|
|
|
t.Errorf("queued deletes for %s = %d, %v, want 1", pushedPath, queued, 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 = db.DeleteLocalArtifacts("pkg:npm/express@1.0.0", "")
|
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 || deleted != 0 {
|
|
|
|
|
t.Errorf("DeleteLocalArtifacts(upstream) = %d, %v, want 0, nil", deleted, err)
|
|
|
|
|
}
|
|
|
|
|
if art, err := db.GetArtifact("pkg:npm/express@1.0.0", "express-1.0.0.tgz"); err != nil || art == nil {
|
|
|
|
|
t.Errorf("upstream artifact removed by DeleteLocalArtifacts: %+v, %v", art, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-13 13:19:58 +02:00
|
|
|
|
|
|
|
|
func TestLocalArtifactManifestAndIntegrity(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
if err := db.UpsertPackage(&Package{PURL: "pkg:npm/request", Ecosystem: "npm", Name: "request"}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertPackage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertVersion(&Version{
|
|
|
|
|
PURL: "pkg:npm/request@2.88.3",
|
|
|
|
|
PackagePURL: "pkg:npm/request",
|
|
|
|
|
Integrity: sql.NullString{String: "sha512-abc", Valid: true},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertVersion: %v", err)
|
|
|
|
|
}
|
|
|
|
|
const manifest = `{"name":"request","version":"2.88.3"}`
|
|
|
|
|
if err := db.UpsertArtifact(&Artifact{
|
|
|
|
|
VersionPURL: "pkg:npm/request@2.88.3",
|
|
|
|
|
Filename: "request-2.88.3.tgz",
|
|
|
|
|
StoragePath: sql.NullString{String: "npm/request/2.88.3/x/request-2.88.3.tgz", Valid: true},
|
|
|
|
|
ContentHash: sql.NullString{String: "00", Valid: true},
|
|
|
|
|
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
|
|
|
Source: SourceLocal,
|
|
|
|
|
Manifest: sql.NullString{String: manifest, Valid: true},
|
|
|
|
|
}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
local, err := db.GetLocalArtifacts("pkg:npm/request")
|
|
|
|
|
if err != nil || len(local) != 1 {
|
|
|
|
|
t.Fatalf("GetLocalArtifacts = %+v, %v", local, err)
|
|
|
|
|
}
|
|
|
|
|
if local[0].Manifest.String != manifest || local[0].Integrity.String != "sha512-abc" {
|
|
|
|
|
t.Errorf("manifest/integrity = %q/%q", local[0].Manifest.String, local[0].Integrity.String)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMigrationAddsArtifactManifest(t *testing.T) {
|
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
|
|
|
|
sqlDB, err := sql.Open("sqlite", dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(schemaSQLite); err != nil {
|
|
|
|
|
t.Fatalf("create schema: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(`ALTER TABLE artifacts DROP COLUMN manifest`); err != nil {
|
|
|
|
|
t.Fatalf("drop manifest column: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := sqlDB.Exec(`DELETE FROM migrations WHERE name = '013_add_artifact_manifest'`); err != nil {
|
|
|
|
|
t.Fatalf("forget migration: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := sqlDB.Close(); err != nil {
|
|
|
|
|
t.Fatalf("close: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db, err := Open(dbPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
|
if err := db.MigrateSchema(); err != nil {
|
|
|
|
|
t.Fatalf("MigrateSchema: %v", err)
|
|
|
|
|
}
|
|
|
|
|
has, err := db.HasColumn("artifacts", "manifest")
|
|
|
|
|
if err != nil || !has {
|
|
|
|
|
t.Errorf("artifacts.manifest after migration = %v, %v", has, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-13 15:13:02 +02:00
|
|
|
|
|
|
|
|
func TestUpsertPackageKeepsSource(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:npm/request", Ecosystem: "npm", Name: "request"}
|
|
|
|
|
if err := db.UpsertPackage(pkg); err != nil {
|
|
|
|
|
t.Fatalf("UpsertPackage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(db.Rebind(`UPDATE packages SET source = ? WHERE purl = ?`), "deps.dev", pkg.PURL); err != nil {
|
|
|
|
|
t.Fatalf("setting source: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertPackage(pkg); err != nil {
|
|
|
|
|
t.Fatalf("second UpsertPackage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
got, err := db.GetPackageByPURL(pkg.PURL)
|
|
|
|
|
if err != nil || got == nil {
|
|
|
|
|
t.Fatalf("GetPackageByPURL: %+v, %v", got, err)
|
|
|
|
|
}
|
|
|
|
|
if got.Source.String != "deps.dev" {
|
|
|
|
|
t.Errorf("source after upsert = %q, want deps.dev", got.Source.String)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-20 20:03:06 +02:00
|
|
|
|
|
|
|
|
func localPush(versionPURL, filename, storagePath string) *Artifact {
|
|
|
|
|
return &Artifact{
|
|
|
|
|
VersionPURL: versionPURL,
|
|
|
|
|
Filename: filename,
|
|
|
|
|
StoragePath: sql.NullString{String: storagePath, Valid: true},
|
|
|
|
|
ContentHash: sql.NullString{String: storagePath, Valid: true},
|
|
|
|
|
Size: sql.NullInt64{Int64: 1, Valid: true},
|
|
|
|
|
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecordLocalArtifactNeverReplacesAPush(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:npm/request", Ecosystem: "npm", Name: "request"}
|
|
|
|
|
version := func(integrity string) *Version {
|
|
|
|
|
return &Version{PURL: "pkg:npm/request@1.0.0", PackagePURL: pkg.PURL,
|
|
|
|
|
Integrity: sql.NullString{String: integrity, Valid: true}}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
first := localPush("pkg:npm/request@1.0.0", "request-1.0.0.tgz", "npm/request/first")
|
|
|
|
|
if err := db.RecordLocalArtifact(pkg, version("sha512-first"), true, first); err != nil {
|
|
|
|
|
t.Fatalf("first RecordLocalArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
second := localPush("pkg:npm/request@1.0.0", "request-1.0.0.tgz", "npm/request/second")
|
|
|
|
|
if err := db.RecordLocalArtifact(pkg, version("sha512-second"), true, second); !errors.Is(err, ErrLocalArtifactExists) {
|
|
|
|
|
t.Fatalf("second RecordLocalArtifact error = %v, want ErrLocalArtifactExists", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
art, err := db.GetArtifact("pkg:npm/request@1.0.0", "request-1.0.0.tgz")
|
|
|
|
|
if err != nil || art == nil || art.StoragePath.String != "npm/request/first" || art.Source != SourceLocal {
|
|
|
|
|
t.Fatalf("artifact after refused push = %+v, %v, want the first push", art, err)
|
|
|
|
|
}
|
|
|
|
|
v, err := db.GetVersionByPURL("pkg:npm/request@1.0.0")
|
|
|
|
|
if err != nil || v == nil || v.Integrity.String != "sha512-first" {
|
|
|
|
|
t.Errorf("version integrity after refused push = %+v, %v, want the first push's", v, err)
|
|
|
|
|
}
|
|
|
|
|
var queued int
|
|
|
|
|
if err := db.Get(&queued, db.Rebind(`SELECT COUNT(*) FROM pending_deletes`)); err != nil || queued != 0 {
|
|
|
|
|
t.Errorf("queued deletes = %d, %v, want none", queued, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecordLocalArtifactReplacesUpstreamCopy(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
seedSourcedArtifact(t, db, "request", SourceUpstream, 100, time.Now())
|
|
|
|
|
pkg := &Package{PURL: "pkg:npm/request", Ecosystem: "npm", Name: "request"}
|
|
|
|
|
v := &Version{PURL: "pkg:npm/request@1.0.0", PackagePURL: pkg.PURL}
|
|
|
|
|
|
|
|
|
|
pushed := localPush("pkg:npm/request@1.0.0", "request-1.0.0.tgz", "npm/request/pushed")
|
|
|
|
|
if err := db.RecordLocalArtifact(pkg, v, true, pushed); err != nil {
|
|
|
|
|
t.Fatalf("RecordLocalArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
art, err := db.GetArtifact("pkg:npm/request@1.0.0", "request-1.0.0.tgz")
|
|
|
|
|
if err != nil || art == nil || art.Source != SourceLocal || art.StoragePath.String != "npm/request/pushed" {
|
|
|
|
|
t.Fatalf("artifact = %+v, %v, want the push", art, err)
|
|
|
|
|
}
|
|
|
|
|
const upstreamPath = "npm/request/1.0.0/request-1.0.0.tgz"
|
|
|
|
|
var queued int
|
|
|
|
|
if err := db.Get(&queued, db.Rebind(`SELECT COUNT(*) FROM pending_deletes WHERE path = ?`), upstreamPath); err != nil || queued != 1 {
|
|
|
|
|
t.Errorf("queued deletes for the upstream copy = %d, %v, want 1", queued, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRecordLocalArtifactCanKeepTheVersionRecord(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:generic/tools", Ecosystem: "generic", Name: "tools"}
|
|
|
|
|
if err := db.UpsertPackage(pkg); err != nil {
|
|
|
|
|
t.Fatalf("UpsertPackage: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertVersion(&Version{PURL: "pkg:generic/tools@1.2.0", PackagePURL: pkg.PURL,
|
|
|
|
|
License: sql.NullString{String: "MIT", Valid: true},
|
|
|
|
|
Integrity: sql.NullString{String: "sha256-upstream", Valid: true}}); err != nil {
|
|
|
|
|
t.Fatalf("UpsertVersion: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pushed := localPush("pkg:generic/tools@1.2.0", "tool.tar.gz", "generic/tools/pushed")
|
|
|
|
|
if err := db.RecordLocalArtifact(pkg, &Version{PURL: "pkg:generic/tools@1.2.0", PackagePURL: pkg.PURL}, false, pushed); err != nil {
|
|
|
|
|
t.Fatalf("RecordLocalArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
v, err := db.GetVersionByPURL("pkg:generic/tools@1.2.0")
|
|
|
|
|
if err != nil || v == nil || v.Integrity.String != "sha256-upstream" || v.License.String != "MIT" {
|
|
|
|
|
t.Errorf("version after push = %+v, %v, want it unchanged", v, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-20 20:07:04 +02:00
|
|
|
|
|
|
|
|
func TestRecordLocalArtifactConcurrentPushesHaveOneWinner(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:generic/tools", Ecosystem: "generic", Name: "tools"}
|
|
|
|
|
const pushes = 8
|
|
|
|
|
start := make(chan struct{})
|
|
|
|
|
errs := make([]error, pushes)
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
for i := range pushes {
|
|
|
|
|
wg.Go(func() {
|
|
|
|
|
<-start
|
|
|
|
|
v := &Version{PURL: "pkg:generic/tools@1.2.0", PackagePURL: pkg.PURL}
|
|
|
|
|
a := localPush("pkg:generic/tools@1.2.0", "tool.tar.gz", fmt.Sprintf("generic/tools/push-%d", i))
|
|
|
|
|
errs[i] = db.RecordLocalArtifact(pkg, v, false, a)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
close(start)
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
winners := 0
|
|
|
|
|
for i, err := range errs {
|
|
|
|
|
switch {
|
|
|
|
|
case err == nil:
|
|
|
|
|
winners++
|
|
|
|
|
case !errors.Is(err, ErrLocalArtifactExists):
|
|
|
|
|
t.Errorf("push %d error = %v, want ErrLocalArtifactExists", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if winners != 1 {
|
|
|
|
|
t.Errorf("%d pushes succeeded, want exactly 1", winners)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-10-20 20:21:01 +02:00
|
|
|
|
2026-10-21 12:33:43 +02:00
|
|
|
// Aliases of one name, such as Composer names differing in case, share the
|
|
|
|
|
// version record and its integrity, so only one of them may be pushed.
|
|
|
|
|
func TestRecordLocalArtifactOneFilePerVersionRefusesAliases(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:composer/acme/private", Ecosystem: "composer", Name: "acme/private"}
|
|
|
|
|
const pushes = 8
|
|
|
|
|
start := make(chan struct{})
|
|
|
|
|
errs := make([]error, pushes)
|
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
|
for i := range pushes {
|
|
|
|
|
wg.Go(func() {
|
|
|
|
|
<-start
|
|
|
|
|
v := &Version{PURL: "pkg:composer/acme/private@1.0.0", PackagePURL: pkg.PURL,
|
|
|
|
|
Integrity: sql.NullString{String: fmt.Sprintf("sha512-push-%d", i), Valid: true}}
|
|
|
|
|
a := localPush("pkg:composer/acme/private@1.0.0", fmt.Sprintf("alias-%d-1.0.0.zip", i),
|
|
|
|
|
fmt.Sprintf("composer/acme/private/push-%d", i))
|
|
|
|
|
errs[i] = db.RecordLocalArtifact(pkg, v, true, a)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
close(start)
|
|
|
|
|
wg.Wait()
|
|
|
|
|
|
|
|
|
|
winner := -1
|
|
|
|
|
for i, err := range errs {
|
|
|
|
|
switch {
|
|
|
|
|
case err == nil:
|
|
|
|
|
if winner >= 0 {
|
|
|
|
|
t.Errorf("pushes %d and %d both succeeded, want exactly 1", winner, i)
|
|
|
|
|
}
|
|
|
|
|
winner = i
|
|
|
|
|
case !errors.Is(err, ErrLocalArtifactExists):
|
|
|
|
|
t.Errorf("push %d error = %v, want ErrLocalArtifactExists", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if winner < 0 {
|
|
|
|
|
t.Fatal("no push succeeded")
|
|
|
|
|
}
|
|
|
|
|
artifacts, err := db.GetArtifactsByVersionPURL("pkg:composer/acme/private@1.0.0")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(artifacts) != 1 || artifacts[0].Filename != fmt.Sprintf("alias-%d-1.0.0.zip", winner) {
|
|
|
|
|
t.Errorf("artifacts = %+v, want only push %d", artifacts, winner)
|
|
|
|
|
}
|
|
|
|
|
v, err := db.GetVersionByPURL("pkg:composer/acme/private@1.0.0")
|
|
|
|
|
if err != nil || v == nil || v.Integrity.String != fmt.Sprintf("sha512-push-%d", winner) {
|
|
|
|
|
t.Errorf("version = %+v, %v, want the integrity of push %d", v, err, winner)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 20:21:01 +02:00
|
|
|
func TestRecordCachedArtifactNeverReplacesAPush(t *testing.T) {
|
|
|
|
|
runWithBothDatabases(t, func(t *testing.T, db *DB) {
|
|
|
|
|
pkg := &Package{PURL: "pkg:npm/request", Ecosystem: "npm", Name: "request"}
|
|
|
|
|
pushed := localPush("pkg:npm/request@1.0.0", "request-1.0.0.tgz", "npm/request/pushed")
|
|
|
|
|
if err := db.RecordLocalArtifact(pkg, &Version{PURL: "pkg:npm/request@1.0.0", PackagePURL: pkg.PURL,
|
|
|
|
|
Integrity: sql.NullString{String: "sha512-pushed", Valid: true}}, true, pushed); err != nil {
|
|
|
|
|
t.Fatalf("RecordLocalArtifact: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetched := &Artifact{
|
|
|
|
|
VersionPURL: "pkg:npm/request@1.0.0",
|
|
|
|
|
Filename: "request-1.0.0.tgz",
|
|
|
|
|
UpstreamURL: "https://registry.example/request-1.0.0.tgz",
|
|
|
|
|
StoragePath: sql.NullString{String: "npm/request/fetched", Valid: true},
|
|
|
|
|
}
|
|
|
|
|
err := db.RecordCachedArtifact(pkg, &Version{PURL: "pkg:npm/request@1.0.0", PackagePURL: pkg.PURL,
|
|
|
|
|
Integrity: sql.NullString{String: "sha512-upstream", Valid: true}}, fetched)
|
|
|
|
|
if !errors.Is(err, ErrLocalArtifactExists) {
|
|
|
|
|
t.Fatalf("RecordCachedArtifact error = %v, want ErrLocalArtifactExists", err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.UpsertArtifact(fetched); !errors.Is(err, ErrLocalArtifactExists) {
|
|
|
|
|
t.Errorf("UpsertArtifact error = %v, want ErrLocalArtifactExists", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
art, err := db.GetArtifact("pkg:npm/request@1.0.0", "request-1.0.0.tgz")
|
|
|
|
|
if err != nil || art == nil || art.Source != SourceLocal || art.StoragePath.String != "npm/request/pushed" {
|
|
|
|
|
t.Errorf("artifact = %+v, %v, want the push", art, err)
|
|
|
|
|
}
|
|
|
|
|
v, err := db.GetVersionByPURL("pkg:npm/request@1.0.0")
|
|
|
|
|
if err != nil || v == nil || v.Integrity.String != "sha512-pushed" {
|
|
|
|
|
t.Errorf("version = %+v, %v, want the push's integrity", v, err)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|