// Package local provides a local filesystem storage adapter for Tutus. // // This adapter is suitable for: // - Development and testing // - Single-node deployments // - NAS or SAN-backed storage // - Air-gapped government networks package local import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "sync" "time" "git.marketally.com/tutus-one/tutus-chain/pkg/storage" ) const providerName = "local" // Adapter implements the storage.Provider interface for local filesystem. type Adapter struct { basePath string maxSize int64 mu sync.RWMutex } // Config holds local adapter configuration. type Config struct { // Path is the base directory for storage Path string // MaxSize is the maximum storage size in bytes (0 = unlimited) MaxSize int64 } // New creates a new local filesystem storage adapter. func New(cfg Config) (*Adapter, error) { path := cfg.Path if path == "" { path = "./storage" } // Ensure base directory exists if err := os.MkdirAll(path, 0755); err != nil { return nil, fmt.Errorf("local: create storage directory: %w", err) } // Create subdirectories for _, sub := range []string{"objects", "meta", "blocks", "states"} { if err := os.MkdirAll(filepath.Join(path, sub), 0755); err != nil { return nil, fmt.Errorf("local: create %s directory: %w", sub, err) } } return &Adapter{ basePath: path, maxSize: cfg.MaxSize, }, nil } // Name returns the provider identifier. func (a *Adapter) Name() string { return providerName } // Put stores data on the local filesystem. func (a *Adapter) Put(ctx context.Context, data io.Reader, opts storage.PutOptions) (storage.ObjectID, error) { a.mu.Lock() defer a.mu.Unlock() // Read all data content, err := io.ReadAll(data) if err != nil { return storage.ObjectID{}, fmt.Errorf("local: read data: %w", err) } // Generate content-addressed ID hash := sha256.Sum256(content) id := hex.EncodeToString(hash[:]) // Check size limits if a.maxSize > 0 { currentSize, _ := a.calculateSize() if currentSize+int64(len(content)) > a.maxSize { return storage.ObjectID{}, storage.ErrQuotaExceeded } } // Write object data objPath := a.objectPath(id) if err := os.MkdirAll(filepath.Dir(objPath), 0755); err != nil { return storage.ObjectID{}, fmt.Errorf("local: create object dir: %w", err) } if err := os.WriteFile(objPath, content, 0644); err != nil { return storage.ObjectID{}, fmt.Errorf("local: write object: %w", err) } // Write metadata meta := objectMeta{ Size: int64(len(content)), ContentType: opts.ContentType, Created: time.Now(), Modified: time.Now(), Checksum: id, Attributes: opts.Attributes, } metaPath := a.metaPath(id) if err := os.MkdirAll(filepath.Dir(metaPath), 0755); err != nil { os.Remove(objPath) // Clean up on error return storage.ObjectID{}, fmt.Errorf("local: create meta dir: %w", err) } metaData, _ := json.Marshal(meta) if err := os.WriteFile(metaPath, metaData, 0644); err != nil { os.Remove(objPath) // Clean up on error return storage.ObjectID{}, fmt.Errorf("local: write metadata: %w", err) } return storage.ObjectID{ Provider: providerName, Container: "default", ID: id, }, nil } // Get retrieves data from the local filesystem. func (a *Adapter) Get(ctx context.Context, id storage.ObjectID) (io.ReadCloser, error) { a.mu.RLock() defer a.mu.RUnlock() objPath := a.objectPath(id.ID) file, err := os.Open(objPath) if err != nil { if os.IsNotExist(err) { return nil, storage.ErrNotFound } return nil, fmt.Errorf("local: open object: %w", err) } return file, nil } // Delete removes an object from the local filesystem. func (a *Adapter) Delete(ctx context.Context, id storage.ObjectID) error { a.mu.Lock() defer a.mu.Unlock() objPath := a.objectPath(id.ID) metaPath := a.metaPath(id.ID) if _, err := os.Stat(objPath); os.IsNotExist(err) { return storage.ErrNotFound } if err := os.Remove(objPath); err != nil { return fmt.Errorf("local: remove object: %w", err) } os.Remove(metaPath) // Best effort metadata removal return nil } // Exists checks if an object exists on the local filesystem. func (a *Adapter) Exists(ctx context.Context, id storage.ObjectID) (bool, error) { a.mu.RLock() defer a.mu.RUnlock() objPath := a.objectPath(id.ID) _, err := os.Stat(objPath) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("local: stat object: %w", err) } return true, nil } // List returns objects matching the given prefix. func (a *Adapter) List(ctx context.Context, prefix string, opts storage.ListOptions) ([]storage.ObjectInfo, error) { a.mu.RLock() defer a.mu.RUnlock() objectsDir := filepath.Join(a.basePath, "objects") var results []storage.ObjectInfo err := filepath.Walk(objectsDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil } // Get relative path as ID rel, _ := filepath.Rel(objectsDir, path) id := strings.ReplaceAll(rel, string(filepath.Separator), "") if prefix != "" && !strings.HasPrefix(id, prefix) { return nil } objInfo := storage.ObjectInfo{ ID: storage.ObjectID{ Provider: providerName, Container: "default", ID: id, }, Size: info.Size(), Modified: info.ModTime(), } if opts.IncludeMetadata { if meta, err := a.loadMeta(id); err == nil { objInfo.ContentType = meta.ContentType objInfo.Created = meta.Created objInfo.Checksum = meta.Checksum objInfo.Attributes = meta.Attributes } } results = append(results, objInfo) if opts.MaxResults > 0 && len(results) >= opts.MaxResults { return filepath.SkipAll } return nil }) if err != nil { return nil, fmt.Errorf("local: list objects: %w", err) } return results, nil } // Head retrieves object metadata. func (a *Adapter) Head(ctx context.Context, id storage.ObjectID) (storage.ObjectInfo, error) { a.mu.RLock() defer a.mu.RUnlock() objPath := a.objectPath(id.ID) info, err := os.Stat(objPath) if err != nil { if os.IsNotExist(err) { return storage.ObjectInfo{}, storage.ErrNotFound } return storage.ObjectInfo{}, fmt.Errorf("local: stat object: %w", err) } result := storage.ObjectInfo{ ID: id, Size: info.Size(), Modified: info.ModTime(), } if meta, err := a.loadMeta(id.ID); err == nil { result.ContentType = meta.ContentType result.Created = meta.Created result.Checksum = meta.Checksum result.Attributes = meta.Attributes } return result, nil } // Close is a no-op for local storage. func (a *Adapter) Close() error { return nil } // ============================================================================= // BlockStorage Interface Implementation // ============================================================================= // PutBlock stores a block with its index. func (a *Adapter) PutBlock(ctx context.Context, index uint32, data []byte) (storage.ObjectID, error) { a.mu.Lock() defer a.mu.Unlock() // Check size limits if a.maxSize > 0 { currentSize, _ := a.calculateSize() if currentSize+int64(len(data)) > a.maxSize { return storage.ObjectID{}, storage.ErrQuotaExceeded } } // Write block file blockPath := a.blockPath(index) if err := os.MkdirAll(filepath.Dir(blockPath), 0755); err != nil { return storage.ObjectID{}, fmt.Errorf("local: create block dir: %w", err) } if err := os.WriteFile(blockPath, data, 0644); err != nil { return storage.ObjectID{}, fmt.Errorf("local: write block: %w", err) } // Generate content hash for ID hash := sha256.Sum256(data) id := hex.EncodeToString(hash[:]) return storage.ObjectID{ Provider: providerName, Container: "blocks", ID: id, }, nil } // GetBlock retrieves a block by its index. func (a *Adapter) GetBlock(ctx context.Context, index uint32) ([]byte, error) { a.mu.RLock() defer a.mu.RUnlock() blockPath := a.blockPath(index) data, err := os.ReadFile(blockPath) if err != nil { if os.IsNotExist(err) { return nil, storage.ErrNotFound } return nil, fmt.Errorf("local: read block: %w", err) } return data, nil } // GetBlockRange retrieves multiple blocks efficiently. func (a *Adapter) GetBlockRange(ctx context.Context, start, end uint32) ([][]byte, error) { a.mu.RLock() defer a.mu.RUnlock() if start > end { return nil, fmt.Errorf("local: invalid block range: start %d > end %d", start, end) } blocks := make([][]byte, 0, end-start+1) for i := start; i <= end; i++ { blockPath := a.blockPath(i) data, err := os.ReadFile(blockPath) if err != nil { if os.IsNotExist(err) { return nil, storage.ErrNotFound } return nil, fmt.Errorf("local: read block %d: %w", i, err) } blocks = append(blocks, data) } return blocks, nil } // GetLatestBlockIndex returns the highest block index in storage. func (a *Adapter) GetLatestBlockIndex(ctx context.Context) (uint32, error) { a.mu.RLock() defer a.mu.RUnlock() blocksDir := filepath.Join(a.basePath, "blocks") var latest uint32 var found bool err := filepath.Walk(blocksDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil } // Parse block index from filename name := info.Name() if !strings.HasSuffix(name, ".block") { return nil } var index uint32 if _, err := fmt.Sscanf(name, "%d.block", &index); err != nil { return nil } if !found || index > latest { latest = index found = true } return nil }) if err != nil { return 0, fmt.Errorf("local: scan blocks: %w", err) } if !found { return 0, storage.ErrNotFound } return latest, nil } // ============================================================================= // StateStorage Interface Implementation // ============================================================================= // PutState stores a state snapshot at a specific height. func (a *Adapter) PutState(ctx context.Context, height uint32, data io.Reader) (storage.ObjectID, error) { a.mu.Lock() defer a.mu.Unlock() // Read all data content, err := io.ReadAll(data) if err != nil { return storage.ObjectID{}, fmt.Errorf("local: read state data: %w", err) } // Check size limits if a.maxSize > 0 { currentSize, _ := a.calculateSize() if currentSize+int64(len(content)) > a.maxSize { return storage.ObjectID{}, storage.ErrQuotaExceeded } } // Write state file statePath := a.statePath(height) if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil { return storage.ObjectID{}, fmt.Errorf("local: create state dir: %w", err) } if err := os.WriteFile(statePath, content, 0644); err != nil { return storage.ObjectID{}, fmt.Errorf("local: write state: %w", err) } // Generate content hash for ID hash := sha256.Sum256(content) id := hex.EncodeToString(hash[:]) return storage.ObjectID{ Provider: providerName, Container: "states", ID: id, }, nil } // GetState retrieves the state snapshot for a given height. func (a *Adapter) GetState(ctx context.Context, height uint32) (io.ReadCloser, error) { a.mu.RLock() defer a.mu.RUnlock() statePath := a.statePath(height) file, err := os.Open(statePath) if err != nil { if os.IsNotExist(err) { return nil, storage.ErrNotFound } return nil, fmt.Errorf("local: open state: %w", err) } return file, nil } // GetLatestState returns the most recent state snapshot. func (a *Adapter) GetLatestState(ctx context.Context) (uint32, io.ReadCloser, error) { a.mu.RLock() defer a.mu.RUnlock() statesDir := filepath.Join(a.basePath, "states") var latest uint32 var found bool err := filepath.Walk(statesDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil } // Parse height from filename name := info.Name() if !strings.HasSuffix(name, ".state") { return nil } var height uint32 if _, err := fmt.Sscanf(name, "%d.state", &height); err != nil { return nil } if !found || height > latest { latest = height found = true } return nil }) if err != nil { return 0, nil, fmt.Errorf("local: scan states: %w", err) } if !found { return 0, nil, storage.ErrNotFound } // Open the latest state file statePath := a.statePath(latest) file, err := os.Open(statePath) if err != nil { return 0, nil, fmt.Errorf("local: open latest state: %w", err) } return latest, file, nil } // objectPath returns the filesystem path for an object. func (a *Adapter) objectPath(id string) string { // Use first 2 chars as subdirectory for better filesystem performance if len(id) >= 2 { return filepath.Join(a.basePath, "objects", id[:2], id) } return filepath.Join(a.basePath, "objects", id) } // metaPath returns the filesystem path for object metadata. func (a *Adapter) metaPath(id string) string { if len(id) >= 2 { return filepath.Join(a.basePath, "meta", id[:2], id+".json") } return filepath.Join(a.basePath, "meta", id+".json") } // blockPath returns the filesystem path for a block by index. func (a *Adapter) blockPath(index uint32) string { // Use first 4 digits as subdirectory for better filesystem performance // e.g., block 12345 -> blocks/0001/12345.block subdir := fmt.Sprintf("%04d", index/10000) return filepath.Join(a.basePath, "blocks", subdir, fmt.Sprintf("%d.block", index)) } // statePath returns the filesystem path for a state snapshot by height. func (a *Adapter) statePath(height uint32) string { // Use first 4 digits as subdirectory for better filesystem performance // e.g., state 12345 -> states/0001/12345.state subdir := fmt.Sprintf("%04d", height/10000) return filepath.Join(a.basePath, "states", subdir, fmt.Sprintf("%d.state", height)) } // objectMeta holds object metadata. type objectMeta struct { Size int64 `json:"size"` ContentType string `json:"content_type,omitempty"` Created time.Time `json:"created"` Modified time.Time `json:"modified"` Checksum string `json:"checksum"` Attributes map[string]string `json:"attributes,omitempty"` } // loadMeta loads object metadata from disk. func (a *Adapter) loadMeta(id string) (objectMeta, error) { metaPath := a.metaPath(id) data, err := os.ReadFile(metaPath) if err != nil { return objectMeta{}, err } var meta objectMeta err = json.Unmarshal(data, &meta) return meta, err } // calculateSize returns the total size of all stored data. func (a *Adapter) calculateSize() (int64, error) { var total int64 // Calculate size across all storage directories for _, subdir := range []string{"objects", "blocks", "states"} { dir := filepath.Join(a.basePath, subdir) err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if !info.IsDir() { total += info.Size() } return nil }) if err != nil { return total, err } } return total, nil }