133 lines
3.8 KiB
Go
133 lines
3.8 KiB
Go
|
|
package blobstore
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"io/fs"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
)
|
||
|
|
|
||
|
|
// LocalFS stores blobs as files beneath a root directory.
|
||
|
|
type LocalFS struct {
|
||
|
|
root string
|
||
|
|
}
|
||
|
|
|
||
|
|
var _ BlobStore = (*LocalFS)(nil)
|
||
|
|
|
||
|
|
// NewLocalFS returns a blob store rooted at dir, creating it if necessary.
|
||
|
|
func NewLocalFS(dir string) (*LocalFS, error) {
|
||
|
|
abs, err := filepath.Abs(dir)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("blobstore: resolve root %q: %w", dir, err)
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(abs, 0o750); err != nil {
|
||
|
|
return nil, fmt.Errorf("blobstore: create root %q: %w", abs, err)
|
||
|
|
}
|
||
|
|
return &LocalFS{root: abs}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Root returns the absolute directory holding the blobs.
|
||
|
|
func (l *LocalFS) Root() string { return l.root }
|
||
|
|
|
||
|
|
// Put writes the stream to a temporary file and renames it into place, so a
|
||
|
|
// failed or oversized upload never leaves a partial object visible.
|
||
|
|
func (l *LocalFS) Put(ctx context.Context, key string, r io.Reader, maxBytes int64) (int64, error) {
|
||
|
|
path, err := l.resolve(key)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||
|
|
return 0, fmt.Errorf("blobstore: create parent of %q: %w", key, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".upload-*")
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("blobstore: create temp file: %w", err)
|
||
|
|
}
|
||
|
|
tmpName := tmp.Name()
|
||
|
|
defer func() {
|
||
|
|
tmp.Close()
|
||
|
|
os.Remove(tmpName) // no-op once the rename has succeeded
|
||
|
|
}()
|
||
|
|
|
||
|
|
// Copy one byte past the limit so an oversized payload is detectable.
|
||
|
|
written, err := io.Copy(tmp, io.LimitReader(r, maxBytes+1))
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("blobstore: write %q: %w", key, err)
|
||
|
|
}
|
||
|
|
if written > maxBytes {
|
||
|
|
return written, fmt.Errorf("%w: limit is %d bytes", ErrTooLarge, maxBytes)
|
||
|
|
}
|
||
|
|
if err := tmp.Sync(); err != nil {
|
||
|
|
return 0, fmt.Errorf("blobstore: sync %q: %w", key, err)
|
||
|
|
}
|
||
|
|
if err := tmp.Close(); err != nil {
|
||
|
|
return 0, fmt.Errorf("blobstore: close %q: %w", key, err)
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return written, err
|
||
|
|
}
|
||
|
|
if err := os.Rename(tmpName, path); err != nil {
|
||
|
|
return written, fmt.Errorf("blobstore: commit %q: %w", key, err)
|
||
|
|
}
|
||
|
|
return written, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get opens the stored file.
|
||
|
|
func (l *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||
|
|
path, err := l.resolve(key)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
f, err := os.Open(path)
|
||
|
|
if errors.Is(err, fs.ErrNotExist) {
|
||
|
|
return nil, fmt.Errorf("%w: %s", ErrNotFound, key)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("blobstore: open %q: %w", key, err)
|
||
|
|
}
|
||
|
|
return f, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete removes the stored file, ignoring a missing one.
|
||
|
|
func (l *LocalFS) Delete(ctx context.Context, key string) error {
|
||
|
|
path, err := l.resolve(key)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||
|
|
return fmt.Errorf("blobstore: delete %q: %w", key, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// resolve maps a key to a path inside the root, rejecting any key that escapes
|
||
|
|
// it. Keys reach this code from upload filenames, so traversal has to fail
|
||
|
|
// closed rather than being normalised away.
|
||
|
|
func (l *LocalFS) resolve(key string) (string, error) {
|
||
|
|
if key == "" {
|
||
|
|
return "", errors.New("blobstore: empty key")
|
||
|
|
}
|
||
|
|
if filepath.IsAbs(key) {
|
||
|
|
return "", fmt.Errorf("blobstore: key %q must be relative", key)
|
||
|
|
}
|
||
|
|
|
||
|
|
clean := filepath.Clean("/" + key)
|
||
|
|
// filepath.Clean on an absolute path collapses any ".." segments.
|
||
|
|
rel := strings.TrimPrefix(clean, "/")
|
||
|
|
if rel == "" {
|
||
|
|
return "", fmt.Errorf("blobstore: key %q resolves to the root", key)
|
||
|
|
}
|
||
|
|
|
||
|
|
path := filepath.Join(l.root, rel)
|
||
|
|
// Defence in depth: confirm the result really is under the root.
|
||
|
|
if path != l.root && !strings.HasPrefix(path, l.root+string(os.PathSeparator)) {
|
||
|
|
return "", fmt.Errorf("blobstore: key %q escapes the store root", key)
|
||
|
|
}
|
||
|
|
return path, nil
|
||
|
|
}
|