// Package gitx is spec.sr.ht's git layer: the bare-repo lifecycle of a space, // the only read path there is, the proposal write path, the tree-splice merge, // and the refs rule the receive hooks enforce. // // # One storage tier // // There is no checkout on disk. Every read resolves a tree and reads blobs, so // the approved head, a pinned ?rev= and a proposal branch are the same // code path with a different revision. Nothing downstream of this package ever // touches the filesystem to find a document. // // # No text merge, ever // // go-git v5 implements only FastForwardMerge, and the whole-document write // grain makes a three-way merge unnecessary anyway. Merge is pure plumbing: // object.Tree manipulation plus a commit with two explicit parents. A conflict // is always "your base moved, re-propose" (*StaleError), never a conflict // marker. Merge never calls Repository.Merge. // // # Staleness is keyed by document id, not path // // Paths move; ids do not. Every changed document is resolved to its path on the // approved head through its frontmatter id before its blob is compared, so a // rename between the base and the head neither invents a conflict nor // resurrects a document that was moved. // // # Two writers, one repo // // Human pushes arrive through native receive-pack (spawned by sshd) and agent // proposals through this package in-process: two independent ref-locking // implementations over the same loose refs. go-git's locking is not verified to // interoperate with native git's, so every write here takes a per-space mutex // (process-wide, keyed by the repository's directory) and every ref move is a // compare-and-swap that retries when it loses. // // # Bounded by construction // // Every operation derives a timeout from the caller's context, every blob read // is capped, and every tree walk is capped in both bytes and entries. Nothing // is truncated to fit: a partially read document would be indexed and served as // though it were whole, so an over-budget read fails with ErrTooLarge instead. package gitx import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "sourcecraft.dev/bigbes/sr-ht-spec/core" ) const ( // DefaultApprovedBranch is the branch a space is created with when the // caller does not name one. It is the branch the human pushes to and the // branch every default read resolves against; "approved" is a property of // being reachable from it, and of nothing else. DefaultApprovedBranch = "main" // defaultTimeout bounds a single gitx operation when the caller's context // carries no earlier deadline. defaultTimeout = 30 * time.Second // maxDocumentSize caps one document blob. Documents are markdown written // for a human to review; anything past this is either an attachment in the // wrong place or a runaway agent. maxDocumentSize = 4 << 20 // 4 MiB // maxWalkBytes caps the total blob bytes one tree walk may materialize, so // a single read cannot pull an entire space into memory. maxWalkBytes = 128 << 20 // 128 MiB // maxTreeEntries caps how many entries one walk may visit, bounding the // cost of a pathological tree independently of its byte size. maxTreeEntries = 100000 // maxTreeDepth caps directory nesting. Deeper than this is not a document // layout, it is a way to blow the stack. maxTreeDepth = 64 // casAttempts is how many times a ref compare-and-swap is retried after // losing to a concurrent writer before giving up with ErrRefRace. casAttempts = 5 // maxRevLen caps a revision string before it reaches go-git's parser. maxRevLen = 255 ) // Repo is a handle to one space: a bare git repository at // "/~/". // // Reads are safe for concurrent use. Writes serialize on a process-wide // per-space lock keyed by the repository directory, so two Repo values opened // over the same space still exclude each other. type Repo struct { dir string ref core.SpaceRef repo *git.Repository approved string // short branch name, read from HEAD // Test-only overrides; zero means "use the package constant". They exist so // truncation and retry paths can be exercised with small fixtures instead // of pathological ones. timeout time.Duration docLimit int64 walkLimit int64 entryLimit int attemptLimit int // beforeCAS, when set, runs immediately before each ref compare-and-swap. // It is the only way to open the window a concurrent native receive-pack // push would land in, which is the one thing about the retry path that // cannot be tested from the outside. beforeCAS func() } // raceHook fires the test-only pre-compare-and-swap hook. func (r *Repo) raceHook() { if r.beforeCAS != nil { r.beforeCAS() } } // DiskPath returns the bare repository directory for a space, // "/~/". The space reference must already be validated; // Create and Open do that before they build a path. func DiskPath(root string, sr core.SpaceRef) string { return filepath.Join(root, "~"+sr.Owner, sr.Name) } // CreateOptions configures Create. type CreateOptions struct { // ApprovedBranch names the branch the human pushes to. Empty means // DefaultApprovedBranch. ApprovedBranch string // Owner is the identity on the initial commit, in practice the instance's // [sr.ht] owner-name/owner-email. It is required: inventing a committer // would put a fabricated identity in a history whose whole purpose is // provenance. Owner Signature // Message is the initial commit's subject. Empty means a generated // "Initialize space ~owner/name". Message string } // Create initialises a new space: a bare repository at DiskPath(root, sr) whose // HEAD points at the approved branch, carrying one empty initial commit. // // The initial commit is deliberately not skipped. It makes the approved head // resolvable from the moment the space exists, so no reader, reconciler or // merge has to special-case an unborn branch, and it costs the human nothing: // they clone and push on top of it rather than pushing an unrelated history. // // root must be absolute — the per-space write lock is keyed by directory, and // two spellings of the same directory would be two locks. Create refuses to // touch an existing directory (ErrExists), and removes what it made if it fails // partway, so a failed create never leaves a half-built space behind. func Create(ctx context.Context, root string, sr core.SpaceRef, opts CreateOptions) (_ *Repo, err error) { if !filepath.IsAbs(root) { return nil, fmt.Errorf("gitx: Create requires an absolute repos root, got %q", root) } if err := validateSpaceRef(sr); err != nil { return nil, err } branch := opts.ApprovedBranch if branch == "" { branch = DefaultApprovedBranch } if err := ValidateBranch(branch); err != nil { return nil, err } if err := opts.Owner.validate("owner"); err != nil { return nil, err } message := opts.Message if message == "" { message = "Initialize space " + sr.String() } dir := filepath.Clean(DiskPath(root, sr)) if _, statErr := os.Stat(dir); statErr == nil { return nil, fmt.Errorf("%w: space %s at %q", ErrExists, sr, dir) } else if !os.IsNotExist(statErr) { return nil, fmt.Errorf("gitx: stat %q: %w", dir, statErr) } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("gitx: create space dir %q: %w", dir, err) } // Nothing past this point may leave a partially built repository behind. defer func() { if err != nil { os.RemoveAll(dir) } }() head := plumbing.NewBranchReferenceName(branch) repo, err := git.PlainInitWithOptions(dir, &git.PlainInitOptions{ Bare: true, InitOptions: git.InitOptions{DefaultBranch: head}, }) if err != nil { return nil, fmt.Errorf("gitx: init bare repo at %q: %w", dir, err) } r := &Repo{dir: dir, ref: sr, repo: repo, approved: branch} unlock, err := r.lock(ctx) if err != nil { return nil, err } defer unlock() emptyTree, err := (&mutableTree{}).write(repo.Storer) if err != nil { return nil, fmt.Errorf("gitx: write empty tree for %s: %w", sr, err) } commit, err := r.writeCommit(CommitMeta{ Message: message, Author: opts.Owner, Committer: opts.Owner, }, emptyTree, nil) if err != nil { return nil, err } if err := repo.Storer.SetReference(plumbing.NewHashReference(head, commit)); err != nil { return nil, fmt.Errorf("gitx: set %s for %s: %w", head, sr, err) } return r, nil } // Open opens an existing space. Invalid names, a missing directory and a // directory that is not a bare repository all yield ErrNotFound, so existence // is never leaked and no crafted name escapes root: core's validators reject // '/', '..' and a leading '-' before any path is built. // // The approved branch is read from HEAD rather than configured separately — // there is exactly one place a space can record it, so there is nothing to keep // in sync. A detached HEAD is corruption in a space repository and is refused. func Open(root string, sr core.SpaceRef) (*Repo, error) { if !filepath.IsAbs(root) { return nil, fmt.Errorf("gitx: Open requires an absolute repos root, got %q", root) } if err := validateSpaceRef(sr); err != nil { return nil, fmt.Errorf("%w: %v", ErrNotFound, err) } dir := filepath.Clean(DiskPath(root, sr)) // Cheap bare-repo sanity check before the path reaches go-git. if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() { return nil, fmt.Errorf("%w: space %s", ErrNotFound, sr) } repo, err := git.PlainOpen(dir) if err != nil { return nil, fmt.Errorf("%w: space %s: %v", ErrNotFound, sr, err) } headRef, err := repo.Reference(plumbing.HEAD, false) if err != nil { return nil, fmt.Errorf("%w: space %s has no HEAD: %v", ErrNotFound, sr, err) } if headRef.Type() != plumbing.SymbolicReference { return nil, fmt.Errorf("gitx: space %s has a detached HEAD; the approved branch is unknowable", sr) } branch := headRef.Target().Short() if err := ValidateBranch(branch); err != nil { return nil, fmt.Errorf("gitx: space %s HEAD points at an unusable branch: %w", sr, err) } return &Repo{dir: dir, ref: sr, repo: repo, approved: branch}, nil } // Dir returns the bare repository's directory. func (r *Repo) Dir() string { return r.dir } // SpaceRef returns the space this handle addresses. func (r *Repo) SpaceRef() core.SpaceRef { return r.ref } // ApprovedBranch returns the short name of the branch HEAD points at. A // document is approved exactly when it is reachable from this branch. func (r *Repo) ApprovedBranch() string { return r.approved } // withTimeout derives a per-operation deadline. A caller-supplied deadline that // is already earlier wins, since context.WithTimeout never extends. func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { d := r.timeout if d <= 0 { d = defaultTimeout } return context.WithTimeout(ctx, d) } func (r *Repo) blobLimit() int64 { if r.docLimit > 0 { return r.docLimit } return maxDocumentSize } func (r *Repo) totalLimit() int64 { if r.walkLimit > 0 { return r.walkLimit } return maxWalkBytes } func (r *Repo) entryCap() int { if r.entryLimit > 0 { return r.entryLimit } return maxTreeEntries } func (r *Repo) casBudget() int { if r.attemptLimit > 0 { return r.attemptLimit } return casAttempts } // validateSpaceRef checks both halves of a space reference with core's rules. func validateSpaceRef(sr core.SpaceRef) error { if err := core.ValidateOwner(sr.Owner); err != nil { return err } return core.ValidateSpaceName(sr.Name) } // Signature is a git identity plus the moment it acted. // // When is required rather than defaulted to time.Now: a commit whose timestamp // this package invented would be a fact about the service pretending to be a // fact about the author, and deterministic timestamps are what make provenance // testable. type Signature struct { Name string Email string When time.Time } // validate rejects identities git cannot round-trip. Angle brackets and // newlines would terminate or forge the ident line in the commit object, which // is how a provenance record comes to say something nobody wrote. func (s Signature) validate(role string) error { if strings.TrimSpace(s.Name) == "" { return fmt.Errorf("gitx: %s name is required", role) } if strings.TrimSpace(s.Email) == "" { return fmt.Errorf("gitx: %s email is required", role) } if s.When.IsZero() { return fmt.Errorf("gitx: %s timestamp is required", role) } for _, field := range []struct{ what, val string }{{"name", s.Name}, {"email", s.Email}} { if strings.ContainsAny(field.val, "<>\n\r\x00") { return fmt.Errorf("gitx: %s %s %q contains a disallowed character", role, field.what, field.val) } } return nil } func (s Signature) toGit() object.Signature { return object.Signature{Name: s.Name, Email: s.Email, When: s.When} }