coverage~bigbes/sr-ht-spec3cb1c03dgitx/gitx.go

Coverage
85.6% 83/97 statements
Δ
Blob
b34dce2
1 // Package gitx is spec.sr.ht's git layer: the bare-repo lifecycle of a space,
2 // the only read path there is, the proposal write path, the tree-splice merge,
3 // and the refs rule the receive hooks enforce.
4 //
5 // # One storage tier
6 //
7 // There is no checkout on disk. Every read resolves a tree and reads blobs, so
8 // the approved head, a pinned ?rev=<sha> and a proposal branch are the same
9 // code path with a different revision. Nothing downstream of this package ever
10 // touches the filesystem to find a document.
11 //
12 // # No text merge, ever
13 //
14 // go-git v5 implements only FastForwardMerge, and the whole-document write
15 // grain makes a three-way merge unnecessary anyway. Merge is pure plumbing:
16 // object.Tree manipulation plus a commit with two explicit parents. A conflict
17 // is always "your base moved, re-propose" (*StaleError), never a conflict
18 // marker. Merge never calls Repository.Merge.
19 //
20 // # Staleness is keyed by document id, not path
21 //
22 // Paths move; ids do not. Every changed document is resolved to its path on the
23 // approved head through its frontmatter id before its blob is compared, so a
24 // rename between the base and the head neither invents a conflict nor
25 // resurrects a document that was moved.
26 //
27 // # Two writers, one repo
28 //
29 // Human pushes arrive through native receive-pack (spawned by sshd) and agent
30 // proposals through this package in-process: two independent ref-locking
31 // implementations over the same loose refs. go-git's locking is not verified to
32 // interoperate with native git's, so every write here takes a per-space mutex
33 // (process-wide, keyed by the repository's directory) and every ref move is a
34 // compare-and-swap that retries when it loses.
35 //
36 // # Bounded by construction
37 //
38 // Every operation derives a timeout from the caller's context, every blob read
39 // is capped, and every tree walk is capped in both bytes and entries. Nothing
40 // is truncated to fit: a partially read document would be indexed and served as
41 // though it were whole, so an over-budget read fails with ErrTooLarge instead.
42 package gitx
43
44 import (
45 "context"
46 "fmt"
47 "os"
48 "path/filepath"
49 "strings"
50 "time"
51
52 "github.com/go-git/go-git/v5"
53 "github.com/go-git/go-git/v5/plumbing"
54 "github.com/go-git/go-git/v5/plumbing/object"
55
56 "sourcecraft.dev/bigbes/sr-ht-spec/core"
57 )
58
59 const (
60 // DefaultApprovedBranch is the branch a space is created with when the
61 // caller does not name one. It is the branch the human pushes to and the
62 // branch every default read resolves against; "approved" is a property of
63 // being reachable from it, and of nothing else.
64 DefaultApprovedBranch = "main"
65
66 // defaultTimeout bounds a single gitx operation when the caller's context
67 // carries no earlier deadline.
68 defaultTimeout = 30 * time.Second
69
70 // maxDocumentSize caps one document blob. Documents are markdown written
71 // for a human to review; anything past this is either an attachment in the
72 // wrong place or a runaway agent.
73 maxDocumentSize = 4 << 20 // 4 MiB
74
75 // maxWalkBytes caps the total blob bytes one tree walk may materialize, so
76 // a single read cannot pull an entire space into memory.
77 maxWalkBytes = 128 << 20 // 128 MiB
78
79 // maxTreeEntries caps how many entries one walk may visit, bounding the
80 // cost of a pathological tree independently of its byte size.
81 maxTreeEntries = 100000
82
83 // maxTreeDepth caps directory nesting. Deeper than this is not a document
84 // layout, it is a way to blow the stack.
85 maxTreeDepth = 64
86
87 // casAttempts is how many times a ref compare-and-swap is retried after
88 // losing to a concurrent writer before giving up with ErrRefRace.
89 casAttempts = 5
90
91 // maxRevLen caps a revision string before it reaches go-git's parser.
92 maxRevLen = 255
93 )
94
95 // Repo is a handle to one space: a bare git repository at
96 // "<root>/~<owner>/<name>".
97 //
98 // Reads are safe for concurrent use. Writes serialize on a process-wide
99 // per-space lock keyed by the repository directory, so two Repo values opened
100 // over the same space still exclude each other.
101 type Repo struct {
102 dir string
103 ref core.SpaceRef
104 repo *git.Repository
105 approved string // short branch name, read from HEAD
106
107 // Test-only overrides; zero means "use the package constant". They exist so
108 // truncation and retry paths can be exercised with small fixtures instead
109 // of pathological ones.
110 timeout time.Duration
111 docLimit int64
112 walkLimit int64
113 entryLimit int
114 attemptLimit int
115
116 // beforeCAS, when set, runs immediately before each ref compare-and-swap.
117 // It is the only way to open the window a concurrent native receive-pack
118 // push would land in, which is the one thing about the retry path that
119 // cannot be tested from the outside.
120 beforeCAS func()
121 }
122
123 // raceHook fires the test-only pre-compare-and-swap hook.
124 30 func (r *Repo) raceHook() {
125 30 if r.beforeCAS != nil {
126 6 r.beforeCAS()
127 6 }
128 }
129
130 // DiskPath returns the bare repository directory for a space,
131 // "<root>/~<owner>/<name>". The space reference must already be validated;
132 // Create and Open do that before they build a path.
133 49 func DiskPath(root string, sr core.SpaceRef) string {
134 49 return filepath.Join(root, "~"+sr.Owner, sr.Name)
135 49 }
136
137 // CreateOptions configures Create.
138 type CreateOptions struct {
139 // ApprovedBranch names the branch the human pushes to. Empty means
140 // DefaultApprovedBranch.
141 ApprovedBranch string
142
143 // Owner is the identity on the initial commit, in practice the instance's
144 // [sr.ht] owner-name/owner-email. It is required: inventing a committer
145 // would put a fabricated identity in a history whose whole purpose is
146 // provenance.
147 Owner Signature
148
149 // Message is the initial commit's subject. Empty means a generated
150 // "Initialize space ~owner/name".
151 Message string
152 }
153
154 // Create initialises a new space: a bare repository at DiskPath(root, sr) whose
155 // HEAD points at the approved branch, carrying one empty initial commit.
156 //
157 // The initial commit is deliberately not skipped. It makes the approved head
158 // resolvable from the moment the space exists, so no reader, reconciler or
159 // merge has to special-case an unborn branch, and it costs the human nothing:
160 // they clone and push on top of it rather than pushing an unrelated history.
161 //
162 // root must be absolute — the per-space write lock is keyed by directory, and
163 // two spellings of the same directory would be two locks. Create refuses to
164 // touch an existing directory (ErrExists), and removes what it made if it fails
165 // partway, so a failed create never leaves a half-built space behind.
166 50 func Create(ctx context.Context, root string, sr core.SpaceRef, opts CreateOptions) (_ *Repo, err error) {
167 50 if !filepath.IsAbs(root) {
168 1 return nil, fmt.Errorf("gitx: Create requires an absolute repos root, got %q", root)
169 1 }
170 49 if err := validateSpaceRef(sr); err != nil {
171 5 return nil, err
172 5 }
173 44 branch := opts.ApprovedBranch
174 44 if branch == "" {
175 43 branch = DefaultApprovedBranch
176 43 }
177 44 if err := ValidateBranch(branch); err != nil {
178 0 return nil, err
179 0 }
180 44 if err := opts.Owner.validate("owner"); err != nil {
181 1 return nil, err
182 1 }
183 43 message := opts.Message
184 43 if message == "" {
185 43 message = "Initialize space " + sr.String()
186 43 }
187
188 43 dir := filepath.Clean(DiskPath(root, sr))
189 43 if _, statErr := os.Stat(dir); statErr == nil {
190 1 return nil, fmt.Errorf("%w: space %s at %q", ErrExists, sr, dir)
191 42 } else if !os.IsNotExist(statErr) {
192 0 return nil, fmt.Errorf("gitx: stat %q: %w", dir, statErr)
193 0 }
194
195 42 if err := os.MkdirAll(dir, 0o755); err != nil {
196 0 return nil, fmt.Errorf("gitx: create space dir %q: %w", dir, err)
197 0 }
198 // Nothing past this point may leave a partially built repository behind.
199 42 defer func() {
200 42 if err != nil {
201 0 os.RemoveAll(dir)
202 0 }
203 }()
204
205 42 head := plumbing.NewBranchReferenceName(branch)
206 42 repo, err := git.PlainInitWithOptions(dir, &git.PlainInitOptions{
207 42 Bare: true,
208 42 InitOptions: git.InitOptions{DefaultBranch: head},
209 42 })
210 42 if err != nil {
211 0 return nil, fmt.Errorf("gitx: init bare repo at %q: %w", dir, err)
212 0 }
213
214 42 r := &Repo{dir: dir, ref: sr, repo: repo, approved: branch}
215 42
216 42 unlock, err := r.lock(ctx)
217 42 if err != nil {
218 0 return nil, err
219 0 }
220 42 defer unlock()
221 42
222 42 emptyTree, err := (&mutableTree{}).write(repo.Storer)
223 42 if err != nil {
224 0 return nil, fmt.Errorf("gitx: write empty tree for %s: %w", sr, err)
225 0 }
226 42 commit, err := r.writeCommit(CommitMeta{
227 42 Message: message,
228 42 Author: opts.Owner,
229 42 Committer: opts.Owner,
230 42 }, emptyTree, nil)
231 42 if err != nil {
232 0 return nil, err
233 0 }
234 42 if err := repo.Storer.SetReference(plumbing.NewHashReference(head, commit)); err != nil {
235 0 return nil, fmt.Errorf("gitx: set %s for %s: %w", head, sr, err)
236 0 }
237 42 return r, nil
238 }
239
240 // Open opens an existing space. Invalid names, a missing directory and a
241 // directory that is not a bare repository all yield ErrNotFound, so existence
242 // is never leaked and no crafted name escapes root: core's validators reject
243 // '/', '..' and a leading '-' before any path is built.
244 //
245 // The approved branch is read from HEAD rather than configured separately —
246 // there is exactly one place a space can record it, so there is nothing to keep
247 // in sync. A detached HEAD is corruption in a space repository and is refused.
248 10 func Open(root string, sr core.SpaceRef) (*Repo, error) {
249 10 if !filepath.IsAbs(root) {
250 1 return nil, fmt.Errorf("gitx: Open requires an absolute repos root, got %q", root)
251 1 }
252 9 if err := validateSpaceRef(sr); err != nil {
253 5 return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
254 5 }
255
256 4 dir := filepath.Clean(DiskPath(root, sr))
257 4 // Cheap bare-repo sanity check before the path reaches go-git.
258 4 if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
259 2 return nil, fmt.Errorf("%w: space %s", ErrNotFound, sr)
260 2 }
261 2 repo, err := git.PlainOpen(dir)
262 2 if err != nil {
263 0 return nil, fmt.Errorf("%w: space %s: %v", ErrNotFound, sr, err)
264 0 }
265
266 2 headRef, err := repo.Reference(plumbing.HEAD, false)
267 2 if err != nil {
268 0 return nil, fmt.Errorf("%w: space %s has no HEAD: %v", ErrNotFound, sr, err)
269 0 }
270 2 if headRef.Type() != plumbing.SymbolicReference {
271 0 return nil, fmt.Errorf("gitx: space %s has a detached HEAD; the approved branch is unknowable", sr)
272 0 }
273 2 branch := headRef.Target().Short()
274 2 if err := ValidateBranch(branch); err != nil {
275 0 return nil, fmt.Errorf("gitx: space %s HEAD points at an unusable branch: %w", sr, err)
276 0 }
277 2 return &Repo{dir: dir, ref: sr, repo: repo, approved: branch}, nil
278 }
279
280 // Dir returns the bare repository's directory.
281 8 func (r *Repo) Dir() string { return r.dir }
282
283 // SpaceRef returns the space this handle addresses.
284 0 func (r *Repo) SpaceRef() core.SpaceRef { return r.ref }
285
286 // ApprovedBranch returns the short name of the branch HEAD points at. A
287 // document is approved exactly when it is reachable from this branch.
288 38 func (r *Repo) ApprovedBranch() string { return r.approved }
289
290 // withTimeout derives a per-operation deadline. A caller-supplied deadline that
291 // is already earlier wins, since context.WithTimeout never extends.
292 303 func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
293 303 d := r.timeout
294 303 if d <= 0 {
295 303 d = defaultTimeout
296 303 }
297 303 return context.WithTimeout(ctx, d)
298 }
299
300 156 func (r *Repo) blobLimit() int64 {
301 156 if r.docLimit > 0 {
302 5 return r.docLimit
303 5 }
304 151 return maxDocumentSize
305 }
306
307 39 func (r *Repo) totalLimit() int64 {
308 39 if r.walkLimit > 0 {
309 1 return r.walkLimit
310 1 }
311 38 return maxWalkBytes
312 }
313
314 39 func (r *Repo) entryCap() int {
315 39 if r.entryLimit > 0 {
316 1 return r.entryLimit
317 1 }
318 38 return maxTreeEntries
319 }
320
321 47 func (r *Repo) casBudget() int {
322 47 if r.attemptLimit > 0 {
323 4 return r.attemptLimit
324 4 }
325 43 return casAttempts
326 }
327
328 // validateSpaceRef checks both halves of a space reference with core's rules.
329 58 func validateSpaceRef(sr core.SpaceRef) error {
330 58 if err := core.ValidateOwner(sr.Owner); err != nil {
331 4 return err
332 4 }
333 54 return core.ValidateSpaceName(sr.Name)
334 }
335
336 // Signature is a git identity plus the moment it acted.
337 //
338 // When is required rather than defaulted to time.Now: a commit whose timestamp
339 // this package invented would be a fact about the service pretending to be a
340 // fact about the author, and deterministic timestamps are what make provenance
341 // testable.
342 type Signature struct {
343 Name string
344 Email string
345 When time.Time
346 }
347
348 // validate rejects identities git cannot round-trip. Angle brackets and
349 // newlines would terminate or forge the ident line in the commit object, which
350 // is how a provenance record comes to say something nobody wrote.
351 374 func (s Signature) validate(role string) error {
352 374 if strings.TrimSpace(s.Name) == "" {
353 4 return fmt.Errorf("gitx: %s name is required", role)
354 4 }
355 370 if strings.TrimSpace(s.Email) == "" {
356 1 return fmt.Errorf("gitx: %s email is required", role)
357 1 }
358 369 if s.When.IsZero() {
359 1 return fmt.Errorf("gitx: %s timestamp is required", role)
360 1 }
361 734 for _, field := range []struct{ what, val string }{{"name", s.Name}, {"email", s.Email}} {
362 734 if strings.ContainsAny(field.val, "<>\n\r\x00") {
363 2 return fmt.Errorf("gitx: %s %s %q contains a disallowed character", role, field.what, field.val)
364 2 }
365 }
366 366 return nil
367 }
368
369 220 func (s Signature) toGit() object.Signature {
370 220 return object.Signature{Name: s.Name, Email: s.Email, When: s.When}
371 220 }