// Package store reads and (for git push) writes files in upspin (or, in dev, // the local filesystem) for the git service. // // The service runs as a single upspin user (git@cosmocr.at). A repo owner // publishes a path by granting that user read,list access in an upspin Access // file; the store then fetches those files on the owner's behalf. Push // write-back additionally requires create,write,delete grants. All access // control therefore lives in upspin Access files, not here. Reads use the // read-only Store interface; only push uses WriteStore. package store
import ( "errors" "io/fs" "os" "path" "path/filepath" "sort" "strings" "syscall"
"upspin.io/client"
upspinerr "upspin.io/errors"
"upspin.io/upspin"
"upspin.io/user"
)
// ErrNotFound is returned for a missing path OR one the browser is not // permitted to read. The two are deliberately conflated so a public service // never leaks the existence of paths it cannot access. var ErrNotFound = errors.New("not found")
// Entry is one child of a directory listing. type Entry struct { Name string // base name (no trailing slash) IsDir bool }
// Store is the read-only file source backing the browser. type Store interface { // Stat reports whether path is a directory. Returns ErrNotFound if the // path does not exist or is not readable. Stat(p string) (isDir bool, err error) // Get returns the contents of a file. Get(p string) ([]byte, error) // List returns the children of a directory, sorted (dirs first). List(dir string) ([]Entry, error) // Seq returns a version token for path that changes whenever path or // anything beneath it changes. For an upspin directory this is its // Sequence, which is the max sequence of its subtree, so it doubles as a // cheap "did anything under here change" token used to invalidate the // per-repo cache. Returns ErrNotFound if the path is missing/unreadable. Seq(p string) (int64, error) }
// WriteStore is a Store that also supports mutation. It is used only by git // push write-back; browsing and clone never need it. The browser still holds a // single upspin identity (git@cosmocr.at), so writes succeed only where the // repo owner has granted that user create/write/delete in an Access file. type WriteStore interface { Store // Put writes data to p, creating any missing parent directories. It // overwrites an existing file at p. Put(p string, data []byte) error // Delete removes the file at p. Deleting a missing path returns ErrNotFound. Delete(p string) error }
// ValidUser reports whether name is a well-formed upspin user name. The first // path element of every request is validated with this before any upspin path // is constructed, guarding against traversal/injection. func ValidUser(name string) bool { _, _, _, err := user.Parse(upspin.UserName(name)) return err == nil }
// --- upspin-backed store (production) ---
// UpspinStore reads from upspin as the configured user. type UpspinStore struct{ c upspin.Client }
// NewUpspinStore returns a Store backed by an upspin client for cfg. func NewUpspinStore(cfg upspin.Config) *UpspinStore { return &UpspinStore{c: client.New(cfg)} }
func mapErr(err error) error { if err == nil { return nil } // Missing, or access withheld because the owner hasn't granted the browser // read access: both surface as "not found" on this public service. if upspinerr.Is(upspinerr.NotExist, err) || upspinerr.Is(upspinerr.Permission, err) || upspinerr.Is(upspinerr.Private, err) { return ErrNotFound } return err }
func (s *UpspinStore) Stat(p string) (bool, error) { de, err := s.c.Lookup(upspin.PathName(p), true) if err != nil { return false, mapErr(err) } return de.IsDir(), nil }
func (s *UpspinStore) Get(p string) ([]byte, error) { b, err := s.c.Get(upspin.PathName(p)) return b, mapErr(err) }
func (s UpspinStore) List(dir string) ([]Entry, error) { pattern := strings.TrimRight(dir, "/") + "/" entries, err := s.c.Glob(pattern) if err != nil { return nil, mapErr(err) } out := make([]Entry, 0, len(entries)) for _, e := range entries { out = append(out, Entry{Name: path.Base(string(e.Name)), IsDir: e.IsDir()}) } sortEntries(out) return out, nil }
func (s *UpspinStore) Seq(p string) (int64, error) { de, err := s.c.Lookup(upspin.PathName(p), true) if err != nil { return 0, mapErr(err) } return de.Sequence, nil }
func (s *UpspinStore) Put(p string, data []byte) error { if err := s.mkdirAll(path.Dir(p)); err != nil { return err } _, err := s.c.Put(upspin.PathName(p), data) return mapErr(err) }
func (s *UpspinStore) Delete(p string) error { return mapErr(s.c.Delete(upspin.PathName(p))) }
// mkdirAll creates dir and any missing ancestors below the user root. The user // root itself (the first path element) is assumed to exist. Directories that // already exist are not an error. func (s *UpspinStore) mkdirAll(dir string) error { dir = strings.Trim(dir, "/") if dir == "" { return nil } elems := strings.Split(dir, "/") // elems[0] is the user root; start creating at the first sub-directory. for i := 1; i < len(elems); i++ { prefix := strings.Join(elems[:i+1], "/") _, err := s.c.MakeDirectory(upspin.PathName(prefix)) if err != nil && !upspinerr.Is(upspinerr.Exist, err) { return mapErr(err) } } return nil }
// --- local filesystem store (dev only) ---
// LocalStore serves files from a local directory rooted at root, so the browser // can be exercised without upspin keys. A request path "/" maps to // "//". type LocalStore struct{ root string }
// NewLocalStore returns a dev Store rooted at root. func NewLocalStore(root string) *LocalStore { return &LocalStore{root: root} }
func (s *LocalStore) full(p string) string { return filepath.Join(s.root, filepath.FromSlash(p)) }
// localNotExist reports whether err means the path does not exist. It also // treats ENOTDIR (a path component is a file, not a directory — e.g. statting // "somefile/HEAD") as not-found, matching upspin's conflation and how Locate // probes for repo marker files under arbitrary paths. func localNotExist(err error) bool { return errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) }
func (s *LocalStore) Stat(p string) (bool, error) { fi, err := os.Stat(s.full(p)) if localNotExist(err) { return false, ErrNotFound } if err != nil { return false, err } return fi.IsDir(), nil }
func (s *LocalStore) Get(p string) ([]byte, error) { b, err := os.ReadFile(s.full(p)) if localNotExist(err) { return nil, ErrNotFound } return b, err }
func (s *LocalStore) List(dir string) ([]Entry, error) { des, err := os.ReadDir(s.full(dir)) if localNotExist(err) { return nil, ErrNotFound } if err != nil { return nil, err } out := make([]Entry, 0, len(des)) for _, de := range des { out = append(out, Entry{Name: de.Name(), IsDir: de.IsDir()}) } sortEntries(out) return out, nil }
func (s *LocalStore) Put(p string, data []byte) error { full := s.full(p) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return err } return os.WriteFile(full, data, 0o644) }
func (s *LocalStore) Delete(p string) error { err := os.Remove(s.full(p)) if os.IsNotExist(err) { return ErrNotFound } return err }
// Seq folds the newest mtime under p into a token (dev fallback; perfect // invalidation is not required in dev). func (s *LocalStore) Seq(p string) (int64, error) { root := s.full(p) fi, err := os.Stat(root) if localNotExist(err) { return 0, ErrNotFound } if err != nil { return 0, err } if !fi.IsDir() { return fi.ModTime().UnixNano(), nil } var newest int64 err = filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { if err != nil { return err } info, err := d.Info() if err != nil { return err } if n := info.ModTime().UnixNano(); n > newest { newest = n } return nil }) if err != nil { return 0, err } return newest, nil }
// sortEntries orders directories first, then by name. func sortEntries(es []Entry) { sort.Slice(es, func(i, j int) bool { if es[i].IsDir != es[j].IsDir { return es[i].IsDir } return es[i].Name < es[j].Name }) }