Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 16:19:16 -05:00
pkg-proxy/internal/storage/blob_test.go

700 lines
19 KiB
Go
Raw Permalink Normal View History

package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
Stop writing fileblob's .attrs sidecar (#328) * Stop writing fileblob's .attrs sidecar fileblob stores blob metadata in an ".attrs" file per object and rewrites it with os.Create, truncating in place outside the atomic rename that protects the blob. A read overlapping a write decodes a partial file and fails with "opening reader: EOF", served as a 502. One writer against four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob is most exposed to it, rewriting a key on every refresh while readers are served from it. Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is imported only by internal/storage, Store sets no ContentType, and Attributes is used only for Size, which comes from os.Stat. A missing sidecar already defaults cleanly, so "metadata=skip" removes the hazard rather than locking around it, and saves a write per store. * Clear .attrs sidecars left by earlier versions metadata=skip stops fileblob rewriting sidecars but does not delete ones already on disk, so a sidecar left partial by an interrupted write now fails every read of its key for good. Before, a later store repaired it by rewriting. Store therefore removes the sidecar for the key it writes. Removal is atomic where the rewrite was not, so a concurrent reader gets the whole old file or nothing. Delete already removes sidecars, so the two paths drain a cache between them. Deriving that path is necessary because fileblob's key escaping is unexported. It is the identity for a plain key and parts from one only for keys that are not valid local paths, which is what filepath.Localize rejects. That also keeps the removal inside the cache directory: without it a key holding ".." resolves outside. The clearing test runs one key per storage path the proxy builds, seeded through a bucket that still writes sidecars so the path under test is fileblob's own. * Explain why failed sidecar cleanup does not fail the store Move the removal into clearLegacySidecar and say why its error is dropped rather than returned. A failed removal leaves exactly the state this change inherited, while failing the write would turn a cleanup miss into a failed request. Windows makes that concrete: Go opens files with FILE_SHARE_READ and FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and that reader is the workload this change exists to protect. Propagating would fail stores during exactly the overlap being fixed. The next store of the key retries. A test pins it, using a non-empty directory at the sidecar path to make os.Remove fail with something other than not-exist on any platform. * Fail the concurrency test if its writer stops The writer returned silently when Store failed, so the test could pass with no concurrent writes at all. Its error is now reported, and the test also checks that at least one write completed. Reporting it showed the writer had been dying on Windows at its first collision: Go opens files without FILE_SHARE_DELETE, so a reader holding the file open makes the writer's rename fail with access denied. The test now skips there, since it cannot contend a writer with readers on that platform. * Clear legacy sidecars for keys fileblob escapes legacySidecarPath declined any key filepath.Localize rejects, which on Windows is every key with a colon: OCI digests and Debian epochs. Their sidecars were never cleared there, and a truncated one kept failing reads, since fileblob still reads a sidecar it finds under metadata=skip. fileblob hex-escapes such characters on the way to disk. The path is now derived the same way, so the sidecar is looked for where fileblob wrote it. Localize still validates the escaped form, which keeps the removal inside the cache directory. * Drop stale comment about declining colon keys on Windows 623ff3e made legacySidecarPath escape keys the way fileblob does, so colon-bearing keys are now cleared on Windows and the OCI and Debian rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment described the behaviour before that commit. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
Stop writing fileblob's .attrs sidecar (#328) * Stop writing fileblob's .attrs sidecar fileblob stores blob metadata in an ".attrs" file per object and rewrites it with os.Create, truncating in place outside the atomic rename that protects the blob. A read overlapping a write decodes a partial file and fails with "opening reader: EOF", served as a 502. One writer against four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob is most exposed to it, rewriting a key on every refresh while readers are served from it. Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is imported only by internal/storage, Store sets no ContentType, and Attributes is used only for Size, which comes from os.Stat. A missing sidecar already defaults cleanly, so "metadata=skip" removes the hazard rather than locking around it, and saves a write per store. * Clear .attrs sidecars left by earlier versions metadata=skip stops fileblob rewriting sidecars but does not delete ones already on disk, so a sidecar left partial by an interrupted write now fails every read of its key for good. Before, a later store repaired it by rewriting. Store therefore removes the sidecar for the key it writes. Removal is atomic where the rewrite was not, so a concurrent reader gets the whole old file or nothing. Delete already removes sidecars, so the two paths drain a cache between them. Deriving that path is necessary because fileblob's key escaping is unexported. It is the identity for a plain key and parts from one only for keys that are not valid local paths, which is what filepath.Localize rejects. That also keeps the removal inside the cache directory: without it a key holding ".." resolves outside. The clearing test runs one key per storage path the proxy builds, seeded through a bucket that still writes sidecars so the path under test is fileblob's own. * Explain why failed sidecar cleanup does not fail the store Move the removal into clearLegacySidecar and say why its error is dropped rather than returned. A failed removal leaves exactly the state this change inherited, while failing the write would turn a cleanup miss into a failed request. Windows makes that concrete: Go opens files with FILE_SHARE_READ and FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and that reader is the workload this change exists to protect. Propagating would fail stores during exactly the overlap being fixed. The next store of the key retries. A test pins it, using a non-empty directory at the sidecar path to make os.Remove fail with something other than not-exist on any platform. * Fail the concurrency test if its writer stops The writer returned silently when Store failed, so the test could pass with no concurrent writes at all. Its error is now reported, and the test also checks that at least one write completed. Reporting it showed the writer had been dying on Windows at its first collision: Go opens files without FILE_SHARE_DELETE, so a reader holding the file open makes the writer's rename fail with access denied. The test now skips there, since it cannot contend a writer with readers on that platform. * Clear legacy sidecars for keys fileblob escapes legacySidecarPath declined any key filepath.Localize rejects, which on Windows is every key with a colon: OCI digests and Debian epochs. Their sidecars were never cleared there, and a truncated one kept failing reads, since fileblob still reads a sidecar it finds under metadata=skip. fileblob hex-escapes such characters on the way to disk. The path is now derived the same way, so the sidecar is looked for where fileblob wrote it. Localize still validates the escaped form, which keeps the removal inside the cache directory. * Drop stale comment about declining colon keys on Windows 623ff3e made legacySidecarPath escape keys the way fileblob does, so colon-bearing keys are now cleared on Windows and the OCI and Debian rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment described the behaviour before that commit. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
"sync"
"sync/atomic"
"testing"
"time"
Stop writing fileblob's .attrs sidecar (#328) * Stop writing fileblob's .attrs sidecar fileblob stores blob metadata in an ".attrs" file per object and rewrites it with os.Create, truncating in place outside the atomic rename that protects the blob. A read overlapping a write decodes a partial file and fails with "opening reader: EOF", served as a 502. One writer against four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob is most exposed to it, rewriting a key on every refresh while readers are served from it. Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is imported only by internal/storage, Store sets no ContentType, and Attributes is used only for Size, which comes from os.Stat. A missing sidecar already defaults cleanly, so "metadata=skip" removes the hazard rather than locking around it, and saves a write per store. * Clear .attrs sidecars left by earlier versions metadata=skip stops fileblob rewriting sidecars but does not delete ones already on disk, so a sidecar left partial by an interrupted write now fails every read of its key for good. Before, a later store repaired it by rewriting. Store therefore removes the sidecar for the key it writes. Removal is atomic where the rewrite was not, so a concurrent reader gets the whole old file or nothing. Delete already removes sidecars, so the two paths drain a cache between them. Deriving that path is necessary because fileblob's key escaping is unexported. It is the identity for a plain key and parts from one only for keys that are not valid local paths, which is what filepath.Localize rejects. That also keeps the removal inside the cache directory: without it a key holding ".." resolves outside. The clearing test runs one key per storage path the proxy builds, seeded through a bucket that still writes sidecars so the path under test is fileblob's own. * Explain why failed sidecar cleanup does not fail the store Move the removal into clearLegacySidecar and say why its error is dropped rather than returned. A failed removal leaves exactly the state this change inherited, while failing the write would turn a cleanup miss into a failed request. Windows makes that concrete: Go opens files with FILE_SHARE_READ and FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and that reader is the workload this change exists to protect. Propagating would fail stores during exactly the overlap being fixed. The next store of the key retries. A test pins it, using a non-empty directory at the sidecar path to make os.Remove fail with something other than not-exist on any platform. * Fail the concurrency test if its writer stops The writer returned silently when Store failed, so the test could pass with no concurrent writes at all. Its error is now reported, and the test also checks that at least one write completed. Reporting it showed the writer had been dying on Windows at its first collision: Go opens files without FILE_SHARE_DELETE, so a reader holding the file open makes the writer's rename fail with access denied. The test now skips there, since it cannot contend a writer with readers on that platform. * Clear legacy sidecars for keys fileblob escapes legacySidecarPath declined any key filepath.Localize rejects, which on Windows is every key with a colon: OCI digests and Debian epochs. Their sidecars were never cleared there, and a truncated one kept failing reads, since fileblob still reads a sidecar it finds under metadata=skip. fileblob hex-escapes such characters on the way to disk. The path is now derived the same way, so the sidecar is looked for where fileblob wrote it. Localize still validates the escaped form, which keeps the removal inside the cache directory. * Drop stale comment about declining colon keys on Windows 623ff3e made legacySidecarPath escape keys the way fileblob does, so colon-bearing keys are now cleared on Windows and the OCI and Debian rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment described the behaviour before that commit. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
"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() }()
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
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)
}
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
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
}
Stop writing fileblob's .attrs sidecar (#328) * Stop writing fileblob's .attrs sidecar fileblob stores blob metadata in an ".attrs" file per object and rewrites it with os.Create, truncating in place outside the atomic rename that protects the blob. A read overlapping a write decodes a partial file and fails with "opening reader: EOF", served as a 502. One writer against four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob is most exposed to it, rewriting a key on every refresh while readers are served from it. Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is imported only by internal/storage, Store sets no ContentType, and Attributes is used only for Size, which comes from os.Stat. A missing sidecar already defaults cleanly, so "metadata=skip" removes the hazard rather than locking around it, and saves a write per store. * Clear .attrs sidecars left by earlier versions metadata=skip stops fileblob rewriting sidecars but does not delete ones already on disk, so a sidecar left partial by an interrupted write now fails every read of its key for good. Before, a later store repaired it by rewriting. Store therefore removes the sidecar for the key it writes. Removal is atomic where the rewrite was not, so a concurrent reader gets the whole old file or nothing. Delete already removes sidecars, so the two paths drain a cache between them. Deriving that path is necessary because fileblob's key escaping is unexported. It is the identity for a plain key and parts from one only for keys that are not valid local paths, which is what filepath.Localize rejects. That also keeps the removal inside the cache directory: without it a key holding ".." resolves outside. The clearing test runs one key per storage path the proxy builds, seeded through a bucket that still writes sidecars so the path under test is fileblob's own. * Explain why failed sidecar cleanup does not fail the store Move the removal into clearLegacySidecar and say why its error is dropped rather than returned. A failed removal leaves exactly the state this change inherited, while failing the write would turn a cleanup miss into a failed request. Windows makes that concrete: Go opens files with FILE_SHARE_READ and FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and that reader is the workload this change exists to protect. Propagating would fail stores during exactly the overlap being fixed. The next store of the key retries. A test pins it, using a non-empty directory at the sidecar path to make os.Remove fail with something other than not-exist on any platform. * Fail the concurrency test if its writer stops The writer returned silently when Store failed, so the test could pass with no concurrent writes at all. Its error is now reported, and the test also checks that at least one write completed. Reporting it showed the writer had been dying on Windows at its first collision: Go opens files without FILE_SHARE_DELETE, so a reader holding the file open makes the writer's rename fail with access denied. The test now skips there, since it cannot contend a writer with readers on that platform. * Clear legacy sidecars for keys fileblob escapes legacySidecarPath declined any key filepath.Localize rejects, which on Windows is every key with a colon: OCI digests and Debian epochs. Their sidecars were never cleared there, and a truncated one kept failing reads, since fileblob still reads a sidecar it finds under metadata=skip. fileblob hex-escapes such characters on the way to disk. The path is now derived the same way, so the sidecar is looked for where fileblob wrote it. Localize still validates the escaped form, which keeps the removal inside the cache directory. * Drop stale comment about declining colon keys on Windows 623ff3e made legacySidecarPath escape keys the way fileblob does, so colon-bearing keys are now cleared on Windows and the OCI and Debian rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment described the behaviour before that commit. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
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)
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
reader, err := b.bucket.NewReader(ctx, key, nil)
if err == nil {
_ = reader.Close()
Stop writing fileblob's .attrs sidecar (#328) * Stop writing fileblob's .attrs sidecar fileblob stores blob metadata in an ".attrs" file per object and rewrites it with os.Create, truncating in place outside the atomic rename that protects the blob. A read overlapping a write decodes a partial file and fails with "opening reader: EOF", served as a 502. One writer against four readers on a single key failed 408 of 2000 reads. cacheMetadataBlob is most exposed to it, rewriting a key on every refresh while readers are served from it. Nothing in the proxy reads what the sidecar holds. gocloud.dev/blob is imported only by internal/storage, Store sets no ContentType, and Attributes is used only for Size, which comes from os.Stat. A missing sidecar already defaults cleanly, so "metadata=skip" removes the hazard rather than locking around it, and saves a write per store. * Clear .attrs sidecars left by earlier versions metadata=skip stops fileblob rewriting sidecars but does not delete ones already on disk, so a sidecar left partial by an interrupted write now fails every read of its key for good. Before, a later store repaired it by rewriting. Store therefore removes the sidecar for the key it writes. Removal is atomic where the rewrite was not, so a concurrent reader gets the whole old file or nothing. Delete already removes sidecars, so the two paths drain a cache between them. Deriving that path is necessary because fileblob's key escaping is unexported. It is the identity for a plain key and parts from one only for keys that are not valid local paths, which is what filepath.Localize rejects. That also keeps the removal inside the cache directory: without it a key holding ".." resolves outside. The clearing test runs one key per storage path the proxy builds, seeded through a bucket that still writes sidecars so the path under test is fileblob's own. * Explain why failed sidecar cleanup does not fail the store Move the removal into clearLegacySidecar and say why its error is dropped rather than returned. A failed removal leaves exactly the state this change inherited, while failing the write would turn a cleanup miss into a failed request. Windows makes that concrete: Go opens files with FILE_SHARE_READ and FILE_SHARE_WRITE but not FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and that reader is the workload this change exists to protect. Propagating would fail stores during exactly the overlap being fixed. The next store of the key retries. A test pins it, using a non-empty directory at the sidecar path to make os.Remove fail with something other than not-exist on any platform. * Fail the concurrency test if its writer stops The writer returned silently when Store failed, so the test could pass with no concurrent writes at all. Its error is now reported, and the test also checks that at least one write completed. Reporting it showed the writer had been dying on Windows at its first collision: Go opens files without FILE_SHARE_DELETE, so a reader holding the file open makes the writer's rename fail with access denied. The test now skips there, since it cannot contend a writer with readers on that platform. * Clear legacy sidecars for keys fileblob escapes legacySidecarPath declined any key filepath.Localize rejects, which on Windows is every key with a colon: OCI digests and Debian epochs. Their sidecars were never cleared there, and a truncated one kept failing reads, since fileblob still reads a sidecar it finds under metadata=skip. fileblob hex-escapes such characters on the way to disk. The path is now derived the same way, so the sidecar is looked for where fileblob wrote it. Localize still validates the escaped form, which keeps the removal inside the cache directory. * Drop stale comment about declining colon keys on Windows 623ff3e made legacySidecarPath escape keys the way fileblob does, so colon-bearing keys are now cleared on Windows and the OCI and Debian rows in TestStoreClearsLegacyAttrsSidecar prove it. The comment described the behaviour before that commit. --------- Co-authored-by: Andrew Nesbitt <andrewnez@gmail.com>
2026-09-15 12:07:18 -07:00
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")
}
}