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 }