Files
OwnCord/Server/storage/storage.go
T
J3vbandClaude Fable 5 ead64cdc20 chore(lint): errorlint + exhaustive + switch-exhaustiveness-check, and permissions.deny for generated files (#1462)
* chore(claude): deny hand-edits to generated files via permissions.deny

CLAUDE.md already says the sqlc, protocol and tauri-typegen outputs are
never hand-edited; this turns the sentence into a permission rule so the
Edit/Write tools refuse those paths outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(lint): switch-exhaustiveness-check on the client, default branch counts as exhaustive

A switch over a string union that misses a member is a silent drop, not a
type error. Every existing default-less switch already covers its union, so
this adds no exceptions; the four switches with a default keep it as the
deliberate catch-all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(lint): enable errorlint and exhaustive in golangci and fix the 110 hits

errorlint: 68 fmt.Errorf sites wrapped the inner error with %v, which hid it
from errors.Is/As upstream — now %w; 6 == / != comparisons on sentinel
errors become errors.Is (the recover() branch in the router asserts the
recovered value is an error first); 36 ClientError type assertions become
errors.As, so a wrapped ClientError still reaches the client with its code.
Three test assertions the autofixer inverted (!ok || code mismatch) are
restored by hand.

exhaustive (default-signifies-exhaustive): one hit, the hub simulation's
FaultStatus switch — FaultOK moves from an if-guard into the switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(claude): path-scoped rules for the three generated-code workflows

.claude/rules/{db-change,protocol-change,gendocs}.md load only when Claude
reads a matching source-of-truth file, so the db-change / protocol-change
skills and the gendocs regeneration step surface at the moment they apply
instead of relying on the CLAUDE.md table being remembered. .gitignore
whitelists .claude/rules/ next to skills/, workflows/ and settings.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:22:55 +02:00

218 lines
7.3 KiB
Go

// Package storage handles file upload validation and storage for the OwnCord server.
package storage
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
)
// ErrIO marks a Save failure caused by the server's own filesystem — disk
// full, permissions, a read-only mount — as opposed to the uploaded content
// being invalid. Handlers use it to return a 5xx instead of blaming the
// client with a 400, so infrastructure failures are distinguishable in any
// status-class dashboard.
var ErrIO = errors.New("storage io failure")
// blockedMagic maps format names to their magic byte signatures. Files whose
// leading bytes match any entry are rejected by ValidateFileType.
var blockedMagic = []struct {
name string
magic []byte
}{
{"PE executable", []byte("MZ")}, // Windows .exe / .dll
{"ELF binary", []byte("\x7fELF")}, // Linux binaries
{"Mach-O 64", []byte("\xcf\xfa\xed\xfe")}, // macOS 64-bit
{"Mach-O 32", []byte("\xce\xfa\xed\xfe")}, // macOS 32-bit
{"shell script", []byte("#!")}, // Shebang scripts (.sh, .py, etc.)
{"Java class", []byte("\xca\xfe\xba\xbe")}, // .class files
{"OLE2 document", []byte("\xd0\xcf\x11\xe0")}, // .doc/.xls with macros
{"WebAssembly", []byte("\x00asm")}, // .wasm modules
{"Windows shortcut", []byte{0x4c, 0x00, 0x00, 0x00}}, // .lnk files
}
// ValidateFileType checks the first few bytes of a file against known blocked
// magic bytes. It returns an error if the content matches a blocked file type,
// or nil if the content is allowed.
func ValidateFileType(header []byte) error {
for _, blocked := range blockedMagic {
if len(header) >= len(blocked.magic) && bytes.Equal(header[:len(blocked.magic)], blocked.magic) {
return fmt.Errorf("blocked file type: %s", blocked.name)
}
}
return nil
}
// Storage manages file uploads on disk.
type Storage struct {
dir string
maxSizeMB int
}
// New creates a Storage instance that stores files in dir.
// dir is created if it does not exist.
func New(dir string, maxSizeMB int) (*Storage, error) {
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("creating storage dir %s: %w", dir, err)
}
return &Storage{dir: dir, maxSizeMB: maxSizeMB}, nil
}
// sanitizeFilename validates that name is safe to use as a filename inside the
// storage directory. It must be a plain basename with no path separators, must
// not be empty, ".", or "..", and must not start with ".".
func sanitizeFilename(name string) error {
if name == "" {
return fmt.Errorf("invalid filename: empty string")
}
// filepath.Base strips any directory component; if it differs from the
// original input the caller smuggled a path separator.
base := filepath.Base(name)
if base != name {
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
}
// Reject "." and ".." explicitly.
if name == "." || name == ".." {
return fmt.Errorf("invalid filename %q: reserved name", name)
}
// Reject filenames starting with "." (hidden/config files).
if strings.HasPrefix(name, ".") {
return fmt.Errorf("invalid filename %q: must not start with '.'", name)
}
// Explicitly reject embedded separators on both Unix and Windows.
if strings.ContainsAny(name, "/\\") {
return fmt.Errorf("invalid filename %q: must not contain path separators", name)
}
return nil
}
// resolvedPath builds the absolute target path and verifies it stays within
// the storage directory.
func (s *Storage) resolvedPath(name string) (string, error) {
absDir, err := filepath.Abs(s.dir)
if err != nil {
return "", fmt.Errorf("resolving storage dir: %w", err)
}
target := filepath.Join(absDir, name)
// Ensure the joined path is still under absDir.
if !strings.HasPrefix(target, absDir+string(filepath.Separator)) &&
target != absDir {
return "", fmt.Errorf("resolved path %q escapes storage directory", target)
}
return target, nil
}
// Save writes the content from r to a file named by uuid within the storage dir.
// It reads the first 8 bytes to validate the file type (rejecting executables
// and scripts) before writing the full content to disk.
// The caller is responsible for generating a UUID filename.
func (s *Storage) Save(uuid string, r io.Reader) (int64, error) {
if err := sanitizeFilename(uuid); err != nil {
return 0, err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return 0, err
}
// Read the first 8 bytes to check magic bytes without consuming the stream.
var header [8]byte
n, err := io.ReadFull(r, header[:])
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
return 0, fmt.Errorf("reading file header: %w", err)
}
headerSlice := header[:n]
if err := ValidateFileType(headerSlice); err != nil {
return 0, err
}
f, err := os.Create(dst)
if err != nil {
return 0, fmt.Errorf("creating file %s: %w: %w", dst, ErrIO, err)
}
// Any failure after this point must remove the partial file: the orphan
// sweep is DB-row-driven, so a file without a DB row is never reclaimed.
// Close before remove — Windows cannot delete an open file.
success := false
defer func() {
_ = f.Close()
if !success {
if removeErr := os.Remove(dst); removeErr != nil {
slog.Error("storage: failed to remove partial file", "path", dst, "err", removeErr)
}
}
}()
// Reconstruct the full stream: header bytes we already read + remainder.
maxBytes := int64(s.maxSizeMB) * 1024 * 1024
full := io.MultiReader(bytes.NewReader(headerSlice), r)
limited := io.LimitReader(full, maxBytes)
written, err := io.Copy(f, limited)
if err != nil {
// Write-side failures surface as *fs.PathError (File.Write wraps
// them); read-side failures (client aborted mid-upload) do not, and
// those stay the client's fault.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return 0, fmt.Errorf("writing file: %w: %w", ErrIO, err)
}
return 0, fmt.Errorf("writing file: %w", err)
}
// Probe for one more byte to detect if the file exceeds the limit.
if written == maxBytes {
var probe [1]byte
if n, _ := full.Read(probe[:]); n > 0 {
return 0, fmt.Errorf("file exceeds maximum size of %d MB", s.maxSizeMB)
}
}
if syncErr := f.Sync(); syncErr != nil {
return 0, fmt.Errorf("syncing file %s: %w: %w", dst, ErrIO, syncErr)
}
success = true
return written, nil
}
// Delete removes the file named uuid from the storage dir.
func (s *Storage) Delete(uuid string) error {
if err := sanitizeFilename(uuid); err != nil {
return err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return err
}
return os.Remove(dst)
}
// File is what serving a stored blob requires of an opened file. Seeking is
// load-bearing, not incidental: both serve paths hand the file to
// http.ServeContent, which needs io.ReadSeeker for range requests — the
// exact capability that makes a remote backend (e.g. S3) the hard part of
// any future storage swap. Stat provides size and modtime the same way.
// *os.File satisfies it.
type File interface {
io.Reader
io.Seeker
io.Closer
Stat() (os.FileInfo, error)
}
// Open opens the file named uuid for reading.
func (s *Storage) Open(uuid string) (File, error) {
if err := sanitizeFilename(uuid); err != nil {
return nil, err
}
dst, err := s.resolvedPath(uuid)
if err != nil {
return nil, err
}
return os.Open(dst)
}