rara/packages/go/artifact/filesystem.go
toki fdc86c7ff4
Some checks are pending
ci / validate (push) Waiting to run
initial commit
2026-07-18 18:41:17 +09:00

135 lines
3.5 KiB
Go

package artifact
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Filesystem struct {
root string
}
func NewFilesystem(root string) (*Filesystem, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve artifact root: %w", err)
}
if err := os.MkdirAll(absolute, 0o750); err != nil {
return nil, fmt.Errorf("create artifact root: %w", err)
}
return &Filesystem{root: absolute}, nil
}
func (store *Filesystem) Put(
ctx context.Context,
key string,
source io.Reader,
mediaType string,
) (Descriptor, error) {
target, err := store.resolve(key)
if err != nil {
return Descriptor{}, err
}
if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil {
return Descriptor{}, fmt.Errorf("create artifact directory: %w", err)
}
temp, err := os.CreateTemp(filepath.Dir(target), ".rara-artifact-*")
if err != nil {
return Descriptor{}, fmt.Errorf("create artifact staging file: %w", err)
}
tempName := temp.Name()
defer func() { _ = os.Remove(tempName) }()
hash := sha256.New()
written, copyErr := copyWithContext(ctx, io.MultiWriter(temp, hash), source)
closeErr := temp.Close()
if copyErr != nil {
return Descriptor{}, copyErr
}
if closeErr != nil {
return Descriptor{}, fmt.Errorf("close artifact staging file: %w", closeErr)
}
if err := os.Rename(tempName, target); err != nil {
return Descriptor{}, fmt.Errorf("publish artifact: %w", err)
}
return Descriptor{
URI: "file://" + target,
ContentHash: "sha256:" + hex.EncodeToString(hash.Sum(nil)),
SizeBytes: written,
MediaType: mediaType,
}, nil
}
func (store *Filesystem) Open(_ context.Context, key string) (io.ReadCloser, error) {
target, err := store.resolve(key)
if err != nil {
return nil, err
}
file, err := os.Open(target)
if err != nil {
return nil, fmt.Errorf("open artifact: %w", err)
}
return file, nil
}
func (store *Filesystem) Delete(_ context.Context, key string) error {
target, err := store.resolve(key)
if err != nil {
return err
}
if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("delete artifact: %w", err)
}
return nil
}
func (store *Filesystem) resolve(key string) (string, error) {
if key == "" {
return "", errors.New("artifact key is required")
}
clean := filepath.Clean(filepath.FromSlash(key))
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", errors.New("artifact key escapes the configured root")
}
target := filepath.Join(store.root, clean)
relative, err := filepath.Rel(store.root, target)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("artifact key escapes the configured root")
}
return target, nil
}
func copyWithContext(ctx context.Context, destination io.Writer, source io.Reader) (int64, error) {
buffer := make([]byte, 32*1024)
var total int64
for {
if err := ctx.Err(); err != nil {
return total, err
}
read, readErr := source.Read(buffer)
if read > 0 {
written, writeErr := destination.Write(buffer[:read])
total += int64(written)
if writeErr != nil {
return total, fmt.Errorf("write artifact: %w", writeErr)
}
if written != read {
return total, io.ErrShortWrite
}
}
if errors.Is(readErr, io.EOF) {
return total, nil
}
if readErr != nil {
return total, fmt.Errorf("read artifact: %w", readErr)
}
}
}