Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/internal/storage/blob.go
simon chrzanowski d7a6a891e3
Abort blob writes when the download fails
gocloud commits whatever a writer received when it is closed, unless its
context was cancelled first. Store closed the writer after a failed copy,
so every download that broke off midway left a partial object under its
fetch path. No database row points at it, so it was never served, but
nothing deleted it either and the orphans kept taking disk space.

- Cancel the writer's context before closing it when the copy fails
- Add a test storing from a reader that errors midway
2026-10-14 08:36:21 +02:00

365 lines
10 KiB
Go

package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"gocloud.dev/blob"
_ "gocloud.dev/blob/azureblob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/s3blob"
"gocloud.dev/gcerrors"
)
const osWindows = "windows"
// attrsExt is fileblob's sidecar suffix, kept only to clear sidecars an
// earlier version wrote.
const attrsExt = ".attrs"
// Blob implements Storage using gocloud.dev/blob.
// Supports local filesystem (file://) and S3 (s3://) URLs.
type Blob struct {
bucket *blob.Bucket
url string
// fileRoot is the directory backing a file:// bucket, empty for cloud
// backends. Used only to clear sidecars an earlier version wrote.
fileRoot string
}
// OpenBucket opens a blob bucket from a URL.
//
// Supported URL schemes:
// - file:///path/to/dir - Local filesystem storage
// - s3://bucket-name - Amazon S3 (uses AWS_* environment variables)
// - s3://bucket-name?region=us-east-1&endpoint=http://localhost:9000 - S3-compatible (MinIO, etc.)
// - gs://bucket-name - Google Cloud Storage (uses Application Default Credentials;
// supports Workload Identity on GKE/GCE without any extra configuration)
// - azblob://container-name - Azure Blob Storage
//
// For local filesystem, the directory is created if it doesn't exist.
//
//nolint:ireturn // The URL scheme selects the storage implementation.
func OpenBucket(ctx context.Context, urlStr string) (Storage, error) {
if strings.HasPrefix(urlStr, "gs://") {
return OpenGCS(ctx, urlStr)
}
var fileRoot string
// Handle file:// URLs specially to create the directory
if strings.HasPrefix(urlStr, "file://") {
path := strings.TrimPrefix(urlStr, "file://")
// Handle file:/// (three slashes) for absolute paths
if strings.HasPrefix(path, "/") && runtime.GOOS != osWindows {
// Unix: file:///path -> /path
// path is already correct
} else if strings.HasPrefix(path, "/") && runtime.GOOS == osWindows {
// Windows: file:///C:/path -> C:/path
path = strings.TrimPrefix(path, "/")
}
// Convert forward slashes to native path separators for filesystem operations
nativePath := filepath.FromSlash(path)
// Ensure directory exists
if err := os.MkdirAll(nativePath, dirPermissions); err != nil {
return nil, fmt.Errorf("creating directory: %w", err)
}
// fileblob requires an absolute path with forward slashes
absPath, err := filepath.Abs(nativePath)
if err != nil {
return nil, fmt.Errorf("resolving path: %w", err)
}
fileRoot = absPath
// Convert back to URL format with forward slashes
urlPath := filepath.ToSlash(absPath)
if runtime.GOOS == osWindows {
// Windows needs file:///C:/path format
urlStr = "file:///" + urlPath
} else {
urlStr = "file://" + urlPath
}
// Create temp files next to the final path instead of in os.TempDir.
// This avoids "invalid cross-device link" errors from os.Rename when
// the bucket directory and os.TempDir are on different filesystems
// (e.g. Docker volume mounts).
//
// Do not write fileblob's ".attrs" sidecar. It is rewritten with
// os.Create, truncating in place outside the atomic rename that
// protects the blob, so a read overlapping a write can decode a
// partial file; a missing one defaults cleanly, a truncated one does
// not. Nothing in the proxy needs it: Store sets no ContentType, and
// Size reads os.Stat via Attributes.
urlStr += "?no_tmp_dir=true&metadata=skip"
}
bucket, err := blob.OpenBucket(ctx, urlStr)
if err != nil {
return nil, fmt.Errorf("opening bucket: %w", err)
}
return &Blob{bucket: bucket, url: urlStr, fileRoot: fileRoot}, nil
}
// legacySidecarPath gives the ".attrs" path an earlier version wrote for key,
// or "" when that path would not be a file inside fileRoot.
func (b *Blob) legacySidecarPath(key string) string {
if p := b.localPath(key); p != "" {
return p + attrsExt
}
return ""
}
// localPath gives the file fileblob keeps key in, or "" when that would not
// be a file inside fileRoot.
//
// The key is escaped the way fileblob escapes it on the way to disk.
// filepath.Localize then validates the escaped form: it rejects an empty,
// absolute or ".." path, and "." would name fileRoot itself. What it declines
// are keys the proxy never produces.
func (b *Blob) localPath(key string) string {
if b.fileRoot == "" {
return ""
}
rel, err := filepath.Localize(escapeKey(key))
if err != nil || rel == "." {
return ""
}
return filepath.Join(b.fileRoot, rel)
}
// escapeKey mirrors fileblob's unexported escapeKey, which hex-escapes a rune
// as "__0x<hex>__". Slashes stay as "/" for filepath.Localize to convert.
func escapeKey(key string) string {
runes := []rune(key)
var out strings.Builder
for i, r := range runes {
if escapeRune(runes, i) {
fmt.Fprintf(&out, "__%#x__", r)
} else {
out.WriteRune(r)
}
}
return out.String()
}
// escapeRune is fileblob's rule for which runes of a key to escape: control
// characters, a raw path separator, a slash that would form "../", "//" or
// end the key, and on Windows the characters its filesystem reserves.
func escapeRune(r []rune, i int) bool {
c := r[i]
switch {
case c < ' ':
return true
case os.PathSeparator != '/' && c == os.PathSeparator:
return true
case i > 1 && c == '/' && r[i-1] == '.' && r[i-2] == '.':
return true
case i > 0 && c == '/' && r[i-1] == '/':
return true
case c == '/' && i == len(r)-1:
return true
case os.PathSeparator == '\\' && strings.ContainsRune(`<>:"|?*`, c):
return true
}
return false
}
// clearLegacySidecar removes the ".attrs" file an earlier version wrote for
// key. Nothing rewrites one now, so a sidecar left partial by an interrupted
// write would fail every read of that key for good. Removing is atomic where
// the rewrite was not, so a concurrent reader gets the whole old file or
// nothing.
//
// Failure is deliberately not fatal. Usually the key never had a sidecar and
// os.Remove reports not-exist. A real failure 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 without
// FILE_SHARE_DELETE, so a reader holding the sidecar open blocks deletion, and
// that reader is the very workload this change protects. The next store of the
// key retries.
func (b *Blob) clearLegacySidecar(key string) {
if sidecar := b.legacySidecarPath(key); sidecar != "" {
_ = os.Remove(sidecar)
}
}
func (b *Blob) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
b.clearLegacySidecar(path)
// Compute hash while writing
h := sha256.New()
tee := io.TeeReader(r, h)
// gocloud commits whatever was written when the writer closes, unless
// its context is cancelled first, so a failed copy cancels it to abort.
writeCtx, abort := context.WithCancel(ctx)
defer abort()
opts := &blob.WriterOptions{}
w, err := b.bucket.NewWriter(writeCtx, path, opts)
if err != nil {
return 0, "", fmt.Errorf("creating writer: %w", err)
}
size, err := io.Copy(w, tee)
if err != nil {
abort()
_ = w.Close()
return 0, "", fmt.Errorf("writing content: %w", err)
}
if err := w.Close(); err != nil {
return 0, "", fmt.Errorf("closing writer: %w", err)
}
hash := hex.EncodeToString(h.Sum(nil))
return size, hash, nil
}
func (b *Blob) Open(ctx context.Context, path string) (io.ReadCloser, error) {
if localPath := b.localPath(path); localPath != "" {
r, err := os.Open(localPath)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("opening local reader: %w", err)
}
return r, nil
}
r, err := b.bucket.NewReader(ctx, path, nil)
if err != nil {
if isNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("opening reader: %w", err)
}
return r, nil
}
func (b *Blob) Exists(ctx context.Context, path string) (bool, error) {
exists, err := b.bucket.Exists(ctx, path)
if err != nil {
return false, fmt.Errorf("checking existence: %w", err)
}
return exists, nil
}
// Delete removes the object at path. On a file:// bucket it also removes the
// object's fetch directory once empty, since fileblob leaves directories
// behind. Any other directory, such as a version directory another fetch may
// be creating its own directory in, is left alone.
func (b *Blob) Delete(ctx context.Context, path string) error {
err := b.bucket.Delete(ctx, path)
if err != nil && !isNotExist(err) {
return fmt.Errorf("deleting object: %w", err)
}
if p := b.localPath(path); p != "" {
if dir := filepath.Dir(p); dir != b.fileRoot && isFetchDir(filepath.Base(dir)) {
_ = os.Remove(dir) // fails, harmlessly, while the directory holds anything
}
}
return nil
}
func (b *Blob) SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error) {
url, err := b.bucket.SignedURL(ctx, path, &blob.SignedURLOptions{
Method: http.MethodGet,
Expiry: expiry,
})
if err != nil {
if gcerrors.Code(err) == gcerrors.Unimplemented {
return "", ErrSignedURLUnsupported
}
return "", fmt.Errorf("signing URL: %w", err)
}
return url, nil
}
func (b *Blob) Size(ctx context.Context, path string) (int64, error) {
attrs, err := b.bucket.Attributes(ctx, path)
if err != nil {
if isNotExist(err) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("getting attributes: %w", err)
}
return attrs.Size, nil
}
func (b *Blob) UsedSpace(ctx context.Context) (int64, error) {
var total int64
iter := b.bucket.List(nil)
for {
obj, err := iter.Next(ctx)
if err == io.EOF {
break
}
if err != nil {
return 0, fmt.Errorf("listing objects: %w", err)
}
total += obj.Size
}
return total, nil
}
// ListPrefix returns object metadata for keys under a prefix.
func (b *Blob) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) {
iter := b.bucket.List(&blob.ListOptions{Prefix: prefix})
objects := make([]ObjectInfo, 0)
for {
obj, err := iter.Next(ctx)
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("listing objects: %w", err)
}
if obj.IsDir {
continue
}
info := ObjectInfo{
Path: obj.Key,
Size: obj.Size,
ModTime: obj.ModTime,
}
objects = append(objects, info)
}
return objects, nil
}
func (b *Blob) Close() error {
return b.bucket.Close()
}
func (b *Blob) URL() string {
return b.url
}
func isNotExist(err error) bool {
return gcerrors.Code(err) == gcerrors.NotFound
}