| 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 |
} |