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  }