git@cosmocr.at/gitweb.git @ main · summary refs log
main / internal / store / store.go /
internal/store/store.go · 8.0 KiB · raw
view: Source ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
// 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 "<user>/<rest>" maps to
// "<root>/<user>/<rest>".
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
	})
}