github.com/anthropics/anthropic-sdk-go@v1.70.1/internal/filestore/filestore.go (about) 1 // Package filestore confines all reads and writes to one folder — a relative 2 // path cannot escape it. 3 // 4 // Beta scope: symlinks are refused or skipped wherever the store meets them, 5 // but there is no hardening against a process racing the store's own 6 // syscalls; fsync durability, non-unix platforms (Open refuses where 7 // O_NOFOLLOW is missing), and read-size caps are out of scope. 8 package filestore 9 10 import ( 11 "crypto/sha256" 12 "encoding/hex" 13 "errors" 14 "fmt" 15 "io" 16 "io/fs" 17 "os" 18 "path/filepath" 19 "slices" 20 "strings" 21 "sync" 22 "syscall" 23 "time" 24 "unicode/utf8" 25 ) 26 27 // Folders the store creates hold downloaded user/model content — keep them 28 // owner-only rather than inheriting the process umask. 29 const ( 30 ownerOnlyDirMode os.FileMode = 0o700 31 ownerOnlyFileMode os.FileMode = 0o600 32 ownerOnlyExecMode os.FileMode = 0o700 33 ) 34 35 // Refusal reasons carried by [FileStoreError]. 36 const ( 37 ReasonEscapesRoot = "escapes the store root" 38 ReasonIsASymlink = "is a symlink" 39 ReasonNotAFile = "is not a regular file" 40 ReasonNotADirectory = "is not a directory" 41 ReasonNotUTF8 = "is not valid utf-8" 42 ReasonMoveDestinationExists = "already exists" 43 ) 44 45 // FileStoreError marks a refused operation — input the store will not act on. 46 // OS-level errors propagate as their usual *PathError values. 47 type FileStoreError struct { 48 Reason string 49 RelPath string 50 } 51 52 func (e *FileStoreError) Error() string { return fmt.Sprintf("path %q %s", e.RelPath, e.Reason) } 53 54 // Root is a store's resolved root path and what [FileStore.Dispose] will do to 55 // it. RemovedOnDispose is true when Open found no root and this store created 56 // it, so Dispose removes it. A root that was already there is someone else's — 57 // a pre-seeded mount, a caller's workdir — and is kept. 58 type Root struct { 59 Path string 60 RemovedOnDispose bool 61 } 62 63 // IsPathLegal reports whether path is usable verbatim as a store location: 64 // absolute (in POSIX terms — mount paths arrive in wire form) with no ".." 65 // elements. 66 func IsPathLegal(path string) bool { 67 return strings.HasPrefix(path, "/") && !slices.Contains(strings.Split(path, "/"), "..") 68 } 69 70 // Option configures a store at [Open]. 71 type Option func(*config) 72 73 type config struct { 74 utf8 bool 75 } 76 77 // WithUTF8 restricts the store to valid UTF-8: a Put of binary bytes and a 78 // Get of a binary file are refused with a *FileStoreError, so a caller that 79 // decodes what Get returns can never hit a decode error. 80 func WithUTF8() Option { 81 return func(c *config) { c.utf8 = true } 82 } 83 84 // PutOption configures a single Put call. 85 type PutOption func(*putConfig) 86 87 type putConfig struct { 88 executable bool 89 } 90 91 // Executable marks the written file executable (owner-only exec bit). 92 func Executable() PutOption { return func(c *putConfig) { c.executable = true } } 93 94 // hashed is one hashed file version: the stat identity it had, and its sha. 95 type hashed struct { 96 mtimeNs int64 97 // userspace cannot set ctime, so writers that preserve mtimes 98 // (rsync -t, cp -p) still miss the cache. 99 ctimeNs int64 100 size int64 101 sha string 102 } 103 104 // FileStore is one confined folder of regular files. 105 // 106 // Every rel path is relative to the root (a leading "/" also means the root) 107 // and refused with a *FileStoreError when it escapes. The store holds regular 108 // files only: symlinks are refused on read and skipped by listings — 109 // [FileStore.FindSymlinks] reports them. A rel path resolving to the root 110 // itself is banned by this interface: Put and Get refuse it, Move and Remove 111 // do nothing. A store opened with [WithUTF8] refuses binary content the same 112 // way — on a Put of such bytes and on a Get of such a file. Only 113 // [FileStore.CreateRoot] makes the root: writes create directories below it, 114 // never the root itself, so a root removed while the store is open stays 115 // removed and the write fails with an error matching [fs.ErrNotExist]. 116 type FileStore struct { 117 root string 118 removedOnDispose bool 119 utf8Only bool 120 // hashMu guards hashes: HashTree and HashFile share the cache. 121 hashMu sync.Mutex 122 hashes map[string]hashed 123 } 124 125 // Open resolves root; creates nothing — only [FileStore.CreateRoot] makes the 126 // folder. On platforms without O_NOFOLLOW it returns an error (a deployment 127 // condition, not a *FileStoreError input refusal). 128 func Open(root string, opts ...Option) (*FileStore, error) { 129 if err := platformCheck(); err != nil { 130 return nil, err 131 } 132 var cfg config 133 for _, o := range opts { 134 o(&cfg) 135 } 136 abs, err := filepath.Abs(root) 137 if err != nil { 138 return nil, err 139 } 140 _, err = os.Lstat(abs) 141 // Only "no entry" means we created it. Any other failure (EACCES, ELOOP) 142 // must not mark a real directory ours to delete on dispose. 143 removedOnDispose := errors.Is(err, fs.ErrNotExist) 144 if err != nil && !removedOnDispose { 145 return nil, err 146 } 147 return &FileStore{ 148 root: abs, 149 removedOnDispose: removedOnDispose, 150 utf8Only: cfg.utf8, 151 hashes: map[string]hashed{}, 152 }, nil 153 } 154 155 // CreateRoot creates the root directory and any missing ancestors; already 156 // existing is fine. 157 func (s *FileStore) CreateRoot() error { 158 return makeDirAndAncestors(s.root) 159 } 160 161 // Root returns the resolved root and what Dispose will do to it. 162 func (s *FileStore) Root() Root { 163 return Root{Path: s.root, RemovedOnDispose: s.removedOnDispose} 164 } 165 166 // Dispose removes the root iff this Open created it; pre-existing roots are kept. 167 func (s *FileStore) Dispose() error { 168 if !s.removedOnDispose { 169 return nil 170 } 171 return os.RemoveAll(s.root) 172 } 173 174 // Put writes data atomically to the file at rel. Missing directories below 175 // the root are created; a missing root is not — the write fails with an 176 // error matching [fs.ErrNotExist]. 177 func (s *FileStore) Put(rel string, data []byte, opts ...PutOption) error { 178 var cfg putConfig 179 for _, o := range opts { 180 o(&cfg) 181 } 182 // "dir/." names a directory just like a trailing "/". 183 tail := strings.ReplaceAll(rel, "\\", "/") 184 if tail == "" || tail == "." || strings.HasSuffix(tail, "/") || strings.HasSuffix(tail, "/.") { 185 return &FileStoreError{Reason: ReasonNotAFile, RelPath: rel} 186 } 187 dest, err := s.resolveUnderRoot(rel) 188 if err != nil { 189 return err 190 } 191 if err := s.requireUTF8(rel, data); err != nil { 192 return err 193 } 194 if err := makeDirsBelowRoot(s.root, filepath.Dir(dest)); err != nil { 195 return err 196 } 197 return replaceViaTemp(dest, data, cfg.executable) 198 } 199 200 // Get returns the file's bytes; (nil, nil) when absent. 201 func (s *FileStore) Get(rel string) ([]byte, error) { 202 dest, err := s.resolveUnderRoot(rel) 203 if err != nil { 204 return nil, err 205 } 206 f, err := openRegularFile(rel, dest) 207 if err != nil { 208 if errors.Is(err, fs.ErrNotExist) { 209 return nil, nil 210 } 211 return nil, err 212 } 213 defer f.Close() 214 data, err := io.ReadAll(f) 215 if err != nil { 216 return nil, err 217 } 218 if err := s.requireUTF8(rel, data); err != nil { 219 return nil, err 220 } 221 return data, nil 222 } 223 224 // Ls returns the relative path of every file under the directory under. 225 func (s *FileStore) Ls(under string) (map[string]bool, error) { 226 base, err := s.resolveUnderRoot(under) 227 if err != nil { 228 return nil, err 229 } 230 files, err := filenamesInDir(s.root, under, base) 231 if err != nil { 232 return nil, err 233 } 234 out := make(map[string]bool) 235 for _, f := range files { 236 out[f.rel] = true 237 } 238 return out, nil 239 } 240 241 // FindSymlinks returns every symlink under under — listings skip them and 242 // reads refuse them. 243 func (s *FileStore) FindSymlinks(under string) (map[string]bool, error) { 244 base, err := s.resolveUnderRoot(under) 245 if err != nil { 246 return nil, err 247 } 248 return symlinksInDir(s.root, under, base) 249 } 250 251 // HashTree returns {rel path → sha256-hex} of every file under the directory 252 // under. Unchanged files — same size, mtime, and ctime since the last call — 253 // reuse their recorded hash instead of being re-read. 254 func (s *FileStore) HashTree(under string) (map[string]string, error) { 255 base, err := s.resolveUnderRoot(under) 256 if err != nil { 257 return nil, err 258 } 259 walkStartNs := nowNs() 260 files, err := filenamesInDir(s.root, under, base) 261 if err != nil { 262 return nil, err 263 } 264 s.hashMu.Lock() 265 defer s.hashMu.Unlock() 266 out := make(map[string]string) 267 for _, f := range files { 268 sha, err := s.hashViaCache(f.rel, f.path, walkStartNs) 269 if err != nil { 270 return nil, err 271 } 272 if sha != "" { 273 out[f.rel] = sha 274 } 275 } 276 return out, nil 277 } 278 279 // HashFile returns one file's sha256-hex; ("", nil) when absent. Shares 280 // [FileStore.HashTree]'s cache. 281 func (s *FileStore) HashFile(rel string) (string, error) { 282 dest, err := s.resolveUnderRoot(rel) 283 if err != nil { 284 return "", err 285 } 286 st, err := os.Lstat(dest) 287 if err != nil { 288 if errors.Is(err, fs.ErrNotExist) { 289 return "", nil 290 } 291 return "", err 292 } 293 if st.Mode()&fs.ModeSymlink != 0 { 294 return "", &FileStoreError{Reason: ReasonIsASymlink, RelPath: rel} 295 } 296 if !st.Mode().IsRegular() { 297 return "", &FileStoreError{Reason: ReasonNotAFile, RelPath: rel} 298 } 299 relKey, err := filepath.Rel(s.root, dest) 300 if err != nil { 301 return "", err 302 } 303 s.hashMu.Lock() 304 defer s.hashMu.Unlock() 305 return s.hashViaCache(filepath.ToSlash(relKey), dest, nowNs()) 306 } 307 308 // Move renames src to dst; an existing dst is refused. The banned store root 309 // as either end does nothing. 310 func (s *FileStore) Move(src, dst string) error { 311 from, err := s.resolveUnderRoot(src) 312 if err != nil { 313 return err 314 } 315 to, err := s.resolveUnderRoot(dst) 316 if err != nil { 317 return err 318 } 319 if from == s.root || to == s.root { 320 return nil 321 } 322 // Stat, not Lstat: a dangling symlink at dst reads absent and is replaced 323 // by the rename. 324 if _, err := os.Stat(to); err == nil { 325 return &FileStoreError{Reason: ReasonMoveDestinationExists, RelPath: dst} 326 } 327 if err := makeDirsBelowRoot(s.root, filepath.Dir(to)); err != nil { 328 return err 329 } 330 return os.Rename(from, to) 331 } 332 333 // Remove deletes a file or subtree; absent — and the banned store root — do nothing. 334 func (s *FileStore) Remove(rel string) error { 335 dest, err := s.resolveUnderRoot(rel) 336 if err != nil { 337 return err 338 } 339 if dest == s.root { 340 return nil 341 } 342 // os.RemoveAll unlinks a symlink rather than following it, so Remove 343 // cannot reach through one. 344 return os.RemoveAll(dest) 345 } 346 347 func (s *FileStore) resolveUnderRoot(rel string) (string, error) { 348 norm := strings.ReplaceAll(rel, "\\", "/") 349 if strings.HasPrefix(norm, "/") { 350 norm = strings.TrimLeft(norm, "/") 351 } else if filepath.IsAbs(rel) { 352 return "", &FileStoreError{Reason: ReasonEscapesRoot, RelPath: rel} 353 } 354 var parts []string 355 for _, p := range strings.Split(norm, "/") { 356 switch p { 357 case "", ".": 358 case "..": 359 return "", &FileStoreError{Reason: ReasonEscapesRoot, RelPath: rel} 360 default: 361 parts = append(parts, p) 362 } 363 } 364 return filepath.Join(append([]string{s.root}, parts...)...), nil 365 } 366 367 func (s *FileStore) requireUTF8(rel string, data []byte) error { 368 if s.utf8Only && !utf8.Valid(data) { 369 return &FileStoreError{Reason: ReasonNotUTF8, RelPath: rel} 370 } 371 return nil 372 } 373 374 // hashViaCache hashes one regular file through the shared cache; the caller 375 // holds hashMu. ("", nil) means the file left this snapshot — vanished or 376 // swapped for a non-regular entry since the walk. 377 func (s *FileStore) hashViaCache(rel, path string, walkStartNs int64) (string, error) { 378 st, err := os.Lstat(path) 379 if err != nil { 380 if errors.Is(err, fs.ErrNotExist) { 381 return "", nil // vanished since the walk: not in this snapshot 382 } 383 return "", err 384 } 385 if !st.Mode().IsRegular() { 386 return "", nil 387 } 388 var sha string 389 if cached, ok := s.hashes[rel]; ok && unchangedSinceHashed(cached, st) { 390 sha = cached.sha 391 } else if sha, err = hashFile(path); err != nil { 392 var fse *FileStoreError 393 if errors.Is(err, fs.ErrNotExist) || errors.As(err, &fse) { 394 return "", nil 395 } 396 // FreeBSD reports EMLINK rather than ELOOP for O_NOFOLLOW. 397 if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.EMLINK) { 398 return "", nil 399 } 400 return "", err 401 } 402 if oldEnoughToCache(st, walkStartNs) { 403 s.hashes[rel] = hashed{mtimeNs: st.ModTime().UnixNano(), ctimeNs: ctimeNs(st), size: st.Size(), sha: sha} 404 } 405 return sha, nil 406 } 407 408 func makeDirAndAncestors(path string) error { 409 var missing []string 410 current := path 411 for { 412 if _, err := os.Stat(current); err == nil { 413 break 414 } 415 parent := filepath.Dir(current) 416 if parent == current { 417 break 418 } 419 missing = append(missing, current) 420 current = parent 421 } 422 for i := len(missing) - 1; i >= 0; i-- { 423 if err := os.Mkdir(missing[i], ownerOnlyDirMode); err != nil && !errors.Is(err, fs.ErrExist) { 424 return err 425 } 426 } 427 return nil 428 } 429 430 // makeDirsBelowRoot creates each missing directory from root down to dir, 431 // never root itself: only CreateRoot makes the root, so a write racing an 432 // rm -rf of the folder fails with ENOENT instead of re-creating it. 433 func makeDirsBelowRoot(root, dir string) error { 434 rel, err := filepath.Rel(root, dir) 435 if err != nil { 436 return err 437 } 438 if rel == "." { 439 return nil 440 } 441 current := root 442 for _, part := range strings.Split(rel, string(filepath.Separator)) { 443 current = filepath.Join(current, part) 444 if err := os.Mkdir(current, ownerOnlyDirMode); err != nil && !errors.Is(err, fs.ErrExist) { 445 return err 446 } 447 } 448 return nil 449 } 450 451 // hashFile is a variable so tests can count invocations. 452 var hashFile = func(path string) (string, error) { 453 f, err := openRegularFile(filepath.Base(path), path) 454 if err != nil { 455 return "", err 456 } 457 defer f.Close() 458 h := sha256.New() 459 if _, err := io.Copy(h, f); err != nil { 460 return "", err 461 } 462 return hex.EncodeToString(h.Sum(nil)), nil 463 } 464 465 // walkedFile is one regular file a walk found. 466 type walkedFile struct { 467 rel string 468 path string 469 } 470 471 // filenamesInDir returns every regular file under base as (rel, path); an 472 // absent base is empty, a present non-directory is refused (under is only 473 // for refusal messages). 474 func filenamesInDir(root, under, base string) ([]walkedFile, error) { 475 st, err := os.Lstat(base) 476 if err != nil { 477 if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { 478 return nil, nil 479 } 480 return nil, err 481 } 482 if !st.IsDir() { 483 return nil, &FileStoreError{Reason: ReasonNotADirectory, RelPath: under} 484 } 485 var out []walkedFile 486 err = filepath.WalkDir(base, func(p string, d fs.DirEntry, walkErr error) error { 487 if walkErr != nil { 488 if errors.Is(walkErr, fs.ErrNotExist) { 489 return nil // a listing is a snapshot, not a lock 490 } 491 return walkErr 492 } 493 // Non-regular entries are skipped; WalkDir never descends into a symlink. 494 if !d.Type().IsRegular() { 495 return nil 496 } 497 rel, err := filepath.Rel(root, p) 498 if err != nil { 499 return err 500 } 501 out = append(out, walkedFile{rel: filepath.ToSlash(rel), path: p}) 502 return nil 503 }) 504 if err != nil { 505 return nil, err 506 } 507 return out, nil 508 } 509 510 func symlinksInDir(root, under, base string) (map[string]bool, error) { 511 out := make(map[string]bool) 512 st, err := os.Lstat(base) 513 if err != nil { 514 if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { 515 return out, nil 516 } 517 return nil, err 518 } 519 if st.Mode()&fs.ModeSymlink != 0 { 520 rel, err := filepath.Rel(root, base) 521 if err != nil { 522 return nil, err 523 } 524 out[filepath.ToSlash(rel)] = true 525 return out, nil 526 } 527 if !st.IsDir() { 528 return nil, &FileStoreError{Reason: ReasonNotADirectory, RelPath: under} 529 } 530 err = filepath.WalkDir(base, func(p string, d fs.DirEntry, walkErr error) error { 531 if walkErr != nil { 532 if errors.Is(walkErr, fs.ErrNotExist) { 533 return nil 534 } 535 return walkErr 536 } 537 // Symlinks to directories are visited as non-dir entries, never descended. 538 if d.Type()&fs.ModeSymlink == 0 { 539 return nil 540 } 541 rel, err := filepath.Rel(root, p) 542 if err != nil { 543 return err 544 } 545 out[filepath.ToSlash(rel)] = true 546 return nil 547 }) 548 if err != nil { 549 return nil, err 550 } 551 return out, nil 552 } 553 554 func unchangedSinceHashed(cached hashed, st os.FileInfo) bool { 555 return st.ModTime().UnixNano() == cached.mtimeNs && ctimeNs(st) == cached.ctimeNs && st.Size() == cached.size 556 } 557 558 // timestampTrustMarginNs: filesystems stamp times with coarse clocks, so a 559 // rewrite shortly after a hashed write can reuse the exact stamps. Files 560 // younger than the margin are simply re-hashed next walk. 561 var timestampTrustMarginNs int64 = 2_000_000_000 562 563 // nowNs is the walk clock; tests freeze it here. 564 var nowNs = func() int64 { return time.Now().UnixNano() } 565 566 func oldEnoughToCache(st os.FileInfo, walkStartNs int64) bool { 567 return max(st.ModTime().UnixNano(), ctimeNs(st)) < walkStartNs-timestampTrustMarginNs 568 }