2026-05-31 02:00:24 +00:00
package local
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/distributor/internal/storage"
)
const backendName = "local"
type Backend struct {
root string
}
func New ( root string ) ( * Backend , error ) {
if root == "" {
return nil , storage . NewError ( storage . OpOpenBackend , backendName , "" , storage . ErrInvalidPath , nil )
}
absRoot , err := filepath . Abs ( root )
if err != nil {
return nil , storage . NewError ( storage . OpOpenBackend , backendName , root , storage . ErrInvalidPath , err )
}
return & Backend { root : filepath . Clean ( absRoot ) } , nil
}
func ( b * Backend ) ReadFile ( ctx context . Context , path string ) ( [ ] byte , error ) {
reader , err := b . OpenReader ( ctx , path )
if err != nil {
return nil , err
}
defer reader . Close ( )
data , err := io . ReadAll ( reader )
if err != nil {
return nil , storage . NewError ( storage . OpReadFile , backendName , path , storage . ErrUnknown , err )
}
return data , nil
}
func ( b * Backend ) OpenReader ( ctx context . Context , path string ) ( io . ReadCloser , error ) {
if err := ctx . Err ( ) ; err != nil {
return nil , err
}
nativePath , err := b . nativePath ( path , false )
if err != nil {
return nil , err
}
if err := b . rejectSymlinkAncestors ( nativePath , true ) ; err != nil {
return nil , err
}
info , err := os . Lstat ( nativePath )
if err != nil {
return nil , b . translateError ( storage . OpOpenReader , path , err )
}
if ! info . Mode ( ) . IsRegular ( ) {
return nil , storage . NewError ( storage . OpOpenReader , backendName , path , storage . ErrUnsupported , nil )
}
file , err := os . Open ( nativePath )
if err != nil {
return nil , b . translateError ( storage . OpOpenReader , path , err )
}
return file , nil
}
func ( b * Backend ) WriteFile ( ctx context . Context , path string , data [ ] byte , opts storage . WriteOptions ) ( storage . Entry , error ) {
opts . Size = int64 ( len ( data ) )
opts . SizeKnown = true
return b . WriteFrom ( ctx , path , bytes . NewReader ( data ) , opts )
}
func ( b * Backend ) WriteFrom ( ctx context . Context , path string , r io . Reader , opts storage . WriteOptions ) ( storage . Entry , error ) {
if err := ctx . Err ( ) ; err != nil {
return storage . Entry { } , err
}
nativePath , err := b . nativePath ( path , false )
if err != nil {
return storage . Entry { } , err
}
parent := filepath . Dir ( nativePath )
if err := b . rejectSymlinkAncestors ( parent , true ) ; err != nil {
return storage . Entry { } , err
}
if info , err := os . Lstat ( nativePath ) ; err == nil {
if ! opts . Overwrite {
return storage . Entry { } , storage . NewError ( storage . OpWriteFrom , backendName , path , storage . ErrAlreadyExist , nil )
}
if ! info . Mode ( ) . IsRegular ( ) {
return storage . Entry { } , storage . NewError ( storage . OpWriteFrom , backendName , path , storage . ErrConflict , nil )
}
} else if ! errors . Is ( err , fs . ErrNotExist ) {
return storage . Entry { } , b . translateError ( storage . OpWriteFrom , path , err )
}
if err := os . MkdirAll ( parent , 0 o755 ) ; err != nil {
return storage . Entry { } , b . translateError ( storage . OpWriteFrom , path , err )
}
temp , err := os . CreateTemp ( parent , ".distributor-write-*" )
if err != nil {
return storage . Entry { } , b . translateError ( storage . OpWriteFrom , path , err )
}
tempPath := temp . Name ( )
cleanup := true
defer func ( ) {
if cleanup {
_ = os . Remove ( tempPath )
}
} ( )
written , copyErr := io . Copy ( temp , r )
closeErr := temp . Close ( )
if copyErr != nil {
return storage . Entry { } , storage . NewError ( storage . OpWriteFrom , backendName , path , storage . ErrUnknown , copyErr )
}
if closeErr != nil {
return storage . Entry { } , storage . NewError ( storage . OpWriteFrom , backendName , path , storage . ErrUnknown , closeErr )
}
if opts . SizeKnown && written != opts . Size {
return storage . Entry { } , storage . NewError ( storage . OpWriteFrom , backendName , path , storage . ErrConflict , fmt . Errorf ( "stream size %d does not match expected size %d" , written , opts . Size ) )
}
if err := os . Rename ( tempPath , nativePath ) ; err != nil {
return storage . Entry { } , b . translateError ( storage . OpWriteFrom , path , err )
}
cleanup = false
return b . Stat ( ctx , path )
}
func ( b * Backend ) Stat ( ctx context . Context , path string ) ( storage . Entry , error ) {
if err := ctx . Err ( ) ; err != nil {
return storage . Entry { } , err
}
nativePath , err := b . nativePath ( path , true )
if err != nil {
return storage . Entry { } , err
}
info , err := os . Lstat ( nativePath )
if err != nil {
return storage . Entry { } , b . translateError ( storage . OpStat , path , err )
}
return entryFromInfo ( path , info ) , nil
}
func ( b * Backend ) Walk ( ctx context . Context , prefix string , opts storage . WalkOptions , fn storage . WalkFunc ) error {
if err := ctx . Err ( ) ; err != nil {
return err
}
nativePrefix , err := b . nativePath ( prefix , true )
if err != nil {
return err
}
info , err := os . Lstat ( nativePrefix )
if err != nil {
if errors . Is ( err , fs . ErrNotExist ) {
return nil
}
return b . translateError ( storage . OpWalk , prefix , err )
}
2026-06-02 18:50:50 +00:00
emitter := storage . NewWalkEmitter ( ctx , backendName , opts , fn )
2026-05-31 02:00:24 +00:00
if ! info . IsDir ( ) {
2026-06-02 18:50:50 +00:00
return storage . FinishWalk ( emitter . Emit ( entryFromInfo ( prefix , info ) ) )
2026-05-31 02:00:24 +00:00
}
walkErr := filepath . WalkDir ( nativePrefix , func ( nativePath string , dirEntry fs . DirEntry , err error ) error {
if err != nil {
return b . translateError ( storage . OpWalk , prefix , err )
}
if nativePath == nativePrefix {
return nil
}
relPath , err := filepath . Rel ( b . root , nativePath )
if err != nil {
return b . translateError ( storage . OpWalk , prefix , err )
}
logicalPath := filepath . ToSlash ( relPath )
if ! opts . Recursive && filepath . Dir ( nativePath ) != nativePrefix {
if dirEntry . IsDir ( ) {
return filepath . SkipDir
}
return nil
}
info , err := dirEntry . Info ( )
if err != nil {
return b . translateError ( storage . OpWalk , logicalPath , err )
}
2026-06-02 18:50:50 +00:00
return emitter . Emit ( entryFromInfo ( logicalPath , info ) )
2026-05-31 02:00:24 +00:00
} )
2026-06-02 18:50:50 +00:00
return storage . FinishWalk ( walkErr )
2026-05-31 02:00:24 +00:00
}
func ( b * Backend ) HasAny ( ctx context . Context , prefix string ) ( bool , error ) {
2026-06-02 18:50:50 +00:00
return storage . HasAny ( ctx , b , prefix )
2026-05-31 02:00:24 +00:00
}
func ( b * Backend ) DeleteManagedBundle ( ctx context . Context , bundlePath string , managedOutputPaths [ ] string , opts storage . DeleteOptions ) error {
2026-06-08 19:02:12 +00:00
return b . deleteManagedTargets ( ctx , storage . OpDeleteManagedBundle , func ( ) ( [ ] string , error ) {
return storage . ManagedBundleTargets ( bundlePath , managedOutputPaths )
} , opts )
}
func ( b * Backend ) DeleteManagedOutputs ( ctx context . Context , bundlePath string , managedOutputPaths [ ] string , opts storage . DeleteOptions ) error {
return b . deleteManagedTargets ( ctx , storage . OpDeleteManagedOutputs , func ( ) ( [ ] string , error ) {
return storage . ManagedOutputTargets ( bundlePath , managedOutputPaths )
} , opts )
}
func ( b * Backend ) deleteManagedTargets ( ctx context . Context , op string , targetsFunc func ( ) ( [ ] string , error ) , opts storage . DeleteOptions ) error {
2026-05-31 02:00:24 +00:00
if err := ctx . Err ( ) ; err != nil {
return err
}
2026-06-08 19:02:12 +00:00
targets , err := targetsFunc ( )
2026-05-31 02:00:24 +00:00
if err != nil {
return err
}
for _ , logicalPath := range targets {
nativePath , err := b . nativePath ( logicalPath , false )
if err != nil {
return err
}
if nativePath == b . root {
2026-06-08 19:02:12 +00:00
return storage . NewError ( op , backendName , logicalPath , storage . ErrInvalidPath , nil )
2026-05-31 02:00:24 +00:00
}
info , err := os . Lstat ( nativePath )
if err != nil {
if opts . IgnoreMissing && errors . Is ( err , fs . ErrNotExist ) {
continue
}
2026-06-08 19:02:12 +00:00
return b . translateError ( op , logicalPath , err )
2026-05-31 02:00:24 +00:00
}
if info . IsDir ( ) {
2026-06-08 19:02:12 +00:00
return storage . NewError ( op , backendName , logicalPath , storage . ErrUnsupported , nil )
2026-05-31 02:00:24 +00:00
}
if err := os . Remove ( nativePath ) ; err != nil {
2026-06-08 19:02:12 +00:00
return b . translateError ( op , logicalPath , err )
2026-05-31 02:00:24 +00:00
}
if opts . PruneEmptyDirs {
b . pruneEmptyParents ( filepath . Dir ( nativePath ) )
}
}
return nil
}
2026-05-31 17:29:20 +00:00
func ( b * Backend ) DeletePrefix ( ctx context . Context , prefix string , opts storage . DeleteOptions ) error {
if err := ctx . Err ( ) ; err != nil {
return err
}
nativePrefix , err := b . nativePath ( prefix , true )
if err != nil {
return err
}
if err := b . rejectSymlinkAncestors ( nativePrefix , false ) ; err != nil {
return err
}
if prefix == "" {
entries , err := os . ReadDir ( nativePrefix )
if err != nil {
if opts . IgnoreMissing && errors . Is ( err , fs . ErrNotExist ) {
return nil
}
return b . translateError ( storage . OpDeletePrefix , prefix , err )
}
for _ , entry := range entries {
if err := ctx . Err ( ) ; err != nil {
return err
}
child := filepath . Join ( nativePrefix , entry . Name ( ) )
if err := os . RemoveAll ( child ) ; err != nil {
return b . translateError ( storage . OpDeletePrefix , entry . Name ( ) , err )
}
}
return nil
}
if _ , err := os . Lstat ( nativePrefix ) ; err != nil {
if opts . IgnoreMissing && errors . Is ( err , fs . ErrNotExist ) {
return nil
}
return b . translateError ( storage . OpDeletePrefix , prefix , err )
}
if err := os . RemoveAll ( nativePrefix ) ; err != nil {
return b . translateError ( storage . OpDeletePrefix , prefix , err )
}
if opts . PruneEmptyDirs {
b . pruneEmptyParents ( filepath . Dir ( nativePrefix ) )
}
return nil
}
2026-05-31 02:00:24 +00:00
func ( b * Backend ) nativePath ( logicalPath string , allowEmpty bool ) ( string , error ) {
if logicalPath == "" {
if ! allowEmpty {
return "" , storage . NewError ( storage . OpValidatePath , backendName , logicalPath , storage . ErrInvalidPath , nil )
}
return b . root , nil
}
if err := storage . ValidatePath ( logicalPath ) ; err != nil {
return "" , err
}
nativePath := filepath . Clean ( filepath . Join ( b . root , filepath . FromSlash ( logicalPath ) ) )
rel , err := filepath . Rel ( b . root , nativePath )
if err != nil {
return "" , storage . NewError ( storage . OpValidatePath , backendName , logicalPath , storage . ErrInvalidPath , err )
}
if rel == ".." || strings . HasPrefix ( rel , ".." + string ( filepath . Separator ) ) || filepath . IsAbs ( rel ) {
return "" , storage . NewError ( storage . OpValidatePath , backendName , logicalPath , storage . ErrInvalidPath , nil )
}
return nativePath , nil
}
func ( b * Backend ) rejectSymlinkAncestors ( nativePath string , includeFinal bool ) error {
rel , err := filepath . Rel ( b . root , nativePath )
if err != nil {
return storage . NewError ( storage . OpValidatePath , backendName , nativePath , storage . ErrInvalidPath , err )
}
if rel == "." {
return nil
}
segments := strings . Split ( rel , string ( filepath . Separator ) )
limit := len ( segments )
if ! includeFinal {
limit --
}
current := b . root
for i := 0 ; i < limit ; i ++ {
current = filepath . Join ( current , segments [ i ] )
info , err := os . Lstat ( current )
if err != nil {
if errors . Is ( err , fs . ErrNotExist ) {
return nil
}
return b . translateError ( storage . OpStat , filepath . ToSlash ( filepath . Join ( segments [ : i + 1 ] ... ) ) , err )
}
if info . Mode ( ) & os . ModeSymlink != 0 {
return storage . NewError ( storage . OpStat , backendName , filepath . ToSlash ( filepath . Join ( segments [ : i + 1 ] ... ) ) , storage . ErrUnsupported , nil )
}
}
return nil
}
func ( b * Backend ) pruneEmptyParents ( start string ) {
for current := start ; current != b . root && strings . HasPrefix ( current , b . root ) ; current = filepath . Dir ( current ) {
err := os . Remove ( current )
if err != nil {
return
}
}
}
func ( b * Backend ) translateError ( op , path string , err error ) error {
kind := storage . ErrUnknown
switch {
case errors . Is ( err , fs . ErrNotExist ) :
kind = storage . ErrNotFound
case errors . Is ( err , fs . ErrExist ) :
kind = storage . ErrAlreadyExist
case errors . Is ( err , fs . ErrPermission ) :
kind = storage . ErrPermission
}
return storage . NewError ( op , backendName , path , kind , err )
}
func entryFromInfo ( path string , info fs . FileInfo ) storage . Entry {
entryType := storage . EntryTypeOther
switch {
case info . Mode ( ) & os . ModeSymlink != 0 :
entryType = storage . EntryTypeSymlink
case info . Mode ( ) . IsRegular ( ) :
entryType = storage . EntryTypeFile
case info . IsDir ( ) :
entryType = storage . EntryTypeDirectory
}
return storage . Entry {
Path : path ,
Type : entryType ,
Size : info . Size ( ) ,
}
}