343 lines
9.0 KiB
Go
343 lines
9.0 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// DirStore is a configurable filesystem-backed Store.
|
|
// It supports single or multiple directories (union), read-only mode,
|
|
// a synthetic index entry, subdirectory operations, custom path resolution,
|
|
// and mounting child DirStores at names (sub-stores).
|
|
type DirStore struct {
|
|
dirs []string
|
|
perm os.FileMode
|
|
readOnly bool
|
|
indexFn func(Store) ([]byte, error)
|
|
resolveFn func([]string, string) (string, error)
|
|
listFn func([]string) ([]os.DirEntry, error)
|
|
createFn func([]string, string, os.FileMode) error
|
|
deleteFn func([]string, string) error
|
|
renameFn func([]string, string, string) error
|
|
openFn func([]string, string) (StoreEntry, error)
|
|
children map[string]*DirStore
|
|
}
|
|
|
|
// DirOption configures a DirStore.
|
|
type DirOption func(*DirStore)
|
|
|
|
// WithReadOnly makes the store reject writes, creates, deletes, and renames.
|
|
func WithReadOnly() DirOption { return func(d *DirStore) { d.readOnly = true } }
|
|
|
|
// WithIndex adds a synthetic read-only "idx" entry whose content is
|
|
// generated by calling fn with the store itself.
|
|
func WithIndex(fn func(Store) ([]byte, error)) DirOption {
|
|
return func(d *DirStore) { d.indexFn = fn }
|
|
}
|
|
|
|
// WithResolver overrides how virtual names map to filesystem paths.
|
|
func WithResolver(fn func(dirs []string, name string) (string, error)) DirOption {
|
|
return func(d *DirStore) { d.resolveFn = fn }
|
|
}
|
|
|
|
// WithLister overrides how the store lists its entries.
|
|
func WithLister(fn func(dirs []string) ([]os.DirEntry, error)) DirOption {
|
|
return func(d *DirStore) { d.listFn = fn }
|
|
}
|
|
|
|
// WithCreator overrides file creation logic.
|
|
func WithCreator(fn func(dirs []string, name string, perm os.FileMode) error) DirOption {
|
|
return func(d *DirStore) { d.createFn = fn }
|
|
}
|
|
|
|
// WithDeleter overrides file deletion logic.
|
|
func WithDeleter(fn func(dirs []string, name string) error) DirOption {
|
|
return func(d *DirStore) { d.deleteFn = fn }
|
|
}
|
|
|
|
// WithRenamer overrides rename logic.
|
|
func WithRenamer(fn func(dirs []string, old, new string) error) DirOption {
|
|
return func(d *DirStore) { d.renameFn = fn }
|
|
}
|
|
|
|
// WithOpener overrides how entries are opened (bypasses filesystem read).
|
|
func WithOpener(fn func(dirs []string, name string) (StoreEntry, error)) DirOption {
|
|
return func(d *DirStore) { d.openFn = fn }
|
|
}
|
|
|
|
// NewDirStore creates a DirStore backed by one or more directories.
|
|
// The first directory is the write target for unions.
|
|
func NewDirStore(dirs []string, perm os.FileMode, opts ...DirOption) *DirStore {
|
|
d := &DirStore{dirs: dirs, perm: perm}
|
|
for _, o := range opts {
|
|
o(d)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// Mount attaches a child DirStore at the given name.
|
|
func (d *DirStore) Mount(name string, child *DirStore) {
|
|
if d.children == nil {
|
|
d.children = make(map[string]*DirStore)
|
|
}
|
|
d.children[name] = child
|
|
}
|
|
|
|
// Unmount removes a mounted child.
|
|
func (d *DirStore) Unmount(name string) {
|
|
delete(d.children, name)
|
|
}
|
|
|
|
// Child returns the child mounted at name, or nil.
|
|
func (d *DirStore) Child(name string) *DirStore {
|
|
if d.children == nil {
|
|
return nil
|
|
}
|
|
return d.children[name]
|
|
}
|
|
|
|
// split checks if name routes to a child store. Returns (child, rest, true)
|
|
// if a child handles this path, or (nil, "", false) otherwise.
|
|
func (d *DirStore) split(name string) (*DirStore, string, bool) {
|
|
if d.children == nil {
|
|
return nil, "", false
|
|
}
|
|
// Check for exact child match (directory itself).
|
|
if child, ok := d.children[name]; ok {
|
|
return child, "", true
|
|
}
|
|
// Check for child prefix: "childname/rest"
|
|
if i := indexOf(name, '/'); i >= 0 {
|
|
prefix := name[:i]
|
|
if child, ok := d.children[prefix]; ok {
|
|
return child, name[i+1:], true
|
|
}
|
|
}
|
|
return nil, "", false
|
|
}
|
|
|
|
func indexOf(s string, b byte) int {
|
|
for i := range len(s) {
|
|
if s[i] == b {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (d *DirStore) resolve(name string) (string, error) {
|
|
if d.resolveFn != nil {
|
|
return d.resolveFn(d.dirs, name)
|
|
}
|
|
for _, dir := range d.dirs {
|
|
p := filepath.Join(dir, name)
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("%s: not found", name)
|
|
}
|
|
|
|
func (d *DirStore) writePath(name string) string {
|
|
return filepath.Join(d.dirs[0], name)
|
|
}
|
|
|
|
func (d *DirStore) Stat(name string) (os.FileInfo, error) {
|
|
// Check children first.
|
|
if child, rest, ok := d.split(name); ok {
|
|
if rest == "" {
|
|
// The child directory itself.
|
|
return &SyntheticFileInfo{Name_: name, Mode_: 0555, IsDir_: true}, nil
|
|
}
|
|
return child.Stat(rest)
|
|
}
|
|
if name == "idx" && d.indexFn != nil {
|
|
return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil
|
|
}
|
|
p, err := d.resolve(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.resolveFn != nil {
|
|
return &SyntheticFileInfo{Name_: name, Mode_: info.Mode(), Size_: info.Size()}, nil
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
func (d *DirStore) List() ([]os.DirEntry, error) {
|
|
if d.listFn != nil {
|
|
entries, err := d.listFn(d.dirs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if d.indexFn != nil {
|
|
result := make([]os.DirEntry, 0, len(entries)+1)
|
|
result = append(result, FileEntry("idx", 0444))
|
|
result = append(result, entries...)
|
|
entries = result
|
|
}
|
|
for name := range d.children {
|
|
entries = append(entries, DirEntry(name, 0555))
|
|
}
|
|
return entries, nil
|
|
}
|
|
seen := make(map[string]bool)
|
|
var result []os.DirEntry
|
|
if d.indexFn != nil {
|
|
result = append(result, FileEntry("idx", 0444))
|
|
seen["idx"] = true
|
|
}
|
|
for _, dir := range d.dirs {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, e := range entries {
|
|
if !seen[e.Name()] {
|
|
seen[e.Name()] = true
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
}
|
|
for name := range d.children {
|
|
if !seen[name] {
|
|
result = append(result, DirEntry(name, 0555))
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (d *DirStore) Open(name string) (StoreEntry, error) {
|
|
// Delegate to child stores.
|
|
if child, rest, ok := d.split(name); ok {
|
|
if rest == "" {
|
|
return nil, fmt.Errorf("%s: is a directory", name)
|
|
}
|
|
return child.Open(rest)
|
|
}
|
|
notBlocking := func(context.Context, string) ([]byte, string, error) {
|
|
return nil, "", fmt.Errorf("blocking read not supported")
|
|
}
|
|
if name == "idx" && d.indexFn != nil {
|
|
return &EntryConfig{
|
|
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: 0444}, nil },
|
|
ReadFn: func() ([]byte, error) { return d.indexFn(d) },
|
|
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
}
|
|
if d.openFn != nil {
|
|
return d.openFn(d.dirs, name)
|
|
}
|
|
p, err := d.resolve(name)
|
|
if err != nil {
|
|
if d.readOnly {
|
|
return nil, err
|
|
}
|
|
// Not found anywhere; write path is in first dir.
|
|
p = d.writePath(name)
|
|
}
|
|
wp := d.writePath(name)
|
|
writeFn := func(data []byte) error {
|
|
if d.readOnly {
|
|
return fmt.Errorf("%s: read-only", name)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(wp), 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(wp, data, d.perm)
|
|
}
|
|
return &EntryConfig{
|
|
StatFn: func() (os.FileInfo, error) { return os.Stat(p) },
|
|
ReadFn: func() ([]byte, error) { return os.ReadFile(p) },
|
|
WriteFn: writeFn,
|
|
BlockingReadFn: notBlocking,
|
|
}, nil
|
|
}
|
|
|
|
func (d *DirStore) Create(name string) error {
|
|
if child, rest, ok := d.split(name); ok && rest != "" {
|
|
return child.Create(rest)
|
|
}
|
|
if d.readOnly {
|
|
return fmt.Errorf("read-only store")
|
|
}
|
|
if d.createFn != nil {
|
|
return d.createFn(d.dirs, name, d.perm)
|
|
}
|
|
p := d.writePath(name)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(p, nil, d.perm)
|
|
}
|
|
|
|
func (d *DirStore) Delete(name string) error {
|
|
if child, rest, ok := d.split(name); ok && rest != "" {
|
|
return child.Delete(rest)
|
|
}
|
|
if d.readOnly {
|
|
return fmt.Errorf("read-only store")
|
|
}
|
|
if d.deleteFn != nil {
|
|
return d.deleteFn(d.dirs, name)
|
|
}
|
|
p, err := d.resolve(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(p)
|
|
}
|
|
|
|
func (d *DirStore) Rename(old, new string) error {
|
|
if child, rest, ok := d.split(old); ok && rest != "" {
|
|
return child.Rename(rest, new)
|
|
}
|
|
if d.readOnly {
|
|
return fmt.Errorf("read-only store")
|
|
}
|
|
if d.renameFn != nil {
|
|
return d.renameFn(d.dirs, old, new)
|
|
}
|
|
p, err := d.resolve(old)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(p, filepath.Join(filepath.Dir(p), new))
|
|
}
|
|
|
|
// ListDir lists entries in a subdirectory.
|
|
func (d *DirStore) ListDir(rel string) ([]os.DirEntry, error) {
|
|
// Check if rel routes to a child.
|
|
if child, rest, ok := d.split(rel); ok {
|
|
if rest == "" {
|
|
return child.List()
|
|
}
|
|
return child.ListDir(rest)
|
|
}
|
|
if len(d.dirs) == 0 {
|
|
return nil, fmt.Errorf("%s: not found", rel)
|
|
}
|
|
return os.ReadDir(filepath.Join(d.dirs[0], rel))
|
|
}
|
|
|
|
// MkdirAll creates a directory relative to the first backing dir.
|
|
func (d *DirStore) MkdirAll(rel string) error {
|
|
if child, rest, ok := d.split(rel); ok && rest != "" {
|
|
return child.MkdirAll(rest)
|
|
}
|
|
if d.readOnly {
|
|
return fmt.Errorf("read-only store")
|
|
}
|
|
if len(d.dirs) == 0 {
|
|
return fmt.Errorf("no backing directory")
|
|
}
|
|
return os.MkdirAll(filepath.Join(d.dirs[0], rel), 0755)
|
|
}
|