package main
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ingest"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/work"
"craftdesign.group/quarry/internal/xfer"
"craftdesign.group/quarry/internal/zitcli"
"errors"
)
// registrar owns a served repository's whole lifetime: its index handle,
// its ingest pipeline, its sync backend, and the goroutine polling it.
//
// This was the body of serve's startup loop. It is a type now because a
// repository can be registered while the server runs, and the loop's
// shape did not survive that: it relied on `defer ix.Close()` reaching
// serve's return, and on a poll goroutine that never stops. Neither is
// available to an admin call, and both are leaks if a repository is ever
// removed.
type registrar struct {
dir string // derived-data root
reposRoot string // where repositories created through the API are placed
node string // this node's id, for durable consumer names
interval time.Duration
ciCloneBase string
ev *events.Server
leases *lease.Manager
deps *api.Deps
fronter *xfer.Fronter
ingestLane *work.Lane
bulk *work.Lane
engine *ci.Engine
odb *oltp.DB
// codes is shared with the auth verifier's cache, so a project code
// learned here is the one the push gate checks.
codes map[string]string
codeMu *sync.Mutex
mu sync.Mutex
running map[string]*served
privMu sync.RWMutex
private map[string]bool
}
// served is what a registration leaves behind that must be released.
type served struct {
ix *index.Index
stop context.CancelFunc
done chan struct{} // closed when the poll goroutine has exited
}
func (rg *registrar) track(name string, s *served) {
rg.mu.Lock()
defer rg.mu.Unlock()
if rg.running == nil {
rg.running = map[string]*served{}
}
rg.running[name] = s
}
// Register opens a repository and starts serving it. Safe to call while
// the server is running.
func (rg *registrar) Register(name, path string) error {
// Startup binds the port only after every repository is registered,
// so each repository's cost is the whole forge's downtime. Timed so
// a slow one is nameable instead of merely suspected.
regStart := time.Now()
defer func() {
if d := time.Since(regStart); d > 500*time.Millisecond {
log.Printf("register %s: took %s", name, d.Round(time.Millisecond))
}
}()
if _, exists := rg.deps.Repos.Get(name); exists {
return fmt.Errorf("repository %q is already served: %w", name, ops.ErrInvalid)
}
if err := os.MkdirAll(filepath.Join(rg.dir, name), 0o755); err != nil {
return err
}
ix, err := index.Open(filepath.Join(rg.dir, name, "index.db"))
if err != nil {
return err
}
cli := zitcli.New(path)
// The lease keys on the store's project code, learned via the same
// boundary-clean handshake user-add uses. Doing it before anything
// is published means a path that is not a repository fails here,
// with the index closed again, rather than half-registering.
code, cerr := xfer.ProjectCode(path)
if cerr != nil {
ix.Close()
// A path that is not a repository is a bad REQUEST, not a server
// fault. Without this it surfaced as "internal error", which
// tells an operator to read the server log for what is in fact
// their own typo.
return fmt.Errorf("%w: %s is not a zit repository: %v", ops.ErrInvalid, path, cerr)
}
rg.codeMu.Lock()
rg.codes[name] = code
rg.codeMu.Unlock()
// Probed once here, not per render: the answer changes only when an
// S-bearing artifact first arrives. A probe that fails leaves it
// false, which withholds the fossil clone rather than offering one
// that might not work.
//
// BOUNDED, because this runs before the server binds its port and
// the cost is a function of the store's size, not a constant: the
// note here used to say ~250ms, measured on small stores. On tcl it
// is 116 SECONDS and on sqlite 16, so a fleet with either in it
// could not answer a request for minutes after starting — the whole
// forge held hostage by a detail about one clone URL. Past the
// deadline the probe is simply an unanswered question, which the
// false above already handles correctly.
fossilClone := false
probeCtx, cancelProbe := context.WithTimeout(context.Background(), 2*time.Second)
if seen, err := cli.SeenSubmanifest(probeCtx); err == nil {
fossilClone = !seen
} else if probeCtx.Err() != nil {
log.Printf("register %s: submanifest probe timed out; fossil clone withheld", name)
} else {
log.Printf("register %s: submanifest probe: %v", name, err)
}
cancelProbe()
indexing := new(atomic.Bool)
indexing.Store(true)
// Bumped after every ingest round so memoised answers that depend
// on the DAG — the leaf set below — recompute exactly when the DAG
// can have changed, and never otherwise.
// One warm pass per repository at a time. warmReads and indexTree
// each spawn SEVERAL zit subprocesses — ls, ls-under, a cat per
// file, an archive — and on a store tcl's size each takes seconds.
// Launched unguarded from every round that moved the tip, the next
// round started another set before the last had finished and they
// stacked: 43 zit children on the demo fleet, three of them the
// same ls against the same hash, with load average 546 on 14 cores.
// A warm pass already running IS the warm pass; a second one is
// duplicate work that makes the first one slower.
var warming, indexingTree atomic.Bool
ingestGen := new(atomic.Uint64)
entry := &api.Repo{
FossilClone: fossilClone,
Index: ix, Zit: cli, Code: code,
Indexing: indexing.Load,
Guard: func(fn func() error) error {
err := rg.leases.Guard(code, fn)
if errors.Is(err, lease.ErrNotHolder) || errors.Is(err, lease.ErrUnavailable) {
return fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
}
return err
},
}
// Leaf status is zit's answer, memoised per ingest: the set only
// changes when a check-in arrives, and the call is ~10ms warm even
// on the largest store because zit's own cache holds it.
var leafMu sync.Mutex
var leafSet map[string]bool
var leafErr error
var leafGen uint64
entry.Leaves = func() (map[string]bool, error) {
gen := ingestGen.Load()
leafMu.Lock()
defer leafMu.Unlock()
if leafSet != nil && leafGen == gen {
return leafSet, leafErr
}
leafSet, leafErr = cli.Leaves(context.Background())
leafGen = gen
return leafSet, leafErr
}
// The default branch is zit's resolution of "trunk" — fossil's
// main-branch setting and tag propagation live there, so a repo
// that renamed trunk to main answers "main". Memoised per ingest
// generation like the leaf set: it changes only when the DAG does.
var dbMu sync.Mutex
var dbName, dbTip string
var dbGen uint64
dbGen = ^uint64(0)
entry.DefaultBranch = func() (string, string) {
gen := ingestGen.Load()
dbMu.Lock()
defer dbMu.Unlock()
if dbGen == gen {
return dbName, dbTip
}
tip, name, err := cli.Info(context.Background(), "trunk")
if err != nil {
return "", "" // caller falls back to the index heuristic
}
dbName, dbTip, dbGen = name, tip, gen
return dbName, dbTip
}
entry.Mirror = func(url string) (bool, error) {
var changed bool
err := entry.Guard(func() error {
var perr error
changed, perr = cli.Pull(url)
return perr
})
return changed, err
}
pipe := ingest.NewPipeline(cli, rg.ev, ix, name, rg.node)
// In-process reindex: through the same lane as every other ingest,
// so a fleet-wide repair is staged at --ingest-limit stores at a
// time rather than a stampede; the indexing flag makes the fleet
// page say what is happening; the generation bump drops every memo
// derived from the shape being rebuilt.
entry.Reindex = func() (int, error) {
var n int
indexing.Store(true)
defer indexing.Store(false)
err := rg.ingestLane.Do(context.Background(), func() error {
var rerr error
n, rerr = pipe.Reindex()
return rerr
})
ingestGen.Add(1)
// Reset drops every derived table, and the doc rows are one of
// them. Without this a rebuild leaves documentation silently
// unsearchable until the next commit happens to move the tip —
// which on a mirror of a quiet project is never.
if err == nil {
if indexingTree.CompareAndSwap(false, true) {
go func() {
defer indexingTree.Store(false)
indexTree(cli, ix)
}()
}
}
return n, err
}
var warmOnce sync.Once
entry.Sync = func() (int, error) {
n, err := pipe.Sync()
// Only a round that actually ingested something can have moved
// the DAG, so only that invalidates the memos hanging off it.
// Bumping on every round made the poll interval the memo's
// lifetime: with a two-second poll, essentially every page load
// paid the cold path — 84ms against 9ms on fossil.
if n > 0 {
ingestGen.Add(1)
}
// Warm the reader's path before a reader pays for it: the memo
// caches are per-process and keyed on the tip, so both a restart
// and a new check-in leave the next files-page visitor holding
// four cold zit subprocesses — 5.6 seconds on a store tcl's
// size. After the round that moved the tip (and once at boot),
// the same reads run here instead, in the background.
if err == nil {
fresh := n > 0
warmOnce.Do(func() { fresh = true })
if fresh {
// CompareAndSwap claims the slot: false means nobody is
// warming, and the winner clears it on the way out.
if warming.CompareAndSwap(false, true) {
go func() {
defer warming.Store(false)
warmReads(cli, ix)
}()
}
if indexingTree.CompareAndSwap(false, true) {
go func() {
defer indexingTree.Store(false)
indexTree(cli, ix)
}()
}
}
}
return n, err
}
entry.Health = func() api.RepoHealth {
s := pipe.Stats()
h := api.RepoHealth{
RoundSeconds: s.LastRound.Seconds(),
LastCount: s.LastCount,
Total: s.Total,
Fallbacks: s.Fallbacks,
Gaps: s.Gaps,
Stalled: s.Stalled,
RetryAt: retryAt(s.RetryAt),
LastErr: s.LastErr,
Failures: s.Failures,
}
if !s.LastOK.IsZero() {
h.LastOK = s.LastOK.Format(time.RFC3339)
}
if !s.LastErrAt.IsZero() {
h.LastErrAt = s.LastErrAt.Format(time.RFC3339)
}
return h
}
rg.fronter.Register(name, path)
// Published complete or not at all.
rg.deps.Repos.Put(name, entry)
ctx, stop := context.WithCancel(context.Background())
done := make(chan struct{})
rg.track(name, &served{ix: ix, stop: stop, done: done})
go rg.poll(ctx, done, name, path, cli, ix, entry.Sync, indexing)
return nil
}
// poll runs the store's initial ingest and then its round loop until the
// context is cancelled.
// retryAt formats the stall's next attempt, empty when not stalled.
func retryAt(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// sync is entry.Sync — the round PLUS the generation bump that lets
// memoised DAG answers know they may be stale. The poll must not call
// the pipeline directly, or those memos would outlive the ingest that
// invalidated them.
func (rg *registrar) poll(ctx context.Context, done chan struct{}, name, path string,
cli *zitcli.Client, ix *index.Index, sync func() (int, error), indexing *atomic.Bool) {
defer close(done)
start := time.Now()
var n int
err := rg.ingestLane.Do(ctx, func() error {
var serr error
n, serr = sync()
return serr
})
indexing.Store(false)
if err != nil {
log.Printf("initial ingest of %s: %v", name, err)
} else {
log.Printf("ingested %d artifacts from %s (%s) in %s",
n, name, path, time.Since(start).Round(time.Second))
}
// warmedTip is the version whose listing has already been warmed,
// warmingTip whether a warm is in flight. Both belong to this poll
// loop: one repository, one warm at a time, and never twice for the
// same tip.
var warmingTip atomic.Bool
var warmedTip atomic.Value
warmedTip.Store("")
// time.Tick leaks its ticker; a Ticker can be stopped, which matters
// now that a repository can be unregistered.
tick := time.NewTicker(rg.interval)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
}
if err := rg.ingestLane.Do(ctx, func() error {
_, serr := sync()
return serr
}); err != nil {
if ctx.Err() != nil {
return
}
// The stall already announced itself once, with the cause;
// repeating it every poll interval buries the log line that
// matters under hundreds that do not.
if !errors.Is(err, ingest.ErrStalled) {
log.Printf("ingest %s: %v", name, err)
}
continue
}
// Warm the tip's listing so the repo home is never the request
// that pays for it: `ls --long` costs ~4ms per file, which is
// 8.5s on a 2,198-path tree. The listing is immutable per
// version, so it is computed once and reused until the tip moves.
//
// ONLY when the tip has actually moved, and only one at a time.
// This fired on EVERY poll tick as a bare goroutine whose result
// was discarded — so on a store where the call takes seconds the
// next tick started another before the last finished. Measured
// on the demo fleet: 134 concurrent `zit ls --long`, all against
// the SAME hash, load average 546 on 14 cores. A listing that is
// immutable per version needs computing once per version, and
// the memo behind it already knows that; what was missing was
// anyone asking whether the work was already in flight.
if b, ok, err := ix.TipOf("trunk"); err == nil && ok {
if warmedTip.Load() != b.Tip && warmingTip.CompareAndSwap(false, true) {
tip := b.Tip
go func() {
defer warmingTip.Store(false)
if _, err := cli.Ls(context.Background(), tip); err == nil {
warmedTip.Store(tip)
}
}()
}
}
// CI looks right after a fold, which is the moment a new
// check-in becomes visible to quarry whatever brought it in.
if rg.engine.Enabled() {
if err := ci.Trigger(rg.engine, ciStore{db: rg.odb}, name,
ciRepo{ix: ix, zit: cli}, rg.ciCloneBase+name); err != nil {
log.Printf("ci: %s: %v", name, err)
}
}
}
}
// Unregister stops serving a repository and releases what it held.
//
// Order matters: drop it from the registry FIRST so no new request can
// find it, then stop the poll loop and wait for it to exit, then close
// the index. Closing while a fold is mid-transaction is how a "removed"
// repository takes the process down with it.
func (rg *registrar) Unregister(name string) error {
if _, ok := rg.deps.Repos.Get(name); !ok {
return fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}
rg.deps.Repos.Delete(name)
rg.fronter.Unregister(name)
rg.mu.Lock()
s := rg.running[name]
delete(rg.running, name)
rg.mu.Unlock()
if s != nil {
s.stop()
<-s.done
s.ix.Close()
}
rg.codeMu.Lock()
delete(rg.codes, name)
rg.codeMu.Unlock()
return nil
}
// Add creates or adopts a repository, records it, and starts serving it.
//
// The order is deliberate: make the store, then persist, then serve. A
// recorded repository that failed to open is visible and fixable; one
// that serves without being recorded vanishes at the next restart and
// leaves no trace of why.
func (rg *registrar) Add(spec api.RepoSpec) (string, error) {
if rg.odb == nil {
return "", fmt.Errorf("no forge database: %w", ops.ErrUnavailable)
}
if _, exists := rg.deps.Repos.Get(spec.Name); exists {
return "", fmt.Errorf("repository %q is already served: %w", spec.Name, ops.ErrInvalid)
}
// The name is validated BEFORE it is joined to a path, because that
// is the only thing standing between a name and the filesystem.
if !oltp.RepoName.MatchString(spec.Name) {
return "", fmt.Errorf("%w: repository name %q: use letters, digits, dot, dash, underscore",
ops.ErrInvalid, spec.Name)
}
if spec.Path == "" {
if rg.reposRoot == "" {
return "", fmt.Errorf("%w: this node has no repositories root, so path is required "+
"(start it with --repos-root DIR)", ops.ErrInvalid)
}
if !spec.Create && spec.Clone == "" {
return "", fmt.Errorf("%w: path is required when adopting an existing store", ops.ErrInvalid)
}
spec.Path = filepath.Join(rg.reposRoot, spec.Name)
}
// Making a store is the one step that leaves something behind if a
// later one fails, so it is undone explicitly rather than left for
// an operator to find.
made := false
switch {
case spec.Create:
if err := rg.mustBeEmpty(spec.Path); err != nil {
return "", err
}
if err := zitcli.Init(context.Background(), spec.Path); err != nil {
return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
made = true
// Seeded BEFORE the repository is registered, so the ingest
// round that follows registration sees the first check-in
// already there rather than racing it.
if files := seedFiles(spec.Name, spec.Description, spec.License, spec.Ignore); spec.Readme || spec.License != "" || spec.Ignore {
if !spec.Readme {
delete(files, "README.md")
}
if err := zitcli.SeedFiles(context.Background(), spec.Path,
spec.Author, "Initial check-in", files); err != nil {
os.RemoveAll(spec.Path)
return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
}
case spec.Clone != "":
if err := rg.mustBeEmpty(spec.Path); err != nil {
return "", err
}
// A clone is minutes of network for a large repository, so it
// takes the bulk lane like any other long, memory-hungry job
// rather than running unbounded because an admin asked.
err := rg.bulk.Do(context.Background(), func() error {
return zitcli.Clone(context.Background(), spec.Clone, spec.Path)
})
if errors.Is(err, work.ErrBusy) {
return "", fmt.Errorf("too many clones in flight, retry shortly: %w", ops.ErrUnavailable)
}
if err != nil {
os.RemoveAll(spec.Path)
return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
made = true
}
undo := func() {
if made {
os.RemoveAll(spec.Path)
}
}
if err := rg.odb.AddRepo(spec.Name, spec.Path, spec.Description, spec.Visibility); err != nil {
undo()
return "", fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
if err := rg.Register(spec.Name, spec.Path); err != nil {
if derr := rg.odb.DeleteRepo(spec.Name); derr != nil {
log.Printf("repo.add %s: rollback failed: %v", spec.Name, derr)
}
undo()
return "", err
}
if spec.Mirror && spec.Clone != "" {
every := time.Duration(spec.MirrorEvery) * time.Second
if every == 0 {
every = 24 * time.Hour
}
// A failed mirror record is not worth unwinding a working
// repository over — it is one admin call to fix, and the
// repository is already serving.
if err := rg.odb.SetMirror(spec.Name, spec.Clone, every); err != nil {
log.Printf("repo.add %s: mirror not recorded: %v", spec.Name, err)
}
}
return spec.Path, nil
}
// mustBeEmpty refuses to create or clone over existing content. Both zit
// verbs create the directory themselves, so anything already there is
// somebody else's, and the failure mode of guessing wrong is destroying
// it on the rollback path.
func (rg *registrar) mustBeEmpty(path string) error {
if path == "" {
return fmt.Errorf("%w: empty path", ops.ErrInvalid)
}
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
if len(entries) > 0 {
return fmt.Errorf("%w: %s already exists and is not empty", ops.ErrInvalid, path)
}
return nil
}
// Remove stops serving a repository and forgets it. The store on disk
// and its derived index are left alone: "stop serving this" and
// "destroy this" are different intentions and only one is reversible.
func (rg *registrar) Remove(name string) error {
if err := rg.Unregister(name); err != nil {
return err
}
if rg.odb != nil {
if err := rg.odb.DeleteRepo(name); err != nil {
return err
}
}
return nil
}
// CloseAll releases every registration — serve's shutdown path.
func (rg *registrar) CloseAll() {
for _, name := range rg.deps.Repos.Names() {
if err := rg.Unregister(name); err != nil {
log.Printf("unregister %s: %v", name, err)
}
}
}
// accountStore adapts forge OLTP to the ops layer's account seam. The
// role strings cross as text because that is the API's shape; parsing
// them here keeps oltp.Role out of the op surface.
type accountStore struct{ db *oltp.DB }
func (a accountStore) Accounts() ([]api.Account, error) {
rows, err := a.db.Accounts()
if err != nil {
return nil, err
}
out := make([]api.Account, 0, len(rows))
for _, r := range rows {
out = append(out, api.Account{Name: r.Name, Admin: r.Admin, Created: r.Created})
}
return out, nil
}
func (a accountStore) AddAccount(name, password string, admin bool) error {
return a.db.AddAccount(name, password, admin)
}
func (a accountStore) SetPassword(name, password string) error {
return a.db.SetPassword(name, password)
}
func (a accountStore) DeleteAccount(name string) error { return a.db.DeleteAccount(name) }
func (a accountStore) Grant(account, repo, role string) error {
r, err := oltp.ParseRole(role)
if err != nil {
return err
}
return a.db.Grant(account, repo, r)
}
func (a accountStore) Revoke(account, repo string) error { return a.db.Revoke(account, repo) }
func (a accountStore) GrantsFor(account string) (map[string]string, error) {
grants, err := a.db.GrantsFor(account)
if err != nil {
return nil, err
}
out := make(map[string]string, len(grants))
for repo, role := range grants {
out[repo] = role.String()
}
// The "" key is the site-admin marker the dashboard reads ("a site
// admin's forge IS the fleet"). RoleOf already grants site admins
// RepoAdmin everywhere; without this map-shaped statement of the
// same fact, an admin with no explicit rows saw an empty dashboard.
if acct, found := a.db.AccountByName(account); found && acct.Admin {
out[""] = oltp.RepoAdmin.String()
}
return out, nil
}
// warmSem bounds concurrent warms fleet-wide: a restart syncs every
// store at once, and 23 parallel zit processes over the biggest repos
// is a memory spike with no reader waiting on it.
var warmSem = make(chan struct{}, 2)
// warmReads performs the reads a repository's files page performs, so
// the per-process memos (keyed on the immutable tip hash) are hot when
// a reader arrives. Failures are irrelevant — the reader's own request
// takes the cold path, exactly as if this had never run.
func warmReads(cli *zitcli.Client, ix *index.Index) {
if cli == nil || ix == nil {
return
}
warmSem <- struct{}{}
defer func() { <-warmSem }()
tip, ok, err := ix.TipOf(ix.DefaultBranch())
if err != nil || !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
files, err := cli.Ls(ctx, tip.Tip)
if err != nil {
return
}
cli.LsUnder(ctx, tip.Tip, "")
for _, f := range files {
base := strings.ToLower(path.Base(f.Name))
if strings.HasPrefix(base, "readme") || strings.HasPrefix(base, "license") ||
strings.HasPrefix(base, "copying") || f.Name == ".quarry/repo.json" {
cli.Cat(ctx, f.Name, tip.Tip)
}
}
}
// indexTree files the default branch tip's FILES into the search
// index: fossil's search scope "d" for documentation, plus source,
// which fossil does not search at all.
//
// quarry rendered both as first-class surfaces — the docs tab, the file
// browser — and could find neither. A reader could open
// www/quickstart.wiki and get nothing searching for it.
//
// One tarball, not a read per file. `cat` per path costs a subprocess
// each: 2,199 files on sqlite is ~31 seconds and 2,199 store opens,
// against 2.2 seconds for one archive. The tarball is the whole tree in
// one pass, which is exactly the shape of the question.
//
// Asynchronous, on the triggers a rebuild and a moved tip already
// carry. Not through dagnats: dagnats is optional — a forge without one
// "simply has no CI rather than a broken one" — and search must not
// inherit that. This is local derivation from the store, the same class
// of work as the fold, which Reindex already routes around the bus on
// purpose.
func indexTree(cli *zitcli.Client, ix *index.Index) {
if cli == nil || ix == nil {
return
}
warmSem <- struct{}{}
defer func() { <-warmSem }()
tip, ok, err := ix.TipOf(ix.DefaultBranch())
if err != nil || !ok {
return
}
// The tip has not moved since the last pass: the rows already
// describe it. This is what makes the boot trigger cheap on a
// restart that ingested nothing.
if at, verr := ix.TreeVersion(); verr == nil && at == tip.Tip {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
tmp, err := os.CreateTemp("", "quarry-tree-*.tar.gz")
if err != nil {
return
}
defer os.Remove(tmp.Name())
tmp.Close()
if err := cli.ArchiveTo(ctx, "tar", tip.Tip, "", tmp.Name(), treeArchiveMaxBytes); err != nil {
log.Printf("tree index: archive %.12s: %v", tip.Tip, err)
return
}
entries, skipped, err := readTreeArchive(tmp.Name())
if err != nil {
log.Printf("tree index: read %.12s: %v", tip.Tip, err)
return
}
if err := ix.ReplaceTree(tip.Tip, entries); err != nil {
log.Printf("tree index: %v", err)
return
}
var docs int
for _, e := range entries {
if e.Doc {
docs++
}
}
log.Printf("tree index: %d files (%d documentation) at %.12s, %d skipped",
len(entries), docs, tip.Tip, skipped)
}
// readTreeArchive turns the tip tarball into index rows, skipping what
// full-text search cannot use: binaries, and files too large to be
// prose or source anybody reads.
func readTreeArchive(path string) (entries []index.TreeText, skipped int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return nil, 0, err
}
defer gz.Close()
tr := tar.NewReader(gz)
buf := make([]byte, treeFileMaxBytes+1)
for {
h, rerr := tr.Next()
if rerr == io.EOF {
break
}
if rerr != nil {
return nil, skipped, rerr
}
if h.Typeflag != tar.TypeReg || h.Size > treeFileMaxBytes {
if h.Typeflag == tar.TypeReg {
skipped++
}
continue
}
// The archive names files under a top-level directory; the
// index wants the repository-relative path a link uses.
name := h.Name
if i := strings.IndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
if name == "" {
continue
}
n, rerr := io.ReadFull(tr, buf[:h.Size])
if rerr != nil && rerr != io.ErrUnexpectedEOF {
skipped++
continue
}
body := buf[:n]
if !utf8.Valid(body) || bytes.IndexByte(body, 0) >= 0 {
skipped++ // binary: a full-text index cannot use it
continue
}
entries = append(entries, index.TreeText{
Path: name, Body: string(body), Doc: api.IsDocPath(name),
})
}
return entries, skipped, nil
}
// treeFileMaxBytes bounds one file, and treeArchiveMaxBytes the whole
// tarball. Generous for prose and source, small against a vendored
// amalgamation or a checked-in binary.
const (
treeFileMaxBytes = 1 << 20
treeArchiveMaxBytes = 512 << 20
)
// Update applies a repository's settings.
func (rg *registrar) Update(name, description, visibility, homepage, topics string) error {
if rg.odb == nil {
return fmt.Errorf("no forge database: %w", ops.ErrUnavailable)
}
if err := rg.odb.SetRepoVisibility(name, visibility); err != nil {
return fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
if err := rg.odb.SetRepoDescription(name, description); err != nil {
return err
}
if err := rg.odb.SetRepoAbout(name, homepage, topics); err != nil {
return err
}
// Visibility is cached for the read gate, so the cache moves with
// the setting rather than a tick later. A window in which a
// repository is still readable after being taken private is the
// feature failing, not staleness.
rg.refreshPrivate()
return nil
}
// refreshPrivate reloads the private set the read gate consults.
//
// Cached because the gate runs on EVERY request and the answer changes
// only when an admin changes it. Refreshed explicitly on every write
// rather than expired on a timer: a window in which a repository is
// still readable after being taken private is not a staleness problem, it is
// the feature failing.
func (rg *registrar) refreshPrivate() {
if rg.odb == nil {
return
}
set, err := rg.odb.PrivateRepos()
if err != nil {
log.Printf("visibility: reload: %v", err)
return
}
rg.privMu.Lock()
rg.private = set
rg.privMu.Unlock()
}
// CanRead reports whether the identity in ctx may read repo.
//
// Public repositories are readable by anyone, which is what keeps an
// anonymous forge working. A private one requires a role on it — and a
// site admin holds every repository, so admins are covered by the same
// lookup rather than by a special case.
func (rg *registrar) CanRead(ctx context.Context, repo string) bool {
rg.privMu.RLock()
private := rg.private[repo]
rg.privMu.RUnlock()
if !private {
return true
}
user := ops.User(ctx)
if user == "" {
return false
}
if rg.odb == nil {
return false
}
acct, ok := rg.odb.AccountByName(user)
if !ok {
return false
}
// RFC 0004's rule for reading at all: any read letter in the
// effective set, categories included.
return rg.odb.EffectiveCaps(acct.Name, repo).HasAny("gjoh2r")
}
// identityCtx resolves a sync request's Basic credentials into the
// context the visibility gate reads.
//
// The sync protocol carries its own login card, but that is verified
// deeper in (and only for pushes). For the visibility question — may
// this caller see the repository at all — the HTTP credential is what
// is available before a single byte of the body is parsed, which is
// where the decision has to be made.
func identityCtx(req *http.Request, db *oltp.DB) context.Context {
ctx := req.Context()
user, pass, ok := req.BasicAuth()
if !ok || user == "" || db == nil {
return ctx
}
if _, authed := db.Authenticate(user, pass); !authed {
return ctx
}
return ops.WithUser(ctx, user)
}
// Delete stops serving a repository and destroys it: the store on disk,
// its derived index, and everything in the forge database keyed to it.
//
// confirm must equal the repository's name. That is the standard
// pattern because it is the only one that works: a yes/no prompt is
// answered reflexively, while typing the name requires having read
// which repository is about to go.
//
// The store on disk is removed ONLY when quarry created it — that is,
// when it lives under the repositories root. quarry serves stores it
// did not create and does not own, and the corpus this forge runs on is
// exactly that: deleting a repository named by --repo would destroy a
// checkout somebody else manages. Those refuse, and say to unregister
// instead.
func (rg *registrar) Delete(name, confirm string) error {
if rg.odb == nil {
return fmt.Errorf("no forge database: %w", ops.ErrUnavailable)
}
if confirm != name {
return fmt.Errorf("%w: type the repository's name to confirm deletion", ops.ErrInvalid)
}
row, found, err := rg.odb.RepoByName(name)
if err != nil {
return err
}
if !found {
return fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}
ours, err := rg.owns(row.Path)
if err != nil {
return err
}
if !ours {
return fmt.Errorf("%w: %s is outside this node's repositories root, so quarry did not "+
"create it and will not delete it — use remove to stop serving it", ops.ErrInvalid, row.Path)
}
// Stop serving before touching anything: Unregister waits for the
// poll loop to exit and closes the index, and removing files under a
// live fold is how a deletion takes the process with it.
if err := rg.Unregister(name); err != nil && !errors.Is(err, ops.ErrNotFound) {
return err
}
if err := rg.odb.PurgeRepo(name); err != nil {
return err
}
rg.refreshPrivate()
// Derived data first — it is rebuildable, so a failure here is
// recoverable, while a half-removed store is not.
if err := os.RemoveAll(filepath.Join(rg.dir, name)); err != nil {
log.Printf("delete %s: derived data: %v", name, err)
}
return os.RemoveAll(row.Path)
}
// owns reports whether a path lies under this node's repositories root.
func (rg *registrar) owns(path string) (bool, error) {
if rg.reposRoot == "" {
return false, nil
}
root, err := filepath.Abs(rg.reposRoot)
if err != nil {
return false, err
}
abs, err := filepath.Abs(path)
if err != nil {
return false, err
}
return strings.HasPrefix(abs, root+string(os.PathSeparator)), nil
}
// Deletable reports whether this node created the repository's store.
func (rg *registrar) Deletable(name string) bool {
if rg.odb == nil {
return false
}
row, found, err := rg.odb.RepoByName(name)
if err != nil || !found {
return false
}
ours, err := rg.owns(row.Path)
return err == nil && ours
}