github.com/creachadair/ffs@v0.17.3/filetree/filetree.go (about)

     1  // Copyright 2025 Michael J. Fromberger. All Rights Reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package filetree defines a composite [blob.Store] implementation that
    16  // handles separate [file.File] and [root.Root] namespaces.
    17  package filetree
    18  
    19  import (
    20  	"context"
    21  	"encoding/base64"
    22  	"encoding/hex"
    23  	"fmt"
    24  	"path"
    25  	"strings"
    26  
    27  	"github.com/creachadair/ffs/blob"
    28  	"github.com/creachadair/ffs/file"
    29  	"github.com/creachadair/ffs/file/root"
    30  	"github.com/creachadair/ffs/fpath"
    31  )
    32  
    33  // Store is a composite [blob.StoreCloser] that maintains separate buckets for
    34  // root pointers and content-addressed data.
    35  type Store struct {
    36  	roots blob.KV
    37  	files blob.CAS
    38  	fsync blob.KV // as files, but with arbitrary writes for sync operations
    39  
    40  	s blob.StoreCloser
    41  }
    42  
    43  type nopCloser struct{ blob.Store }
    44  
    45  func (nopCloser) Close(context.Context) error { return nil }
    46  
    47  // NewStore constructs a Store overlaying the specified base.
    48  func NewStore(ctx context.Context, base blob.Store) (Store, error) {
    49  	var out Store
    50  	if sc, ok := base.(blob.StoreCloser); ok {
    51  		out.s = sc
    52  	} else {
    53  		out.s = nopCloser{base}
    54  	}
    55  
    56  	var err error
    57  	out.roots, err = base.KV(ctx, "root")
    58  	if err != nil {
    59  		return Store{}, fmt.Errorf("open root keyspace: %w", err)
    60  	}
    61  	out.fsync, err = base.KV(ctx, "file")
    62  	if err != nil {
    63  		return Store{}, fmt.Errorf("open file keyspace: %w", err)
    64  	}
    65  	out.files, err = base.CAS(ctx, "file")
    66  	if err != nil {
    67  		return Store{}, fmt.Errorf("open file keyspace: %w", err)
    68  	}
    69  	return out, nil
    70  }
    71  
    72  // Files returns the files bucket of the underlying storage.
    73  func (s Store) Files() blob.CAS { return s.files }
    74  
    75  // Roots returns the roots bucket of the underlying storage.
    76  func (s Store) Roots() blob.KV { return s.roots }
    77  
    78  // Sync returns a sync view of the files bucket.
    79  func (s Store) Sync() blob.KV { return s.fsync }
    80  
    81  // Base returns the underlying store for c.
    82  func (s Store) Base() blob.Store { return s.s }
    83  
    84  // Close closes the store attached to c.
    85  func (s Store) Close(ctx context.Context) error { return s.s.Close(ctx) }
    86  
    87  func isAllHex(s string) bool {
    88  	for _, c := range s {
    89  		if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
    90  			return false
    91  		}
    92  	}
    93  	return true
    94  }
    95  
    96  // PathInfo is the result of parsing and opening a path spec.
    97  type PathInfo struct {
    98  	Path    string     // the original input path (unparsed)
    99  	Base    *file.File // the root or starting file of the path
   100  	BaseKey string     // the storage key of the base file
   101  	File    *file.File // the target file of the path
   102  	FileKey string     // the storage key of the target file
   103  	Root    *root.Root // the specified root, or nil if none
   104  	RootKey string     // the key of root, or ""
   105  }
   106  
   107  // Flush flushes the base file to reflect any changes and returns its updated
   108  // storage key. If p is based on a root, the root is also updated and saved.
   109  func (p *PathInfo) Flush(ctx context.Context) (string, error) {
   110  	key, err := p.Base.Flush(ctx)
   111  	if err != nil {
   112  		return "", err
   113  	}
   114  	p.BaseKey = key
   115  
   116  	// If this path started at a root, write out the updated contents.
   117  	if p.Root != nil {
   118  		// If the file has changed, invalidate the index.
   119  		if p.Root.FileKey != key {
   120  			p.Root.IndexKey = ""
   121  		}
   122  		p.Root.FileKey = key
   123  		if err := p.Root.Save(ctx, p.RootKey); err != nil {
   124  			return "", err
   125  		}
   126  	}
   127  	return key, nil
   128  }
   129  
   130  // OpenPath parses and opens the specified path in s.
   131  // The path has either the form "<root-key>/some/path" or "@<file-key>/some/path".
   132  func OpenPath(ctx context.Context, s Store, path string) (*PathInfo, error) {
   133  	out := &PathInfo{Path: path}
   134  
   135  	first, rest := SplitPath(path)
   136  
   137  	// Check for a @file key prefix; otherwise it should be a root.
   138  	if !strings.HasPrefix(first, "@") {
   139  		rp, err := root.Open(ctx, s.Roots(), first)
   140  		if err != nil {
   141  			return nil, err
   142  		}
   143  		rf, err := rp.File(ctx, s.Files())
   144  		if err != nil {
   145  			return nil, err
   146  		}
   147  		out.Root = rp
   148  		out.RootKey = first
   149  		out.Base = rf
   150  		out.File = rf
   151  		out.FileKey = rp.FileKey // provisional
   152  
   153  	} else if fk, err := ParseKey(strings.TrimPrefix(first, "@")); err != nil {
   154  		return nil, err
   155  
   156  	} else if fp, err := file.Open(ctx, s.Files(), fk); err != nil {
   157  		return nil, err
   158  
   159  	} else {
   160  		out.Base = fp
   161  		out.File = fp
   162  		out.FileKey = fk
   163  	}
   164  	out.BaseKey = out.Base.Key() // safe, it was just opened
   165  
   166  	// If the rest of the path is empty, the starting point is the target.
   167  	if rest == "" {
   168  		return out, nil
   169  	}
   170  
   171  	// Otherwise, open a path relative to the base.
   172  	tf, err := fpath.Open(ctx, out.Base, rest)
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  	out.File = tf
   177  	out.FileKey = out.File.Key() // safe, it was just opened
   178  	return out, nil
   179  }
   180  
   181  // SplitPath parses s as a slash-separated path specification.
   182  // The first segment of s identifies the storage key of a root or file, the
   183  // rest indicates a sequence of child names starting from that file.
   184  // The rest may be empty.
   185  func SplitPath(s string) (first, rest string) {
   186  	if pre, post, ok := strings.Cut(s, "=/"); ok { // <base64>=/more/stuff
   187  		return pre + "=", path.Clean(post)
   188  	}
   189  	if strings.HasSuffix(s, "=") {
   190  		return s, ""
   191  	}
   192  	pre, post, _ := strings.Cut(s, "/")
   193  	return pre, path.Clean(post)
   194  }
   195  
   196  // ParseKey parses the string encoding of a key. A key must be a hex string, a
   197  // base64 string, or a literal string prefixed with "@":
   198  //
   199  //	@foo     encodes "foo"
   200  //	@@foo    encodes "@foo"
   201  //	414243   encodes "ABC"
   202  //	eHl6enk= encodes "xyzzy"
   203  func ParseKey(s string) (string, error) {
   204  	if strings.HasPrefix(s, "@") {
   205  		return s[1:], nil
   206  	}
   207  	var key []byte
   208  	var err error
   209  	if isAllHex(s) {
   210  		key, err = hex.DecodeString(s)
   211  	} else if strings.HasSuffix(s, "=") {
   212  		key, err = base64.StdEncoding.DecodeString(s)
   213  	} else {
   214  		key, err = base64.RawStdEncoding.DecodeString(s) // tolerate missing padding
   215  	}
   216  	if err != nil {
   217  		return "", fmt.Errorf("invalid key %q: %w", s, err)
   218  	}
   219  	return string(key), nil
   220  }