package storage import ( "context" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "path/filepath" "strings" "time" ) // LocalFS stores blobs on the local filesystem. // Every write is atomic: temp file → fsync → rename. type LocalFS struct { root string // absolute, canonical path tmpRoot string // where temp files land during upload } func NewLocalFS(root, tmpRoot string) (*LocalFS, error) { root, err := filepath.Abs(root) if err != nil { return nil, fmt.Errorf("abs root: %w", err) } tmpRoot, err = filepath.Abs(tmpRoot) if err != nil { return nil, fmt.Errorf("abs tmpRoot: %w", err) } for _, dir := range []string{root, tmpRoot} { if err := os.MkdirAll(dir, 0750); err != nil { return nil, fmt.Errorf("mkdir %s: %w", dir, err) } } return &LocalFS{root: root, tmpRoot: tmpRoot}, nil } // safePath returns the absolute path for key and ensures it stays under root. func (fs *LocalFS) safePath(key string) (string, error) { // Reject keys that try to escape with .. or absolute paths. if filepath.IsAbs(key) || strings.Contains(key, "..") { return "", fmt.Errorf("invalid storage key: %q", key) } full := filepath.Join(fs.root, filepath.FromSlash(key)) // Canonical check: the resolved path must still be under root. canonical, err := filepath.Abs(full) if err != nil { return "", fmt.Errorf("abs path: %w", err) } if !strings.HasPrefix(canonical+string(filepath.Separator), fs.root+string(filepath.Separator)) { return "", fmt.Errorf("path traversal detected: %q", key) } return canonical, nil } func (fs *LocalFS) Put(ctx context.Context, key string, r io.Reader) (string, int64, error) { dest, err := fs.safePath(key) if err != nil { return "", 0, err } if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil { return "", 0, fmt.Errorf("mkdir parent: %w", err) } // Write to temp file in tmpRoot (same filesystem for atomic rename). tmp, err := os.CreateTemp(fs.tmpRoot, "upload-*") if err != nil { return "", 0, fmt.Errorf("create temp: %w", err) } tmpName := tmp.Name() committed := false defer func() { if !committed { tmp.Close() os.Remove(tmpName) } }() h := sha256.New() multi := io.MultiWriter(tmp, h) size, err := io.Copy(multi, r) if err != nil { return "", 0, fmt.Errorf("write temp: %w", err) } // fsync: flush to physical storage before rename. if err := tmp.Sync(); err != nil { return "", 0, fmt.Errorf("fsync: %w", err) } if err := tmp.Close(); err != nil { return "", 0, fmt.Errorf("close temp: %w", err) } // Atomic rename: either fully committed or not. if err := os.Rename(tmpName, dest); err != nil { return "", 0, fmt.Errorf("rename to final: %w", err) } committed = true checksum := hex.EncodeToString(h.Sum(nil)) return checksum, size, nil } func (fs *LocalFS) Get(ctx context.Context, key string) (io.ReadCloser, error) { path, err := fs.safePath(key) if err != nil { return nil, err } f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("blob not found: %q", key) } return nil, fmt.Errorf("open blob: %w", err) } return f, nil } func (fs *LocalFS) Delete(ctx context.Context, key string) error { path, err := fs.safePath(key) if err != nil { return err } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("delete blob: %w", err) } return nil } func (fs *LocalFS) Stat(ctx context.Context, key string) (FileInfo, error) { path, err := fs.safePath(key) if err != nil { return FileInfo{}, err } fi, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return FileInfo{}, fmt.Errorf("blob not found: %q", key) } return FileInfo{}, fmt.Errorf("stat blob: %w", err) } // Recompute checksum on stat (used for integrity verification). checksum, err := fileChecksum(path) if err != nil { return FileInfo{}, err } return FileInfo{ Size: fi.Size(), Checksum: checksum, ModTime: fi.ModTime().UTC().Truncate(time.Second), }, nil } // CreateTemp legt eine Datei im Staging-Verzeichnis an. Gedacht für Uploads, // deren Größe erst beim Schreiben feststeht: Der Inhalt landet auf derselben // Platte wie die späteren Blobs, nicht im Arbeitsspeicher. Der Aufrufer ist für // Schließen und Entfernen zuständig. func (fs *LocalFS) CreateTemp(pattern string) (*os.File, error) { f, err := os.CreateTemp(fs.tmpRoot, pattern) if err != nil { return nil, fmt.Errorf("create temp file: %w", err) } return f, nil } // LocalPath gibt den Pfad im Dateisystem heraus (siehe LocalPather). // // Die Prüfung auf Ausbruch aus dem Wurzelverzeichnis ist dieselbe wie bei jedem // anderen Zugriff – ein Schlüssel mit ".." kommt hier so wenig durch wie bei // Get oder Delete. Der Rückgabewert ist zum **Lesen** gedacht; wer über diesen // Pfad schreibt, umgeht das atomare Schreiben und hat die Garantien verspielt. func (fs *LocalFS) LocalPath(key string) (string, error) { path, err := fs.safePath(key) if err != nil { return "", err } if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("stat %s: %w", key, err) } return path, nil } // Walk läuft den Baum unterhalb von prefix ab und meldet jede Datei. // Verzeichnisse werden übersprungen; die Prüfsumme bleibt leer, weil das // Nachrechnen über den gesamten Speicher unverhältnismäßig teuer wäre – wer // sie braucht, holt sie über Stat. func (fs *LocalFS) Walk(ctx context.Context, prefix string, fn func(key string, info FileInfo) error) error { prefix = strings.Trim(prefix, "/") base := fs.root if prefix != "" { var err error base, err = fs.safePath(prefix) if err != nil { return err } } if _, err := os.Stat(base); os.IsNotExist(err) { return nil } return filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if ctx.Err() != nil { return ctx.Err() } if d.IsDir() { return nil } info, err := d.Info() if err != nil { // Datei ist zwischen Auflisten und Abfragen verschwunden – das ist // im laufenden Betrieb normal und kein Grund abzubrechen. if os.IsNotExist(err) { return nil } return err } rel, err := filepath.Rel(fs.root, path) if err != nil { return err } return fn(filepath.ToSlash(rel), FileInfo{Size: info.Size(), ModTime: info.ModTime()}) }) } // DeletePrefix entfernt ein ganzes Unterverzeichnis des Speichers. // // Absichtlich streng: ein leerer Präfix würde den gesamten Speicher löschen, // deshalb wird er abgelehnt. Der aufgelöste Pfad muss ausserdem echt unterhalb // der Wurzel liegen – safePath prüft das bereits, der zusätzliche Vergleich // schützt davor, versehentlich die Wurzel selbst zu treffen. func (fs *LocalFS) DeletePrefix(ctx context.Context, prefix string) error { prefix = strings.Trim(prefix, "/") if prefix == "" { return fmt.Errorf("refusing to delete empty prefix") } dir, err := fs.safePath(prefix) if err != nil { return err } if dir == fs.root { return fmt.Errorf("refusing to delete storage root") } if err := os.RemoveAll(dir); err != nil { return fmt.Errorf("delete prefix %q: %w", prefix, err) } return nil } func (fs *LocalFS) Move(ctx context.Context, oldKey, newKey string) error { oldPath, err := fs.safePath(oldKey) if err != nil { return err } newPath, err := fs.safePath(newKey) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(newPath), 0750); err != nil { return fmt.Errorf("mkdir parent: %w", err) } return os.Rename(oldPath, newPath) } func fileChecksum(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", fmt.Errorf("open for checksum: %w", err) } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", fmt.Errorf("hash file: %w", err) } return hex.EncodeToString(h.Sum(nil)), nil }