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 }