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, 0o755); 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) } emitter := storage.NewWalkEmitter(ctx, backendName, opts, fn) if !info.IsDir() { return storage.FinishWalk(emitter.Emit(entryFromInfo(prefix, info))) } 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) } return emitter.Emit(entryFromInfo(logicalPath, info)) }) return storage.FinishWalk(walkErr) } func (b *Backend) HasAny(ctx context.Context, prefix string) (bool, error) { return storage.HasAny(ctx, b, prefix) } func (b *Backend) DeleteManagedBundle(ctx context.Context, bundlePath string, managedOutputPaths []string, opts storage.DeleteOptions) error { if err := ctx.Err(); err != nil { return err } targets, err := storage.ManagedBundleTargets(bundlePath, managedOutputPaths) if err != nil { return err } for _, logicalPath := range targets { nativePath, err := b.nativePath(logicalPath, false) if err != nil { return err } if nativePath == b.root { return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrInvalidPath, nil) } info, err := os.Lstat(nativePath) if err != nil { if opts.IgnoreMissing && errors.Is(err, fs.ErrNotExist) { continue } return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err) } if info.IsDir() { return storage.NewError(storage.OpDeleteManagedBundle, backendName, logicalPath, storage.ErrUnsupported, nil) } if err := os.Remove(nativePath); err != nil { return b.translateError(storage.OpDeleteManagedBundle, logicalPath, err) } if opts.PruneEmptyDirs { b.pruneEmptyParents(filepath.Dir(nativePath)) } } return nil } 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 } 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(), } }