mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 23:59:16 -05:00
gocloud commits whatever a writer received when it is closed, unless its context was cancelled first. Store closed the writer after a failed copy, so every download that broke off midway left a partial object under its fetch path. No database row points at it, so it was never served, but nothing deleted it either and the orphans kept taking disk space. - Cancel the writer's context before closing it when the copy fails - Add a test storing from a reader that errors midway
700 lines
19 KiB
Go
700 lines
19 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gocloud.dev/blob"
|
|
)
|
|
|
|
func TestOpenBucket(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
defer func() { _ = b.Close() }()
|
|
|
|
if b.URL() == "" {
|
|
t.Error("URL() should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestBlobStore(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
content := "test content for blob storage"
|
|
|
|
size, hash, err := b.Store(ctx, "npm/lodash/4.17.21/lodash.tgz", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Store failed: %v", err)
|
|
}
|
|
|
|
if size != int64(len(content)) {
|
|
t.Errorf("size = %d, want %d", size, len(content))
|
|
}
|
|
|
|
h := sha256.Sum256([]byte(content))
|
|
wantHash := hex.EncodeToString(h[:])
|
|
if hash != wantHash {
|
|
t.Errorf("hash = %s, want %s", hash, wantHash)
|
|
}
|
|
}
|
|
|
|
func TestBlobOpen(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
content := "readable content"
|
|
|
|
_, _, _ = b.Store(ctx, "test/read.txt", strings.NewReader(content))
|
|
|
|
r, err := b.Open(ctx, "test/read.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open failed: %v", err)
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
seeker, ok := r.(io.Seeker)
|
|
if !ok {
|
|
t.Fatal("local file reader does not implement io.Seeker")
|
|
}
|
|
if _, err := seeker.Seek(9, io.SeekStart); err != nil {
|
|
t.Fatalf("Seek failed: %v", err)
|
|
}
|
|
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll failed: %v", err)
|
|
}
|
|
if string(data) != "content" {
|
|
t.Errorf("content after seek = %q, want %q", string(data), "content")
|
|
}
|
|
}
|
|
|
|
func TestBlobOpenNotFound(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := b.Open(ctx, "does/not/exist.txt")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Open non-existent = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestBlobExists(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
exists, err := b.Exists(ctx, "test/exists.txt")
|
|
if err != nil {
|
|
t.Fatalf("Exists failed: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("Exists returned true for non-existent file")
|
|
}
|
|
|
|
_, _, _ = b.Store(ctx, "test/exists.txt", strings.NewReader("content"))
|
|
|
|
exists, err = b.Exists(ctx, "test/exists.txt")
|
|
if err != nil {
|
|
t.Fatalf("Exists after store failed: %v", err)
|
|
}
|
|
if !exists {
|
|
t.Error("Exists returned false for existing file")
|
|
}
|
|
}
|
|
|
|
func TestBlobDelete(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
_, _, _ = b.Store(ctx, "test/delete/nested/file.txt", strings.NewReader("content"))
|
|
|
|
err := b.Delete(ctx, "test/delete/nested/file.txt")
|
|
if err != nil {
|
|
t.Fatalf("Delete failed: %v", err)
|
|
}
|
|
|
|
exists, _ := b.Exists(ctx, "test/delete/nested/file.txt")
|
|
if exists {
|
|
t.Error("file still exists after delete")
|
|
}
|
|
}
|
|
|
|
// A fetch stores its object in a directory of its own, so Delete removes that
|
|
// directory once it is empty rather than leave one behind per deleted object.
|
|
// A version directory from the layout before fetch directories is left alone,
|
|
// since a new fetch may be creating its directory inside it.
|
|
func TestBlobDeleteRemovesEmptyFetchDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
b := openFileBlob(t, dir)
|
|
ctx := context.Background()
|
|
const (
|
|
emptied = "npm/pkg/1.0.0/0123456789abcdef/pkg.tgz"
|
|
shared = "npm/pkg/1.0.0/fedcba9876543210/pkg.tgz"
|
|
kept = "npm/pkg/1.0.0/fedcba9876543210/other.tgz"
|
|
legacy = "npm/pkg/2.0.0/pkg.tgz"
|
|
)
|
|
for _, key := range []string{emptied, shared, kept, legacy} {
|
|
if _, _, err := b.Store(ctx, key, strings.NewReader("content")); err != nil {
|
|
t.Fatalf("Store(%q): %v", key, err)
|
|
}
|
|
}
|
|
|
|
for _, key := range []string{emptied, shared, legacy} {
|
|
if err := b.Delete(ctx, key); err != nil {
|
|
t.Fatalf("Delete(%q): %v", key, err)
|
|
}
|
|
}
|
|
|
|
pkg := filepath.Join(dir, "npm", "pkg")
|
|
if _, err := os.Stat(filepath.Join(pkg, "1.0.0", "0123456789abcdef")); !os.IsNotExist(err) {
|
|
t.Errorf("emptied fetch directory left behind (stat err %v)", err)
|
|
}
|
|
for _, d := range []string{filepath.Join("1.0.0", "fedcba9876543210"), "1.0.0", "2.0.0"} {
|
|
if _, err := os.Stat(filepath.Join(pkg, d)); err != nil {
|
|
t.Errorf("directory %s removed: %v", d, err)
|
|
}
|
|
}
|
|
assertReadsBack(t, b, kept, "content")
|
|
}
|
|
|
|
func TestBlobDeleteNotFound(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
// Delete non-existent file should not error
|
|
err := b.Delete(ctx, "does/not/exist.txt")
|
|
if err != nil {
|
|
t.Errorf("Delete non-existent = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestBlobSize(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
content := "size test content"
|
|
|
|
_, _, _ = b.Store(ctx, "test/size.txt", strings.NewReader(content))
|
|
|
|
size, err := b.Size(ctx, "test/size.txt")
|
|
if err != nil {
|
|
t.Fatalf("Size failed: %v", err)
|
|
}
|
|
if size != int64(len(content)) {
|
|
t.Errorf("Size = %d, want %d", size, len(content))
|
|
}
|
|
}
|
|
|
|
func TestBlobSizeNotFound(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
_, err := b.Size(ctx, "does/not/exist.txt")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Size non-existent = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestBlobUsedSpace(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
// Empty storage
|
|
used, err := b.UsedSpace(ctx)
|
|
if err != nil {
|
|
t.Fatalf("UsedSpace failed: %v", err)
|
|
}
|
|
if used != 0 {
|
|
t.Errorf("UsedSpace empty = %d, want 0", used)
|
|
}
|
|
|
|
// Add some files
|
|
_, _, _ = b.Store(ctx, "a.txt", strings.NewReader("aaaa")) // 4 bytes
|
|
_, _, _ = b.Store(ctx, "b.txt", strings.NewReader("bbbbbb")) // 6 bytes
|
|
_, _, _ = b.Store(ctx, "c/d.txt", strings.NewReader("ccccc")) // 5 bytes
|
|
|
|
used, err = b.UsedSpace(ctx)
|
|
if err != nil {
|
|
t.Fatalf("UsedSpace failed: %v", err)
|
|
}
|
|
if used != 15 {
|
|
t.Errorf("UsedSpace = %d, want 15", used)
|
|
}
|
|
}
|
|
|
|
func TestBlobLargeFile(t *testing.T) {
|
|
assertLargeFileRoundTrip(t, createTestBlob(t))
|
|
}
|
|
|
|
func TestBlobSignedURLUnsupported(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
// fileblob has no URL signer configured, so this must surface as
|
|
// ErrSignedURLUnsupported rather than a generic error.
|
|
_, err := b.SignedURL(ctx, "test/file.txt", time.Minute)
|
|
if !errors.Is(err, ErrSignedURLUnsupported) {
|
|
t.Errorf("SignedURL on fileblob = %v, want ErrSignedURLUnsupported", err)
|
|
}
|
|
}
|
|
|
|
func TestBlobOverwrite(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
|
|
// Store initial content
|
|
_, _, err := b.Store(ctx, "test/file.txt", strings.NewReader("initial"))
|
|
if err != nil {
|
|
t.Fatalf("initial Store failed: %v", err)
|
|
}
|
|
|
|
// Overwrite with new content
|
|
_, _, err = b.Store(ctx, "test/file.txt", strings.NewReader("updated"))
|
|
if err != nil {
|
|
t.Fatalf("update Store failed: %v", err)
|
|
}
|
|
|
|
// Verify updated content
|
|
r, err := b.Open(ctx, "test/file.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open failed: %v", err)
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
|
|
data, _ := io.ReadAll(r)
|
|
if string(data) != "updated" {
|
|
t.Errorf("content = %q, want %q", string(data), "updated")
|
|
}
|
|
}
|
|
|
|
func TestOpenBucketSetsNoTmpDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
defer func() { _ = b.Close() }()
|
|
|
|
// fileblob uses os.TempDir() by default for temp files, then os.Rename to
|
|
// the final path. This fails with "invalid cross-device link" when the bucket
|
|
// dir and os.TempDir() are on different filesystems (e.g. Docker volumes).
|
|
// OpenBucket must set no_tmp_dir=true so temp files are created next to the
|
|
// final path instead.
|
|
if !strings.Contains(b.URL(), "no_tmp_dir=true") {
|
|
t.Errorf("URL should contain no_tmp_dir=true to avoid cross-device rename errors, got %q", b.URL())
|
|
}
|
|
|
|
// Verify Store still works with the parameter set
|
|
content := "cross-device test"
|
|
_, _, err = b.Store(ctx, "test/cross-device.txt", strings.NewReader(content))
|
|
if err != nil {
|
|
t.Fatalf("Store failed with no_tmp_dir=true: %v", err)
|
|
}
|
|
|
|
r, err := b.Open(ctx, "test/cross-device.txt")
|
|
if err != nil {
|
|
t.Fatalf("Open failed: %v", err)
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
|
|
data, _ := io.ReadAll(r)
|
|
if string(data) != content {
|
|
t.Errorf("content = %q, want %q", string(data), content)
|
|
}
|
|
}
|
|
|
|
func createTestBlob(t *testing.T) *Blob {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = b.Close() })
|
|
blob, ok := b.(*Blob)
|
|
if !ok {
|
|
t.Fatalf("OpenBucket returned %T, want *Blob", b)
|
|
}
|
|
return blob
|
|
}
|
|
|
|
func fileURLFromPath(path string) string {
|
|
if runtime.GOOS == osWindows {
|
|
// Windows paths need file:///C:/path format
|
|
path = filepath.ToSlash(path)
|
|
return "file:///" + path
|
|
}
|
|
return "file://" + path
|
|
}
|
|
|
|
func TestOpenBucketWritesNoAttrsSidecar(t *testing.T) {
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
defer func() { _ = b.Close() }()
|
|
|
|
if _, _, err := b.Store(ctx, "pkg/thing-1.0.0.tgz", strings.NewReader("content")); err != nil {
|
|
t.Fatalf("Store failed: %v", err)
|
|
}
|
|
|
|
sidecars, err := filepath.Glob(filepath.Join(dir, "*", "*.attrs"))
|
|
if err != nil {
|
|
t.Fatalf("Glob failed: %v", err)
|
|
}
|
|
if len(sidecars) != 0 {
|
|
t.Errorf("got sidecar files %v, want none: a truncated sidecar fails reads that overlap a write", sidecars)
|
|
}
|
|
}
|
|
|
|
// A read overlapping a write to the same key must not fail. fileblob rewrote
|
|
// its ".attrs" sidecar in place, so a reader decoding it mid-write saw a
|
|
// partial file, which the proxy served as a 502 on an artifact it held.
|
|
func TestConcurrentReadsSurviveWritesToSameKey(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
// Go opens files without FILE_SHARE_DELETE, so a writer cannot replace
|
|
// a file a reader holds open: its rename fails with access denied
|
|
// instead of contending. The other platforms exercise this race.
|
|
t.Skip("Windows refuses to replace a file readers hold open")
|
|
}
|
|
const (
|
|
key = "pkg/thing-1.0.0.tgz"
|
|
readers = 4
|
|
readsPerRead = 500
|
|
)
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
defer func() { _ = b.Close() }()
|
|
|
|
payload := strings.Repeat("x", 4096)
|
|
if _, _, err := b.Store(ctx, key, strings.NewReader(payload)); err != nil {
|
|
t.Fatalf("seeding Store failed: %v", err)
|
|
}
|
|
|
|
// The writer reports how it ended: a Store failure would otherwise stop
|
|
// the writes silently and let zero read failures pass for a test that
|
|
// never contended anything.
|
|
done := make(chan struct{})
|
|
var writers sync.WaitGroup
|
|
var writes int
|
|
var writeErr error
|
|
writers.Add(1)
|
|
go func() {
|
|
defer writers.Done()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
}
|
|
if _, _, err := b.Store(ctx, key, strings.NewReader(payload)); err != nil {
|
|
writeErr = err
|
|
return
|
|
}
|
|
writes++
|
|
}
|
|
}()
|
|
|
|
var failures atomic.Int64
|
|
var reading sync.WaitGroup
|
|
for range readers {
|
|
reading.Add(1)
|
|
go func() {
|
|
defer reading.Done()
|
|
for range readsPerRead {
|
|
r, err := b.Open(ctx, key)
|
|
if err != nil {
|
|
failures.Add(1)
|
|
continue
|
|
}
|
|
if _, err := io.Copy(io.Discard, r); err != nil {
|
|
failures.Add(1)
|
|
}
|
|
_ = r.Close()
|
|
}
|
|
}()
|
|
}
|
|
reading.Wait()
|
|
close(done)
|
|
writers.Wait()
|
|
|
|
if writeErr != nil {
|
|
t.Fatalf("writer stopped early: %v", writeErr)
|
|
}
|
|
if writes == 0 {
|
|
t.Fatal("no write completed, so the reads were never contended")
|
|
}
|
|
if got := failures.Load(); got != 0 {
|
|
t.Errorf("%d of %d reads failed while one writer rewrote the same key, want 0", got, readers*readsPerRead)
|
|
}
|
|
}
|
|
|
|
// seedLegacySidecar stores key through a bucket that still writes sidecars, as
|
|
// an earlier version did, and returns the path fileblob actually used. It is
|
|
// discovered rather than assumed, so callers test the real mapping.
|
|
func seedLegacySidecar(t *testing.T, dir, key, payload string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
legacy, err := blob.OpenBucket(ctx, fileURLFromPath(dir)+"?no_tmp_dir=true")
|
|
if err != nil {
|
|
t.Fatalf("opening legacy bucket: %v", err)
|
|
}
|
|
if err := legacy.WriteAll(ctx, key, []byte(payload), nil); err != nil {
|
|
t.Fatalf("legacy WriteAll: %v", err)
|
|
}
|
|
if err := legacy.Close(); err != nil {
|
|
t.Fatalf("closing legacy bucket: %v", err)
|
|
}
|
|
|
|
var found []string
|
|
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() && strings.HasSuffix(path, ".attrs") {
|
|
found = append(found, path)
|
|
}
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
t.Fatalf("walking %s: %v", dir, walkErr)
|
|
}
|
|
if len(found) != 1 {
|
|
t.Fatalf("got sidecars %v, want exactly one", found)
|
|
}
|
|
return found[0]
|
|
}
|
|
|
|
// An interrupted setAttrs leaves a partial sidecar that fails every read of the
|
|
// key, and nothing rewrites one now, so a store has to clear it.
|
|
//
|
|
// One key per storage path the proxy builds: ArtifactPath across ecosystems,
|
|
// metadata blobs, and the Gradle build cache. Scoped npm names, Go's "!" case
|
|
// escaping and the ":" in OCI digests and Debian epochs are the characters
|
|
// most likely to part fileblob's mapping from a plain path join.
|
|
func TestStoreClearsLegacyAttrsSidecar(t *testing.T) {
|
|
keys := []string{
|
|
"npm/@babel/core/7.24.0/core-7.24.0.tgz",
|
|
"maven/org.apache.commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
|
|
"golang/github.com/!burnt!sushi/toml/v1.3.2/v1.3.2.zip",
|
|
"oci/library/nginx/sha256:abc123def456/manifest",
|
|
"debian/tzdata/1:2024a-1/tzdata_2024a-1_all.deb",
|
|
"pypi/requests/2.31.0/requests-2.31.0-py3-none-any.whl",
|
|
"cargo/serde/1.0.197/serde-1.0.197.crate",
|
|
"julia/Example/a1b2c3/a1b2c3.tar.gz",
|
|
"conda/numpy/1.26.4/numpy-1.26.4-py311.conda",
|
|
"_metadata/npm/@babel/core/metadata",
|
|
"_gradle/http-build-cache/0a1b2c3d4e5f",
|
|
// Keys fileblob escapes on every platform.
|
|
"npm/pkg//1.0.0/x.tgz",
|
|
"npm/pkg/../1.0.0/x.tgz",
|
|
}
|
|
|
|
for _, key := range keys {
|
|
t.Run(key, func(t *testing.T) {
|
|
assertStoreClearsSidecar(t, key)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertStoreClearsSidecar(t *testing.T, key string) {
|
|
t.Helper()
|
|
const payload = "payload"
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
|
|
sidecar := seedLegacySidecar(t, dir, key, payload)
|
|
if err := os.WriteFile(sidecar, []byte(`{"user.content_type":"appl`), 0o600); err != nil {
|
|
t.Fatalf("corrupting sidecar: %v", err)
|
|
}
|
|
|
|
b := openFileBlob(t, dir)
|
|
reader, err := b.bucket.NewReader(ctx, key, nil)
|
|
if err == nil {
|
|
_ = reader.Close()
|
|
t.Fatal("corrupt sidecar did not fail the read, so it is not the file fileblob reads for this key")
|
|
}
|
|
|
|
derived := b.legacySidecarPath(key)
|
|
if _, _, err := b.Store(ctx, key, strings.NewReader(payload)); err != nil {
|
|
t.Fatalf("Store failed: %v", err)
|
|
}
|
|
|
|
if derived == "" {
|
|
t.Fatalf("legacySidecarPath declined %q, but fileblob wrote %q", key, sidecar)
|
|
}
|
|
if derived != sidecar {
|
|
t.Fatalf("derived %q, but fileblob wrote %q", derived, sidecar)
|
|
}
|
|
if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
|
|
t.Errorf("sidecar still present after Store, stat err = %v", err)
|
|
}
|
|
assertReadsBack(t, b, key, payload)
|
|
}
|
|
|
|
func assertReadsBack(t *testing.T, b *Blob, key, want string) {
|
|
t.Helper()
|
|
r, err := b.Open(context.Background(), key)
|
|
if err != nil {
|
|
t.Fatalf("read still failing after Store cleared the sidecar: %v", err)
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
got, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll failed: %v", err)
|
|
}
|
|
if string(got) != want {
|
|
t.Errorf("got %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func openFileBlob(t *testing.T, dir string) *Blob {
|
|
t.Helper()
|
|
s, err := OpenBucket(context.Background(), fileURLFromPath(dir))
|
|
if err != nil {
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = s.Close() })
|
|
b, ok := s.(*Blob)
|
|
if !ok {
|
|
t.Fatalf("got %T, want *Blob", s)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// fileblob escapes a non-local key in a way this cannot reproduce, and one
|
|
// holding ".." resolves outside the cache directory. Removal declines both
|
|
// rather than delete the wrong file.
|
|
func TestLegacySidecarPathEscapesLikeFileblob(t *testing.T) {
|
|
root := filepath.FromSlash("/var/cache/proxy")
|
|
b := &Blob{fileRoot: root}
|
|
windows := runtime.GOOS == osWindows
|
|
|
|
for _, tc := range []struct{ key, unix, windows string }{
|
|
{"npm/pkg/1.0.0/x.tgz", "npm/pkg/1.0.0/x.tgz", "npm/pkg/1.0.0/x.tgz"},
|
|
{"npm/pkg//1.0.0/x.tgz", "npm/pkg/__0x2f__1.0.0/x.tgz", "npm/pkg/__0x2f__1.0.0/x.tgz"},
|
|
{"npm/pkg/../../etc/passwd", "npm/pkg/..__0x2f__..__0x2f__etc/passwd", "npm/pkg/..__0x2f__..__0x2f__etc/passwd"},
|
|
{"npm/pkg/1.0.0/", "npm/pkg/1.0.0__0x2f__", "npm/pkg/1.0.0__0x2f__"},
|
|
{"npm/a\x01b", "npm/a__0x1__b", "npm/a__0x1__b"},
|
|
{"oci/nginx/sha256:abc/manifest", "oci/nginx/sha256:abc/manifest", "oci/nginx/sha256__0x3a__abc/manifest"},
|
|
{"debian/tzdata/1:2024a-1/x.deb", "debian/tzdata/1:2024a-1/x.deb", "debian/tzdata/1__0x3a__2024a-1/x.deb"},
|
|
{`npm/a\b`, `npm/a\b`, "npm/a__0x5c__b"},
|
|
} {
|
|
want := tc.unix
|
|
if windows {
|
|
want = tc.windows
|
|
}
|
|
want = filepath.Join(root, filepath.FromSlash(want)) + attrsExt
|
|
if got := b.legacySidecarPath(tc.key); got != want {
|
|
t.Errorf("legacySidecarPath(%q) = %q, want %q", tc.key, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLegacySidecarPathDeclinesNonLocalKeys(t *testing.T) {
|
|
b := &Blob{fileRoot: filepath.FromSlash("/var/cache/proxy")}
|
|
|
|
for _, key := range []string{"", ".", "..", "/etc/passwd"} {
|
|
if got := b.legacySidecarPath(key); got != "" {
|
|
t.Errorf("legacySidecarPath(%q) = %q, want \"\"", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cloud backends have no local directory, so nothing is removed for them.
|
|
func TestLegacySidecarPathEmptyForCloudBackends(t *testing.T) {
|
|
b := &Blob{}
|
|
if got := b.legacySidecarPath("npm/pkg/1.0.0/x.tgz"); got != "" {
|
|
t.Errorf("legacySidecarPath = %q, want \"\" when there is no file root", got)
|
|
}
|
|
}
|
|
|
|
// Cleanup that cannot complete must not fail the write. A non-empty directory
|
|
// at the sidecar path makes os.Remove fail with something other than not-exist
|
|
// on every platform, which is what a Windows sharing violation would look like
|
|
// here.
|
|
func TestStoreSucceedsWhenSidecarCannotBeRemoved(t *testing.T) {
|
|
const key = "npm/pkg/1.0.0/pkg-1.0.0.tgz"
|
|
const payload = "payload"
|
|
dir := t.TempDir()
|
|
ctx := context.Background()
|
|
|
|
b := openFileBlob(t, dir)
|
|
|
|
sidecar := filepath.Join(dir, filepath.FromSlash(key)) + ".attrs"
|
|
if err := os.MkdirAll(filepath.Join(sidecar, "blocker"), 0o750); err != nil {
|
|
t.Fatalf("seeding an unremovable sidecar: %v", err)
|
|
}
|
|
if err := os.Remove(sidecar); err == nil {
|
|
t.Fatal("sidecar path was removable, so the test proves nothing")
|
|
}
|
|
|
|
if _, _, err := b.Store(ctx, key, strings.NewReader(payload)); err != nil {
|
|
t.Errorf("Store failed because cleanup could not complete: %v", err)
|
|
}
|
|
}
|
|
|
|
type failingReader struct {
|
|
data []byte
|
|
err error
|
|
}
|
|
|
|
func (r *failingReader) Read(p []byte) (int, error) {
|
|
if len(r.data) == 0 {
|
|
return 0, r.err
|
|
}
|
|
n := copy(p, r.data)
|
|
r.data = r.data[n:]
|
|
return n, nil
|
|
}
|
|
|
|
// A download that breaks off midway must not leave its first half behind as
|
|
// an object, or every failed fetch adds an orphan nothing ever deletes.
|
|
func TestBlobStoreFailedCopyLeavesNoObject(t *testing.T) {
|
|
b := createTestBlob(t)
|
|
ctx := context.Background()
|
|
readErr := errors.New("connection reset")
|
|
|
|
_, _, err := b.Store(ctx, "npm/lodash/4.17.21/lodash.tgz", &failingReader{
|
|
data: []byte(strings.Repeat("partial", 1024)),
|
|
err: readErr,
|
|
})
|
|
if !errors.Is(err, readErr) {
|
|
t.Fatalf("Store error = %v, want %v", err, readErr)
|
|
}
|
|
|
|
exists, err := b.Exists(ctx, "npm/lodash/4.17.21/lodash.tgz")
|
|
if err != nil {
|
|
t.Fatalf("Exists failed: %v", err)
|
|
}
|
|
if exists {
|
|
t.Error("failed Store left a partial object behind")
|
|
}
|
|
}
|