2026-01-29 16:13:16 +00:00
|
|
|
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"
|
2026-01-29 20:41:52 +00:00
|
|
|
"path/filepath"
|
|
|
|
|
"runtime"
|
2026-01-29 16:13:16 +00:00
|
|
|
"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"
|
2026-01-29 16:13:16 +00:00
|
|
|
"testing"
|
2026-04-27 12:04:38 +01:00
|
|
|
"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"
|
2026-01-29 16:13:16 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestOpenBucket(t *testing.T) {
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
2026-01-29 16:13:16 +00:00
|
|
|
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)
|
|
|
|
|
}
|
2026-01-29 16:13:16 +00:00
|
|
|
|
|
|
|
|
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")
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
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) {
|
2026-03-18 10:59:29 +00:00
|
|
|
assertLargeFileRoundTrip(t, createTestBlob(t))
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:07:31 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:13:16 +00:00
|
|
|
func createTestBlob(t *testing.T) *Blob {
|
|
|
|
|
t.Helper()
|
|
|
|
|
dir := t.TempDir()
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
2026-01-29 20:41:52 +00:00
|
|
|
b, err := OpenBucket(ctx, fileURLFromPath(dir))
|
2026-01-29 16:13:16 +00:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("OpenBucket failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
t.Cleanup(func() { _ = b.Close() })
|
2026-08-29 11:23:07 +01:00
|
|
|
blob, ok := b.(*Blob)
|
|
|
|
|
if !ok {
|
|
|
|
|
t.Fatalf("OpenBucket returned %T, want *Blob", b)
|
|
|
|
|
}
|
|
|
|
|
return blob
|
2026-01-29 16:13:16 +00:00
|
|
|
}
|
2026-01-29 20:41:52 +00:00
|
|
|
|
|
|
|
|
func fileURLFromPath(path string) string {
|
2026-03-18 10:59:29 +00:00
|
|
|
if runtime.GOOS == osWindows {
|
2026-01-29 20:41:52 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-14 08:36:21 +02:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|