From 33a3deafbd02e8f74b9cd588d48867e0c9f77a69 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 22 Jul 2026 10:58:57 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20foundation=20=E2=80=94=20go.mod=20with?= =?UTF-8?q?=20every=20dependency,=20and=20the=20core/=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 foundation commit. Two things, so that later parallel waves write disjoint directories and never touch go.mod: - go.mod / go.sum carrying every external dependency the whole module will need (go-git, bleve, goldmark, chi, lib/pq, yaml.v3, the MCP SDK, brant, auxilia, testify, and the sr-ht-core fork). Populated by building a throwaway blank-import file, which is then deleted; `go mod tidy` runs once, at the very end of the build-out. - core/, the pure domain: owner/space names, safe relative paths, the globally-unique document ID grammar, frontmatter parsing and schema validation, `.spec.yml` policy with auto_merge glob matching, and the proposal state machine. Standard library plus yaml.v3, nothing else. Two design invariants are enforced here rather than documented and hoped for: "approved" is not a status (it is a property of the branch a document is reachable from), and the proposal machine has exactly open/merged/rejected. A per-space `.spec.yml` cannot reintroduce either. Note on the sr-ht-core pin: the design calls for a `replace` onto git.srht.bigb.es/~bigbes/core-go at c2c2f38, but that commit's go.mod still declares `module git.sr.ht/~sircmpwn/core-go`, so Go rejects the replacement. Both siblings pin the later dd418a20 under the canonical path with no replace; this does the same. --- .gitignore | 17 ++ Makefile | 79 ++++++++ core/errors.go | 69 +++++++ core/frontmatter.go | 276 ++++++++++++++++++++++++++ core/frontmatter_test.go | 405 +++++++++++++++++++++++++++++++++++++++ core/id.go | 101 ++++++++++ core/id_test.go | 101 ++++++++++ core/names.go | 220 +++++++++++++++++++++ core/names_test.go | 216 +++++++++++++++++++++ core/policy.go | 240 +++++++++++++++++++++++ core/policy_test.go | 321 +++++++++++++++++++++++++++++++ core/proposal.go | 93 +++++++++ core/proposal_test.go | 147 ++++++++++++++ go.mod | 107 +++++++++++ go.sum | 358 ++++++++++++++++++++++++++++++++++ 15 files changed, 2750 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 core/errors.go create mode 100644 core/frontmatter.go create mode 100644 core/frontmatter_test.go create mode 100644 core/id.go create mode 100644 core/id_test.go create mode 100644 core/names.go create mode 100644 core/names_test.go create mode 100644 core/policy.go create mode 100644 core/policy_test.go create mode 100644 core/proposal.go create mode 100644 core/proposal_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5c8c7bb478218c0cd6c9fe903bab0f95947ddc3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Compiled service binaries +/specsrht +/specsrht-migrate + +# Transient build artifacts +*.tmp + +# Local instance config +/config.ini + +# Local bare repos and the bleve index + render cache. Both are the service's +# runtime state, not source; the cache is safe to delete at any time. +/repos/ +/cache/ + +# Intermediate stylesheet; only the content-hashed main.min..css ships. +/web/static/main.css diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..cb090c88c8b65c0a47c73fb2622b410df4839b67 --- /dev/null +++ b/Makefile @@ -0,0 +1,79 @@ +# spec.sr.ht — build scaffolding (compare.sr.ht / sourcehut-dolt style). +# +# One Go binary plus a brant migration wrapper, both under cmd/. The CSS is +# built from the shared sourcehut scss partials with sassc; there is no +# frontend bundle — the prose differ renders server-side. + +SERVICE=spec.sr.ht +BIN=specsrht +MIGRATE_BIN=specsrht-migrate + +PREFIX?=/usr/local +BINDIR?=$(PREFIX)/bin +SHAREDIR?=$(PREFIX)/share +ASSETS?=/usr/share/sourcehut +STATICDIR?=$(ASSETS)/$(SERVICE)/static +MIGRATIONDIR?=$(SHAREDIR)/sourcehut/migrations/$(SERVICE) + +SASSC?=sassc +SASSC_INCLUDE=-I$(ASSETS)/scss + +all: build + +# Compile the binaries. cmd/ lands in Phase 1's Wave B, so until then each +# target skips rather than failing — a red default target during the build-out +# trains everyone to ignore it. +build: $(BIN) $(MIGRATE_BIN) + +$(BIN): + @if [ -d ./cmd/$(BIN) ]; then \ + echo "go build -o $@ ./cmd/$(BIN)"; \ + go build -o $@ ./cmd/$(BIN); \ + else \ + echo "skip $@: ./cmd/$(BIN) not present yet"; \ + fi + +$(MIGRATE_BIN): + @if [ -d ./cmd/$(MIGRATE_BIN) ]; then \ + echo "go build -o $@ ./cmd/$(MIGRATE_BIN)"; \ + go build -o $@ ./cmd/$(MIGRATE_BIN); \ + else \ + echo "skip $@: ./cmd/$(MIGRATE_BIN) not present yet"; \ + fi + +test: + go test ./... + +# CSS pipeline: sassc -> minify -> content-hashed filename. The running service +# globs web/static/main.min.*.css at startup, so the hash in the name is the +# cache-busting version. Requires the shared scss partials installed at +# $(ASSETS)/scss (core.sr.ht `make install`) and scss/main.scss to exist. +# Produces exactly ONE web/static/main.min..css; old ones and the +# intermediate main.css are removed. +css: + mkdir -p web/static + rm -f web/static/main.css web/static/main.min.*.css + $(SASSC) $(SASSC_INCLUDE) scss/main.scss web/static/main.css + minify -o web/static/main.min.css web/static/main.css + mv web/static/main.min.css \ + web/static/main.min.$$(sha256sum web/static/main.min.css | cut -c1-8).css + rm -f web/static/main.css + +# Local development run. Requires a ./config.ini in the working directory (or +# ../config.ini, /etc/sr.ht/config.ini) carrying the instance's shared +# [sr.ht]/[webhooks] keys plus a [spec.sr.ht] section. +run-dev: build + ./$(BIN) -b localhost:5091 + +install: build + install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN) + install -Dm755 $(MIGRATE_BIN) $(DESTDIR)$(BINDIR)/$(MIGRATE_BIN) + mkdir -p $(DESTDIR)$(STATICDIR) $(DESTDIR)$(MIGRATIONDIR) + install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/* + install -Dm644 -t $(DESTDIR)$(MIGRATIONDIR) migrations/*.sql + +clean: + rm -f $(BIN) $(MIGRATE_BIN) + rm -f web/static/main.css web/static/main.min.*.css + +.PHONY: all build test css run-dev install clean $(BIN) $(MIGRATE_BIN) diff --git a/core/errors.go b/core/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..526706b28474904904a0b3f2d783191895b76bda --- /dev/null +++ b/core/errors.go @@ -0,0 +1,69 @@ +// Package core holds the pure domain logic of spec.sr.ht: owner and space name +// validation, safe document paths, the globally-unique document ID grammar, the +// frontmatter schema contract, `.spec.yml` space policy, and the proposal state +// machine. +// +// It depends on nothing but the standard library and gopkg.in/yaml.v3, never +// touches the network or the filesystem, and knows nothing about git, Postgres +// or HTTP. Dependency direction is strictly downward: gitx, db, service, api and +// web import core; core imports none of them. That is what keeps the three +// agent-facing surfaces (REST, MCP, GraphQL) behaviourally identical — they +// share these rules rather than each re-deriving them. +package core + +import "errors" + +// Sentinel errors, one per failure class. Callers compare with errors.Is; +// wrapping with %w adds the offending value without losing the class, which is +// what lets the API layer map a failure to a status code (422 for a schema +// violation, 400 for a malformed name) without string matching. +var ( + // ErrInvalidName is returned for a malformed owner or space name. + ErrInvalidName = errors.New("invalid name") + + // ErrInvalidPath is returned for a path that is not a safe relative path + // inside a space: absolute, traversing, or carrying bytes that would be + // unsafe in a git tree or misleading in the review UI. + ErrInvalidPath = errors.New("invalid path") + + // ErrInvalidDocID is returned for a document ID that does not match the + // PREFIX-DIGITS grammar. IDs are globally unique and are the anchor for + // cross-space links, comments and staleness checks, so shape is enforced + // at every door. + ErrInvalidDocID = errors.New("invalid document id") + + // ErrMalformedFrontmatter marks a document whose YAML frontmatter block is + // missing, unterminated, not a mapping, or not parseable. It is distinct + // from ErrMissingField: this is "cannot read", not "read but incomplete". + ErrMalformedFrontmatter = errors.New("malformed frontmatter") + + // ErrMissingField marks frontmatter that parsed but omits a key the space's + // schema requires. This is the 422 an agent gets for forgetting `status:`. + ErrMissingField = errors.New("missing required frontmatter field") + + // ErrInvalidStatus is returned for a `status:` value outside the allowed + // enum. Note "approved" is deliberately not a status — approval is a + // property of the branch a document is reachable from, never of authored + // metadata. + ErrInvalidStatus = errors.New("invalid status") + + // ErrInvalidPolicy is returned for a `.spec.yml` that parses as YAML but + // does not describe a usable policy. + ErrInvalidPolicy = errors.New("invalid .spec.yml policy") + + // ErrInvalidPattern is returned for a malformed auto_merge path pattern. + // A pattern that silently matches nothing would quietly turn the bimodal + // cadence back into "everything waits for a human", so it is rejected. + ErrInvalidPattern = errors.New("invalid path pattern") + + // ErrInvalidState is returned for a proposal state string outside the + // open/merged/rejected set, typically read back from Postgres. + ErrInvalidState = errors.New("invalid proposal state") + + // ErrInvalidTransition is returned for a proposal state change the machine + // does not allow, such as re-merging an already rejected proposal. + ErrInvalidTransition = errors.New("invalid proposal state transition") + + // ErrInvalidApproval is returned for an approval kind outside human/policy. + ErrInvalidApproval = errors.New("invalid approval kind") +) diff --git a/core/frontmatter.go b/core/frontmatter.go new file mode 100644 index 0000000000000000000000000000000000000000..cb32eeb55f7cdf4f4d7c77cadb9e4eed8513f7ee --- /dev/null +++ b/core/frontmatter.go @@ -0,0 +1,276 @@ +package core + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +// Status is the authored lifecycle marker of a document. +// +// The enum is exactly draft/review/superseded. "approved" is deliberately not a +// status: a document is approved when it is reachable from the approved ref, +// and that is the whole definition. Had "approved" stayed here, either the +// approved branch would permanently carry `status: draft`, or the merge would +// have to rewrite frontmatter nobody authored — which would also invalidate the +// agent's If-Match base on its next read. +type Status string + +const ( + StatusDraft Status = "draft" + StatusReview Status = "review" + StatusSuperseded Status = "superseded" +) + +// DefaultStatuses returns the full status enum, in the order a space's +// `.spec.yml` would list it. A fresh slice each call, so a caller cannot mutate +// the default out from under everyone else. +func DefaultStatuses() []Status { + return []Status{StatusDraft, StatusReview, StatusSuperseded} +} + +// ParseStatus validates a status string against the enum. +func ParseStatus(s string) (Status, error) { + switch Status(s) { + case StatusDraft, StatusReview, StatusSuperseded: + return Status(s), nil + } + return "", fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, s, joinStatuses(DefaultStatuses())) +} + +func joinStatuses(ss []Status) string { + parts := make([]string, len(ss)) + for i, s := range ss { + parts[i] = string(s) + } + return strings.Join(parts, "|") +} + +// Frontmatter is the YAML header of a document. The modelled fields are the +// ones the service reasons about; anything else an author writes is preserved +// in git and reported by Present, but is not interpreted here. +type Frontmatter struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Status Status `yaml:"status"` + Supersedes string `yaml:"supersedes"` + Owners []string `yaml:"owners"` + Tags []string `yaml:"tags"` + Type string `yaml:"type"` + Summary string `yaml:"summary"` + + // Present is the set of top-level keys that actually appeared in the + // source. Required-key validation asks about presence, not emptiness: + // `title:` with no value is present and fails for being blank, whereas an + // absent `title:` is a different error with a different fix. A zero-valued + // struct field cannot tell those apart. + Present map[string]bool `yaml:"-"` +} + +// Has reports whether key appeared in the parsed source. Nil-safe, so a +// hand-constructed Frontmatter reports every key as absent rather than panicking. +func (f Frontmatter) Has(key string) bool { return f.Present[key] } + +const ( + frontDelim = "---" + // docEndDelim is YAML's explicit end-of-document marker. Editors emit it + // occasionally, and treating it as a closing fence costs one comparison. + docEndDelim = "..." +) + +// SplitFrontmatter separates the YAML frontmatter block from the markdown body. +// The document must open with a "---" line and close the block with a "---" +// (or "...") line; both delimiters are excluded from the returned slices. +// +// It is strict on purpose. A document whose frontmatter is unterminated would +// otherwise parse as a body-only document with no ID, silently dropping out of +// the registry and the index instead of failing at the door. +func SplitFrontmatter(src []byte) (front, body []byte, err error) { + s := string(src) + if strings.HasPrefix(s, "\ufeff") { + return nil, nil, fmt.Errorf("%w: document starts with a UTF-8 BOM before the %q delimiter", + ErrMalformedFrontmatter, frontDelim) + } + + first, rest, hasMore := strings.Cut(s, "\n") + if strings.TrimSuffix(first, "\r") != frontDelim { + return nil, nil, fmt.Errorf("%w: document does not start with a %q delimiter", + ErrMalformedFrontmatter, frontDelim) + } + if !hasMore { + return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q", + ErrMalformedFrontmatter, frontDelim) + } + + for pos := 0; pos <= len(rest); { + line, tail, ok := strings.Cut(rest[pos:], "\n") + switch strings.TrimSuffix(line, "\r") { + case frontDelim, docEndDelim: + return []byte(rest[:pos]), []byte(tail), nil + } + if !ok { + break + } + pos = len(rest) - len(tail) + } + return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q", + ErrMalformedFrontmatter, frontDelim) +} + +// ParseFrontmatter decodes a frontmatter block into a Frontmatter, recording +// which keys were present. +// +// Duplicate keys are rejected explicitly rather than last-one-wins: a document +// carrying two `id:` lines is exactly the typo that corrupts the global ID +// registry, and it is far cheaper to reject at the door than to find weeks later. +func ParseFrontmatter(front []byte) (Frontmatter, error) { + var node yaml.Node + if err := yaml.Unmarshal(front, &node); err != nil { + return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err) + } + if node.Kind == yaml.DocumentNode { + if len(node.Content) != 1 { + return Frontmatter{}, fmt.Errorf("%w: expected exactly one YAML document, got %d", + ErrMalformedFrontmatter, len(node.Content)) + } + node = *node.Content[0] + } + + // An empty block is well-formed YAML and yields a zero node. It is not a + // parse failure; it fails schema validation instead, which names the + // missing keys and is the more useful error. + if node.Kind == 0 { + return Frontmatter{Present: map[string]bool{}}, nil + } + if node.Kind != yaml.MappingNode { + return Frontmatter{}, fmt.Errorf("%w: frontmatter must be a YAML mapping", ErrMalformedFrontmatter) + } + + present := make(map[string]bool, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + if key.Kind != yaml.ScalarNode { + return Frontmatter{}, fmt.Errorf("%w: non-scalar key at line %d", ErrMalformedFrontmatter, key.Line) + } + if present[key.Value] { + return Frontmatter{}, fmt.Errorf("%w: duplicate key %q at line %d", + ErrMalformedFrontmatter, key.Value, key.Line) + } + present[key.Value] = true + } + + var fm Frontmatter + if err := node.Decode(&fm); err != nil { + return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err) + } + fm.Present = present + return fm, nil +} + +// ParseDocument splits and parses a whole document, returning its frontmatter +// and its markdown body. +func ParseDocument(src []byte) (Frontmatter, []byte, error) { + front, body, err := SplitFrontmatter(src) + if err != nil { + return Frontmatter{}, nil, err + } + fm, err := ParseFrontmatter(front) + if err != nil { + return Frontmatter{}, nil, err + } + return fm, body, nil +} + +// Schema is a space's frontmatter contract, as carried by `.spec.yml`. It is +// enforced at propose time and at push time — schema validation at the door is +// the cheapest available defence against agent slop. +type Schema struct { + Required []string `yaml:"required"` + Status []Status `yaml:"status"` +} + +// DefaultSchema is the contract a space gets when its `.spec.yml` says nothing. +func DefaultSchema() Schema { + return Schema{ + Required: []string{"id", "title", "status"}, + Status: DefaultStatuses(), + } +} + +// Validate reports whether the schema itself is usable. The status list is the +// interesting half: a space may narrow the enum (say, forbid `review`) but may +// not invent a member, which is what keeps "approved" from creeping back in via +// a per-space config file. +func (s Schema) Validate() error { + seenKey := make(map[string]bool, len(s.Required)) + for _, key := range s.Required { + if key == "" { + return fmt.Errorf("%w: schema.required contains an empty key", ErrInvalidPolicy) + } + if seenKey[key] { + return fmt.Errorf("%w: schema.required lists %q twice", ErrInvalidPolicy, key) + } + seenKey[key] = true + } + + if len(s.Status) == 0 { + return fmt.Errorf("%w: schema.status must list at least one status", ErrInvalidPolicy) + } + seenStatus := make(map[Status]bool, len(s.Status)) + for _, st := range s.Status { + if _, err := ParseStatus(string(st)); err != nil { + return fmt.Errorf("%w: schema.status: %v", ErrInvalidPolicy, err) + } + if seenStatus[st] { + return fmt.Errorf("%w: schema.status lists %q twice", ErrInvalidPolicy, st) + } + seenStatus[st] = true + } + return nil +} + +// AllowsStatus reports whether st is permitted by this schema. +func (s Schema) AllowsStatus(st Status) bool { + for _, allowed := range s.Status { + if st == allowed { + return true + } + } + return false +} + +// ValidateFrontmatter checks a parsed document header against the schema. It +// answers "may this document be proposed into this space", and every write path +// (REST, MCP, and the update hook on your own pushes) runs it. +func (s Schema) ValidateFrontmatter(fm Frontmatter) error { + for _, key := range s.Required { + if !fm.Has(key) { + return fmt.Errorf("%w: %q", ErrMissingField, key) + } + } + if fm.Has("id") { + if err := ValidateDocID(fm.ID); err != nil { + return err + } + } + if fm.Has("title") && strings.TrimSpace(fm.Title) == "" { + return fmt.Errorf("%w: %q is present but blank", ErrMissingField, "title") + } + if fm.Has("status") && !s.AllowsStatus(fm.Status) { + return fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, fm.Status, joinStatuses(s.Status)) + } + // `supersedes:` with no value is a half-finished edit, not an absent key, + // so it is rejected rather than ignored. + if fm.Has("supersedes") { + if err := ValidateDocID(fm.Supersedes); err != nil { + return fmt.Errorf("supersedes: %w", err) + } + } + for _, owner := range fm.Owners { + if err := ValidateOwner(strings.TrimPrefix(owner, "~")); err != nil { + return fmt.Errorf("owners: %w", err) + } + } + return nil +} diff --git a/core/frontmatter_test.go b/core/frontmatter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b6444665db3765e67283388573b59f33e5b7d0ce --- /dev/null +++ b/core/frontmatter_test.go @@ -0,0 +1,405 @@ +package core + +import ( + "errors" + "strings" + "testing" +) + +func TestParseStatus(t *testing.T) { + tests := []struct { + in string + ok bool + }{ + {"draft", true}, + {"review", true}, + {"superseded", true}, + + // "approved" is a property of the branch, never of the frontmatter. + // This case is the whole reason the enum is validated at all. + {"approved", false}, + {"Approved", false}, + {"APPROVED", false}, + {"merged", false}, + {"Draft", false}, + {"draft ", false}, + {" draft", false}, + {"", false}, + {"черновик", false}, + } + for _, tc := range tests { + st, err := ParseStatus(tc.in) + if (err == nil) != tc.ok { + t.Errorf("ParseStatus(%q) = %q, %v, want ok=%v", tc.in, st, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidStatus) { + t.Errorf("ParseStatus(%q) error %v is not ErrInvalidStatus", tc.in, err) + } + } +} + +func TestSplitFrontmatter(t *testing.T) { + tests := []struct { + name string + in string + wantFront string + wantBody string + ok bool + }{ + {"minimal", "---\nid: SPEC-1\n---\n# Title\n", "id: SPEC-1\n", "# Title\n", true}, + {"empty block", "---\n---\n", "", "", true}, + {"empty body", "---\nid: SPEC-1\n---\n", "id: SPEC-1\n", "", true}, + {"no trailing newline after fence", "---\nid: SPEC-1\n---", "id: SPEC-1\n", "", true}, + {"crlf", "---\r\nid: SPEC-1\r\n---\r\nbody\r\n", "id: SPEC-1\r\n", "body\r\n", true}, + {"dot fence", "---\nid: SPEC-1\n...\nbody\n", "id: SPEC-1\n", "body\n", true}, + {"hr in body is not a fence", "---\nid: SPEC-1\n---\ntext\n\n---\n\nmore\n", "id: SPEC-1\n", "text\n\n---\n\nmore\n", true}, + + {"empty document", "", "", "", false}, + {"no frontmatter", "# Just markdown\n", "", "", false}, + {"leading blank line", "\n---\nid: SPEC-1\n---\n", "", "", false}, + {"fence only", "---", "", "", false}, + {"unterminated", "---\nid: SPEC-1\nbody\n", "", "", false}, + {"indented fence", " ---\nid: SPEC-1\n---\n", "", "", false}, + {"four dashes", "----\nid: SPEC-1\n----\n", "", "", false}, + {"utf-8 bom", "\ufeff---\nid: SPEC-1\n---\n", "", "", false}, + // A fence with trailing whitespace is not a fence; treating it as one + // would make an unterminated block parse as a body-only document. + {"fence with trailing space", "--- \nid: SPEC-1\n---\n", "", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + front, body, err := SplitFrontmatter([]byte(tc.in)) + if (err == nil) != tc.ok { + t.Fatalf("SplitFrontmatter(%q) = %q, %q, %v, want ok=%v", tc.in, front, body, err, tc.ok) + } + if !tc.ok { + if !errors.Is(err, ErrMalformedFrontmatter) { + t.Fatalf("error %v is not ErrMalformedFrontmatter", err) + } + return + } + if string(front) != tc.wantFront { + t.Errorf("front = %q, want %q", front, tc.wantFront) + } + if string(body) != tc.wantBody { + t.Errorf("body = %q, want %q", body, tc.wantBody) + } + }) + } +} + +func TestParseFrontmatter(t *testing.T) { + full := "id: SPEC-0007\n" + + "title: Proposal storage model\n" + + "status: draft\n" + + "supersedes: SPEC-0003\n" + + "owners: [~bigbes]\n" + + "tags: [storage, review]\n" + + "type: spec\n" + + "summary: how proposals are stored\n" + + fm, err := ParseFrontmatter([]byte(full)) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if fm.ID != "SPEC-0007" || fm.Title != "Proposal storage model" || fm.Status != StatusDraft { + t.Fatalf("unexpected frontmatter: %+v", fm) + } + if fm.Supersedes != "SPEC-0003" || fm.Type != "spec" || fm.Summary != "how proposals are stored" { + t.Fatalf("unexpected frontmatter: %+v", fm) + } + if len(fm.Owners) != 1 || fm.Owners[0] != "~bigbes" { + t.Fatalf("owners = %v", fm.Owners) + } + if len(fm.Tags) != 2 || fm.Tags[0] != "storage" { + t.Fatalf("tags = %v", fm.Tags) + } + for _, key := range []string{"id", "title", "status", "supersedes", "owners", "tags", "type", "summary"} { + if !fm.Has(key) { + t.Errorf("Has(%q) = false, want true", key) + } + } + if fm.Has("approved") { + t.Error(`Has("approved") = true for a key that is not in the source`) + } +} + +func TestParseFrontmatterPresenceVsEmptiness(t *testing.T) { + // `title:` with no value is present-but-blank, which is a different error + // from an absent title. The distinction is the reason Present exists. + fm, err := ParseFrontmatter([]byte("id: SPEC-1\ntitle:\n")) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !fm.Has("title") { + t.Fatal(`Has("title") = false for a key that is present with a null value`) + } + if fm.Title != "" { + t.Fatalf("Title = %q, want empty", fm.Title) + } + if fm.Has("status") { + t.Fatal(`Has("status") = true for an absent key`) + } +} + +func TestParseFrontmatterUnknownKeysArePreserved(t *testing.T) { + // Unknown keys are authored metadata we do not model; they must not fail + // the parse, and they must be reportable so a schema can require them. + fm, err := ParseFrontmatter([]byte("id: SPEC-1\nreviewers: [~someone]\n")) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !fm.Has("reviewers") { + t.Fatal(`Has("reviewers") = false, want true`) + } +} + +func TestParseFrontmatterErrors(t *testing.T) { + tests := []struct { + name string + in string + ok bool + }{ + {"empty block", "", true}, + {"whitespace only", "\n \n", true}, + {"comment only", "# nothing here\n", true}, + + {"duplicate id", "id: SPEC-1\nid: SPEC-2\n", false}, + {"duplicate title far apart", "id: SPEC-1\ntitle: a\nstatus: draft\ntitle: b\n", false}, + {"sequence not mapping", "- id: SPEC-1\n", false}, + {"scalar not mapping", "just a string\n", false}, + {"tab indentation", "id: SPEC-1\n\ttitle: x\n", false}, + {"unclosed flow seq", "tags: [a, b\n", false}, + {"owners as scalar", "owners: bigbes\n", false}, + {"tags as mapping", "tags: {a: 1}\n", false}, + {"complex key", "? [a, b]\n: value\n", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fm, err := ParseFrontmatter([]byte(tc.in)) + if (err == nil) != tc.ok { + t.Fatalf("ParseFrontmatter(%q) = %+v, %v, want ok=%v", tc.in, fm, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrMalformedFrontmatter) { + t.Fatalf("error %v is not ErrMalformedFrontmatter", err) + } + if err == nil && fm.Present == nil { + t.Fatal("Present must never be nil on a successful parse") + } + }) + } +} + +func TestParseDocument(t *testing.T) { + src := "---\nid: SPEC-0007\ntitle: T\nstatus: draft\n---\n\n# Heading\n\nbody\n" + fm, body, err := ParseDocument([]byte(src)) + if err != nil { + t.Fatalf("ParseDocument: %v", err) + } + if fm.ID != "SPEC-0007" || fm.Status != StatusDraft { + t.Fatalf("frontmatter = %+v", fm) + } + if string(body) != "\n# Heading\n\nbody\n" { + t.Fatalf("body = %q", body) + } + + if _, _, err := ParseDocument([]byte("# no frontmatter\n")); !errors.Is(err, ErrMalformedFrontmatter) { + t.Fatalf("ParseDocument without frontmatter = %v, want ErrMalformedFrontmatter", err) + } +} + +func TestSchemaValidate(t *testing.T) { + tests := []struct { + name string + in Schema + ok bool + }{ + {"default", DefaultSchema(), true}, + {"narrowed status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft}}, true}, + {"no required keys", Schema{Required: nil, Status: DefaultStatuses()}, true}, + {"custom required key", Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()}, true}, + + {"empty status list", Schema{Required: []string{"id"}, Status: nil}, false}, + // A per-space config file must not be able to reintroduce "approved". + {"approved status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, "approved"}}, false}, + {"unknown status", Schema{Required: []string{"id"}, Status: []Status{"wip"}}, false}, + {"duplicate status", Schema{Required: []string{"id"}, Status: []Status{StatusDraft, StatusDraft}}, false}, + {"empty required key", Schema{Required: []string{""}, Status: DefaultStatuses()}, false}, + {"duplicate required key", Schema{Required: []string{"id", "id"}, Status: DefaultStatuses()}, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err == nil) != tc.ok { + t.Fatalf("Schema.Validate() = %v, want ok=%v", err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidPolicy) { + t.Fatalf("error %v is not ErrInvalidPolicy", err) + } + }) + } +} + +func TestSchemaValidateFrontmatter(t *testing.T) { + tests := []struct { + name string + schema Schema + front string + wantErr error // nil means "must succeed" + }{ + { + name: "complete document", + schema: DefaultSchema(), + front: "id: SPEC-0007\ntitle: Storage\nstatus: draft\n", + }, + { + name: "optional keys absent", + schema: DefaultSchema(), + front: "id: SPEC-0007\ntitle: Storage\nstatus: review\n", + }, + { + name: "owners with and without tilde", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~bigbes, bigbes]\n", + }, + { + name: "extra keys ignored", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nreviewers: [~x]\n", + }, + { + name: "missing status", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\n", + wantErr: ErrMissingField, + }, + { + name: "missing id", + schema: DefaultSchema(), + front: "title: T\nstatus: draft\n", + wantErr: ErrMissingField, + }, + { + name: "empty frontmatter", + schema: DefaultSchema(), + front: "", + wantErr: ErrMissingField, + }, + { + name: "blank title", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: \" \"\nstatus: draft\n", + wantErr: ErrMissingField, + }, + { + name: "null id", + schema: DefaultSchema(), + front: "id:\ntitle: T\nstatus: draft\n", + wantErr: ErrInvalidDocID, + }, + { + name: "malformed id", + schema: DefaultSchema(), + front: "id: spec-7\ntitle: T\nstatus: draft\n", + wantErr: ErrInvalidDocID, + }, + { + name: "approved status rejected", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: approved\n", + wantErr: ErrInvalidStatus, + }, + { + name: "status outside narrowed enum", + schema: Schema{Required: []string{"id", "title", "status"}, Status: []Status{StatusDraft}}, + front: "id: SPEC-1\ntitle: T\nstatus: review\n", + wantErr: ErrInvalidStatus, + }, + { + name: "supersedes present but empty", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes:\n", + wantErr: ErrInvalidDocID, + }, + { + name: "supersedes malformed", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nsupersedes: SPEC 3\n", + wantErr: ErrInvalidDocID, + }, + { + name: "bad owner", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: [~Big Bes]\n", + wantErr: ErrInvalidName, + }, + { + name: "empty owner entry", + schema: DefaultSchema(), + front: "id: SPEC-1\ntitle: T\nstatus: draft\nowners: ['']\n", + wantErr: ErrInvalidName, + }, + { + name: "custom required key missing", + schema: Schema{Required: []string{"id", "reviewers"}, Status: DefaultStatuses()}, + front: "id: SPEC-1\ntitle: T\nstatus: draft\n", + wantErr: ErrMissingField, + }, + { + name: "status not required and absent", + schema: Schema{Required: []string{"id"}, Status: DefaultStatuses()}, + front: "id: SPEC-1\n", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fm, err := ParseFrontmatter([]byte(tc.front)) + if err != nil { + t.Fatalf("ParseFrontmatter(%q): %v", tc.front, err) + } + err = tc.schema.ValidateFrontmatter(fm) + if tc.wantErr == nil { + if err != nil { + t.Fatalf("ValidateFrontmatter = %v, want nil", err) + } + return + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("ValidateFrontmatter = %v, want %v", err, tc.wantErr) + } + }) + } +} + +func TestSchemaAllowsStatus(t *testing.T) { + s := DefaultSchema() + for _, st := range DefaultStatuses() { + if !s.AllowsStatus(st) { + t.Errorf("default schema must allow %q", st) + } + } + if s.AllowsStatus("approved") { + t.Error(`default schema must not allow "approved"`) + } + if s.AllowsStatus("") { + t.Error("default schema must not allow the empty status") + } +} + +func TestDefaultStatusesIsNotShared(t *testing.T) { + a := DefaultStatuses() + a[0] = "approved" + if b := DefaultStatuses(); b[0] != StatusDraft { + t.Fatalf("DefaultStatuses() leaked a shared slice: %v", b) + } +} + +func TestDefaultSchemaMatchesDesign(t *testing.T) { + s := DefaultSchema() + if got, want := strings.Join(s.Required, ","), "id,title,status"; got != want { + t.Fatalf("default required = %q, want %q", got, want) + } + if got, want := joinStatuses(s.Status), "draft|review|superseded"; got != want { + t.Fatalf("default status enum = %q, want %q", got, want) + } +} diff --git a/core/id.go b/core/id.go new file mode 100644 index 0000000000000000000000000000000000000000..640296f0b6c4770641b4bf387925c0929b02a6a1 --- /dev/null +++ b/core/id.go @@ -0,0 +1,101 @@ +package core + +import ( + "fmt" + "strings" +) + +const ( + // MaxDocIDPrefixLen bounds the alphabetic part of an ID ("SPEC", "RFC"). + MaxDocIDPrefixLen = 16 + + // MaxDocIDSeqLen bounds the numeric part. Eight digits is far past the + // volume this service will ever see; the cap exists so a pathological ID + // cannot become a pathological registry key. + MaxDocIDSeqLen = 8 +) + +// DocID is a parsed document identifier such as "SPEC-0007". +// +// Seq is kept as a string rather than an int on purpose: leading zeros are part +// of how these IDs are written and read, and "SPEC-7" and "SPEC-0007" are +// therefore different IDs in a registry that is globally unique. Normalizing +// them to the same integer would silently merge two documents. +type DocID struct { + Prefix string + Seq string +} + +// String renders the canonical "PREFIX-SEQ" form. +func (d DocID) String() string { return d.Prefix + "-" + d.Seq } + +// ParseDocID parses and validates a document ID. The grammar is deliberately +// tiny: +// +// ID = PREFIX "-" SEQ +// PREFIX = [A-Z] [A-Z0-9]* (1..MaxDocIDPrefixLen) +// SEQ = [0-9]+ (1..MaxDocIDSeqLen) +// +// Exactly one '-' separates the two, so the split is unambiguous and IDs sort +// predictably. ASCII-only and uppercase-only is the load-bearing part: IDs are +// the global registry key, so a Cyrillic "С" or a lowercase "spec" must be a +// different-looking ID that is rejected outright rather than a homograph that +// quietly registers alongside the real one. +// +// Core validates shape only. Global uniqueness is enforced by the registry +// table, which is the only thing that can know about other spaces. +func ParseDocID(s string) (DocID, error) { + if s == "" { + return DocID{}, fmt.Errorf("%w: empty id", ErrInvalidDocID) + } + i := strings.IndexByte(s, '-') + if i < 0 { + return DocID{}, fmt.Errorf("%w: id %q has no '-' separator", ErrInvalidDocID, s) + } + prefix, seq := s[:i], s[i+1:] + if strings.IndexByte(seq, '-') >= 0 { + return DocID{}, fmt.Errorf("%w: id %q has more than one '-'", ErrInvalidDocID, s) + } + + if prefix == "" { + return DocID{}, fmt.Errorf("%w: id %q has an empty prefix", ErrInvalidDocID, s) + } + if len(prefix) > MaxDocIDPrefixLen { + return DocID{}, fmt.Errorf("%w: id %q prefix is too long (%d > %d)", + ErrInvalidDocID, s, len(prefix), MaxDocIDPrefixLen) + } + if !isUpperAlpha(prefix[0]) { + return DocID{}, fmt.Errorf("%w: id %q prefix must start with A-Z", ErrInvalidDocID, s) + } + for i := 0; i < len(prefix); i++ { + if c := prefix[i]; !isUpperAlpha(c) && !isDigit(c) { + return DocID{}, fmt.Errorf("%w: id %q prefix contains disallowed byte %q", ErrInvalidDocID, s, c) + } + } + + if seq == "" { + return DocID{}, fmt.Errorf("%w: id %q has an empty sequence", ErrInvalidDocID, s) + } + if len(seq) > MaxDocIDSeqLen { + return DocID{}, fmt.Errorf("%w: id %q sequence is too long (%d > %d)", + ErrInvalidDocID, s, len(seq), MaxDocIDSeqLen) + } + for i := 0; i < len(seq); i++ { + if !isDigit(seq[i]) { + return DocID{}, fmt.Errorf("%w: id %q sequence contains disallowed byte %q", ErrInvalidDocID, s, seq[i]) + } + } + + return DocID{Prefix: prefix, Seq: seq}, nil +} + +// ValidateDocID reports whether s is a well-formed document ID, discarding the +// parse. Use it where only the verdict matters (frontmatter schema checks, push +// validation). +func ValidateDocID(s string) error { + _, err := ParseDocID(s) + return err +} + +func isUpperAlpha(c byte) bool { return c >= 'A' && c <= 'Z' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } diff --git a/core/id_test.go b/core/id_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0190b62ddf737c9977dc53cbd6fa10f13bfba0c5 --- /dev/null +++ b/core/id_test.go @@ -0,0 +1,101 @@ +package core + +import ( + "errors" + "strings" + "testing" +) + +func TestParseDocID(t *testing.T) { + tests := []struct { + name string + in string + wantPrefix string + wantSeq string + ok bool + }{ + {"spec", "SPEC-0007", "SPEC", "0007", true}, + {"rfc", "RFC-1", "RFC", "1", true}, + {"single letter prefix", "X-42", "X", "42", true}, + {"digits in prefix", "C4-0001", "C4", "0001", true}, + {"all zeros", "SPEC-0000", "SPEC", "0000", true}, + {"max prefix", strings.Repeat("A", MaxDocIDPrefixLen) + "-1", strings.Repeat("A", MaxDocIDPrefixLen), "1", true}, + {"max seq", "SPEC-" + strings.Repeat("9", MaxDocIDSeqLen), "SPEC", strings.Repeat("9", MaxDocIDSeqLen), true}, + + {"empty", "", "", "", false}, + {"no separator", "SPEC0007", "", "", false}, + {"two separators", "HOME-OPS-0001", "", "", false}, + {"double dash", "SPEC--1", "", "", false}, + {"empty prefix", "-0007", "", "", false}, + {"empty seq", "SPEC-", "", "", false}, + {"lowercase prefix", "spec-0007", "", "", false}, + {"mixed case prefix", "Spec-0007", "", "", false}, + {"digit-leading prefix", "0SPEC-1", "", "", false}, + {"underscore in prefix", "SPEC_A-1", "", "", false}, + {"dot in prefix", "SPEC.A-1", "", "", false}, + {"non-digit seq", "SPEC-007a", "", "", false}, + {"signed seq", "SPEC-+1", "", "", false}, + {"hex seq", "SPEC-0x7", "", "", false}, + {"prefix too long", strings.Repeat("A", MaxDocIDPrefixLen+1) + "-1", "", "", false}, + {"seq too long", "SPEC-" + strings.Repeat("9", MaxDocIDSeqLen+1), "", "", false}, + {"leading space", " SPEC-0007", "", "", false}, + {"trailing space", "SPEC-0007 ", "", "", false}, + {"inner space", "SPEC - 0007", "", "", false}, + {"newline", "SPEC-0007\n", "", "", false}, + // Homograph: Cyrillic С/Р/Е/С look identical to Latin in most fonts. + // Accepting them would let two visually identical IDs both register. + {"cyrillic homograph", "СПЕС-0007", "", "", false}, + {"fullwidth digits", "SPEC-0007", "", "", false}, + {"arabic-indic digits", "SPEC-٠٠٠٧", "", "", false}, + {"path injection", "SPEC-0007/../x", "", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + id, err := ParseDocID(tc.in) + if (err == nil) != tc.ok { + t.Fatalf("ParseDocID(%q) = %+v, %v, want ok=%v", tc.in, id, err, tc.ok) + } + if !tc.ok { + if !errors.Is(err, ErrInvalidDocID) { + t.Fatalf("error %v is not ErrInvalidDocID", err) + } + return + } + if id.Prefix != tc.wantPrefix || id.Seq != tc.wantSeq { + t.Fatalf("ParseDocID(%q) = %+v, want {%q %q}", tc.in, id, tc.wantPrefix, tc.wantSeq) + } + if got := id.String(); got != tc.in { + t.Fatalf("String() = %q, want round-trip %q", got, tc.in) + } + }) + } +} + +// Leading zeros are significant: the registry is global and "SPEC-7" is a +// different key from "SPEC-0007". Normalizing them together would silently +// merge two documents. +func TestDocIDLeadingZerosAreSignificant(t *testing.T) { + a, err := ParseDocID("SPEC-7") + if err != nil { + t.Fatal(err) + } + b, err := ParseDocID("SPEC-0007") + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatalf("SPEC-7 and SPEC-0007 must not compare equal, got %+v", a) + } + if a.String() == b.String() { + t.Fatal("SPEC-7 and SPEC-0007 must not render identically") + } +} + +func TestValidateDocID(t *testing.T) { + if err := ValidateDocID("SPEC-0007"); err != nil { + t.Fatalf("ValidateDocID(SPEC-0007) = %v, want nil", err) + } + if err := ValidateDocID("spec-0007"); !errors.Is(err, ErrInvalidDocID) { + t.Fatalf("ValidateDocID(spec-0007) = %v, want ErrInvalidDocID", err) + } +} diff --git a/core/names.go b/core/names.go new file mode 100644 index 0000000000000000000000000000000000000000..45405089aea2bf86800b8b2f9f5c838574eee718 --- /dev/null +++ b/core/names.go @@ -0,0 +1,220 @@ +package core + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +const ( + // MaxOwnerLen bounds owner names. meta.sr.ht already bounds them at + // registration, so this is a sanity cap rather than the authority. + MaxOwnerLen = 64 + + // MaxSpaceNameLen bounds space names. A space name becomes a directory + // under the repos root and a URL segment, so it stays short. + MaxSpaceNameLen = 100 + + // MaxPathLen bounds a document or attachment path within a space. Well + // under any filesystem limit; the point is to keep a hostile path out of + // the index and the render cache, not to be permissive. + MaxPathLen = 512 +) + +const ( + // DocExt is the only extension a document may carry. Matched + // case-sensitively: git trees are case-sensitive, so accepting ".MD" would + // create documents that the renderer and indexer disagree about. + DocExt = ".md" + + // PolicyFile is the space policy, versioned in the space itself so that + // policy changes are reviewable like any other change. + PolicyFile = ".spec.yml" +) + +// SpaceRef identifies a space: one bare git repo, owned by one user. Both +// fields are stored without decoration — Owner never carries the leading '~'. +type SpaceRef struct { + Owner string + Name string +} + +// String renders the canonical URL and on-disk form, "~owner/name". +func (r SpaceRef) String() string { return "~" + r.Owner + "/" + r.Name } + +// isNameByte reports whether c is allowed in an owner or space name: lowercase +// alphanumerics plus '_', '-' and '.'. '/' is deliberately excluded, so a name +// can never span path components. +func isNameByte(c byte) bool { + switch { + case c >= 'a' && c <= 'z': + return true + case c >= '0' && c <= '9': + return true + case c == '_' || c == '-' || c == '.': + return true + default: + return false + } +} + +// validateName holds the rules shared by owners and spaces: non-empty, within +// the allowed byte set, not starting with '-' (which would read as an option to +// anything shelling out) and containing no ".." (path traversal, since both +// kinds of name become a path segment under the repos root). +func validateName(kind, s string, maxLen int) error { + if s == "" { + return fmt.Errorf("%w: empty %s", ErrInvalidName, kind) + } + if len(s) > maxLen { + return fmt.Errorf("%w: %s %q is too long (%d > %d)", ErrInvalidName, kind, s, len(s), maxLen) + } + if s[0] == '-' { + return fmt.Errorf("%w: %s %q must not start with '-'", ErrInvalidName, kind, s) + } + if strings.Contains(s, "..") { + return fmt.Errorf("%w: %s %q must not contain '..'", ErrInvalidName, kind, s) + } + // '.' is in the allowed byte set, so the bare current-directory name has to + // be excluded by hand: a space named "." would resolve to the repos root. + if s == "." { + return fmt.Errorf("%w: %s %q is not allowed", ErrInvalidName, kind, s) + } + for i := 0; i < len(s); i++ { + if !isNameByte(s[i]) { + return fmt.Errorf("%w: %s %q contains disallowed byte %q", ErrInvalidName, kind, s, s[i]) + } + } + return nil +} + +// ValidateOwner reports whether s is a well-formed sourcehut owner name (the +// part after '~' in a URL). Callers must strip the leading '~' first. +func ValidateOwner(s string) error { return validateName("owner", s, MaxOwnerLen) } + +// ValidateSpaceName reports whether s is a well-formed space name — the same +// character family as an owner, capped at MaxSpaceNameLen. +func ValidateSpaceName(s string) error { return validateName("space", s, MaxSpaceNameLen) } + +// ParseSpaceRef parses "~owner/name" (or "owner/name") into a validated +// SpaceRef. Surrounding slashes are tolerated because the same string arrives +// both as a URL path and as a config value, but anything else that does not +// split into exactly two non-empty segments is rejected rather than repaired. +func ParseSpaceRef(s string) (SpaceRef, error) { + trimmed := strings.Trim(s, "/") + if trimmed == "" { + return SpaceRef{}, fmt.Errorf("%w: empty space reference", ErrInvalidName) + } + segs := strings.Split(trimmed, "/") + if len(segs) != 2 { + return SpaceRef{}, fmt.Errorf("%w: space reference %q must have exactly 2 segments, got %d", + ErrInvalidName, s, len(segs)) + } + ref := SpaceRef{Owner: strings.TrimPrefix(segs[0], "~"), Name: segs[1]} + if err := ValidateOwner(ref.Owner); err != nil { + return SpaceRef{}, err + } + if err := ValidateSpaceName(ref.Name); err != nil { + return SpaceRef{}, err + } + return ref, nil +} + +// badPathRune reports whether r must never appear in a path. Two families: +// control characters, which git tolerates in a tree entry but which corrupt +// logs, JSON and the index; and the Unicode bidirectional overrides, which can +// make a path render in the review UI as something other than what will be +// committed. Reviewing agent output is the product, so a path that lies about +// itself on screen is a correctness bug, not a nicety. +func badPathRune(r rune) bool { + if r < 0x20 || r == 0x7f { + return true + } + switch r { + case 0x200e, 0x200f, // LRM, RLM + 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // LRE, RLE, PDF, LRO, RLO + 0x2066, 0x2067, 0x2068, 0x2069: // LRI, RLI, FSI, PDI + return true + } + return false +} + +// ValidatePath reports whether p is a safe relative path inside a space, usable +// as a git tree path for a document or an attachment. The rules: +// +// - non-empty and no longer than MaxPathLen; +// - valid UTF-8, no control characters, no bidi overrides (see badPathRune); +// - relative: no leading '/', no trailing '/'; +// - no backslashes — on a git tree a '\' is an ordinary filename byte, so +// accepting it produces paths that mean different things to different +// clients; +// - no empty, "." or ".." components: traversal, and the whole point of this +// function; +// - no ".git" component, which git refuses to track and which is the classic +// checkout-escape vector; +// - no component ending in '.' or ' ', which are invisible on screen and +// therefore an easy way to shadow an existing document. +// +// ValidatePath deliberately allows dotfiles (".spec.yml" is one) and non-ASCII +// letters (specs here are written in Russian as well as English). +func ValidatePath(p string) error { + if p == "" { + return fmt.Errorf("%w: empty path", ErrInvalidPath) + } + if len(p) > MaxPathLen { + return fmt.Errorf("%w: path is too long (%d > %d)", ErrInvalidPath, len(p), MaxPathLen) + } + if !utf8.ValidString(p) { + return fmt.Errorf("%w: path is not valid UTF-8", ErrInvalidPath) + } + for _, r := range p { + if badPathRune(r) { + return fmt.Errorf("%w: path %q contains disallowed rune %U", ErrInvalidPath, p, r) + } + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("%w: path %q must be relative", ErrInvalidPath, p) + } + if strings.HasSuffix(p, "/") { + return fmt.Errorf("%w: path %q must not end in '/'", ErrInvalidPath, p) + } + if strings.Contains(p, `\`) { + return fmt.Errorf("%w: path %q must not contain a backslash", ErrInvalidPath, p) + } + for _, comp := range strings.Split(p, "/") { + switch comp { + case "": + return fmt.Errorf("%w: path %q has an empty component", ErrInvalidPath, p) + case ".", "..": + return fmt.Errorf("%w: path %q has a traversal component %q", ErrInvalidPath, p, comp) + case ".git": + return fmt.Errorf("%w: path %q has a %q component", ErrInvalidPath, p, comp) + } + if strings.HasSuffix(comp, ".") || strings.HasSuffix(comp, " ") { + return fmt.Errorf("%w: path %q component %q ends in '.' or a space", ErrInvalidPath, p, comp) + } + } + return nil +} + +// ValidateDocPath reports whether p is a valid path for a markdown document: +// everything ValidatePath requires, plus a ".md" extension on a non-empty base +// name. The extension carries meaning here — it is what tells the indexer and +// the renderer that a blob is a document rather than an attachment — so a +// document named exactly ".md" is rejected as having no name at all. +func ValidateDocPath(p string) error { + if err := ValidatePath(p); err != nil { + return err + } + if !strings.HasSuffix(p, DocExt) { + return fmt.Errorf("%w: document path %q must end in %q", ErrInvalidPath, p, DocExt) + } + base := p + if i := strings.LastIndex(p, "/"); i >= 0 { + base = p[i+1:] + } + if base == DocExt { + return fmt.Errorf("%w: document path %q has an empty base name", ErrInvalidPath, p) + } + return nil +} diff --git a/core/names_test.go b/core/names_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5844d0677a85d2ed7104b3d45954a4225c9bcf56 --- /dev/null +++ b/core/names_test.go @@ -0,0 +1,216 @@ +package core + +import ( + "errors" + "strings" + "testing" +) + +func TestValidateOwner(t *testing.T) { + tests := []struct { + in string + ok bool + }{ + {"bigbes", true}, + {"user_name", true}, + {"user-name", true}, + {"user.name", true}, + {"u123", true}, + {"a", true}, + {strings.Repeat("a", MaxOwnerLen), true}, + + {"", false}, + {strings.Repeat("a", MaxOwnerLen+1), false}, + {"-leading", false}, + {"~bigbes", false}, // caller must strip the '~' first + {"has/slash", false}, + {"has..dots", false}, + {"..", false}, + {"Upper", false}, + {"has space", false}, + {"tilde~", false}, + {"emoji😀", false}, + {"привет", false}, + {"nul\x00byte", false}, + } + for _, tc := range tests { + err := ValidateOwner(tc.in) + if (err == nil) != tc.ok { + t.Errorf("ValidateOwner(%q) = %v, want ok=%v", tc.in, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidName) { + t.Errorf("ValidateOwner(%q) error %v is not ErrInvalidName", tc.in, err) + } + } +} + +func TestValidateSpaceName(t *testing.T) { + tests := []struct { + in string + ok bool + }{ + {"rfcs", true}, + {"tarantool-rfcs", true}, + {"home_ops", true}, + {"v1.0", true}, + {strings.Repeat("a", MaxSpaceNameLen), true}, + + {"", false}, + {strings.Repeat("a", MaxSpaceNameLen+1), false}, + {"-dashfirst", false}, + {"a/b", false}, + {"a..b", false}, + {".", false}, // single dot is a legal byte set but "." alone is a directory + {"..", false}, // traversal + {"CamelCase", false}, + {"+everything", false}, // project namespace, not a space name + } + for _, tc := range tests { + err := ValidateSpaceName(tc.in) + if (err == nil) != tc.ok { + t.Errorf("ValidateSpaceName(%q) = %v, want ok=%v", tc.in, err, tc.ok) + } + } +} + +func TestValidateSpaceNameRejectsBareDot(t *testing.T) { + // "." passes the byte-set rule, so it must be caught somewhere: a space + // literally named "." would resolve to the repos root. + if err := ValidateSpaceName("."); err == nil { + t.Fatal(`ValidateSpaceName(".") must fail: it would resolve to the repos root`) + } +} + +func TestParseSpaceRef(t *testing.T) { + tests := []struct { + name string + in string + wantOwner string + wantName string + ok bool + }{ + {"tilde form", "~bigbes/rfcs", "bigbes", "rfcs", true}, + {"bare form", "bigbes/rfcs", "bigbes", "rfcs", true}, + {"leading slash", "/~bigbes/rfcs", "bigbes", "rfcs", true}, + {"trailing slash", "~bigbes/rfcs/", "bigbes", "rfcs", true}, + + {"empty", "", "", "", false}, + {"slashes only", "///", "", "", false}, + {"one segment", "~bigbes", "", "", false}, + {"three segments", "~bigbes/rfcs/docs", "", "", false}, + {"empty owner", "~/rfcs", "", "", false}, + {"empty name", "~bigbes/", "", "", false}, + {"traversal owner", "~../rfcs", "", "", false}, + {"traversal name", "~bigbes/..", "", "", false}, + {"uppercase owner", "~BigBes/rfcs", "", "", false}, + {"space in name", "~bigbes/my rfcs", "", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ref, err := ParseSpaceRef(tc.in) + if (err == nil) != tc.ok { + t.Fatalf("ParseSpaceRef(%q) = %v, %v, want ok=%v", tc.in, ref, err, tc.ok) + } + if !tc.ok { + if !errors.Is(err, ErrInvalidName) { + t.Fatalf("error %v is not ErrInvalidName", err) + } + return + } + if ref.Owner != tc.wantOwner || ref.Name != tc.wantName { + t.Fatalf("ParseSpaceRef(%q) = %+v, want {%q %q}", tc.in, ref, tc.wantOwner, tc.wantName) + } + if got, want := ref.String(), "~"+tc.wantOwner+"/"+tc.wantName; got != want { + t.Fatalf("String() = %q, want %q", got, want) + } + }) + } +} + +func TestValidatePath(t *testing.T) { + tests := []struct { + name string + in string + ok bool + }{ + {"simple", "specs/0007-storage.md", true}, + {"root file", "README.md", true}, + {"dotfile", ".spec.yml", true}, + {"deep", "a/b/c/d/e.md", true}, + {"cyrillic", "спеки/хранилище.md", true}, + {"attachment", "assets/diagram.png", true}, + {"dot in name", "v1.2.3.md", true}, + {"leading dot component", "notes/.private.md", true}, + {"max length", strings.Repeat("a", MaxPathLen), true}, + + {"empty", "", false}, + {"too long", strings.Repeat("a", MaxPathLen+1), false}, + {"absolute", "/etc/passwd", false}, + {"trailing slash", "specs/", false}, + {"parent traversal", "../etc/passwd", false}, + {"embedded traversal", "specs/../../etc/passwd", false}, + {"interior traversal", "specs/../notes/a.md", false}, + {"dot component", "./a.md", false}, + {"interior dot component", "a/./b.md", false}, + {"empty component", "specs//a.md", false}, + {"git dir", ".git/config", false}, + {"nested git dir", "specs/.git/HEAD", false}, + {"backslash", `specs\a.md`, false}, + {"windows traversal", `..\a.md`, false}, + {"nul byte", "specs/a\x00.md", false}, + {"newline", "specs/a\nb.md", false}, + {"tab", "specs/a\tb.md", false}, + {"del", "specs/a\x7f.md", false}, + {"rtl override", "specs/exe\u202egnp.md", false}, + {"rtl mark", "specs/a\u200fb.md", false}, + {"pop directional isolate", "specs/a\u2069b.md", false}, + {"invalid utf-8", "specs/\xff\xfe.md", false}, + {"trailing dot component", "specs/a./b.md", false}, + {"trailing space component", "specs/a /b.md", false}, + {"trailing space at end", "specs/a.md ", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePath(tc.in) + if (err == nil) != tc.ok { + t.Fatalf("ValidatePath(%q) = %v, want ok=%v", tc.in, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error %v is not ErrInvalidPath", err) + } + }) + } +} + +func TestValidateDocPath(t *testing.T) { + tests := []struct { + name string + in string + ok bool + }{ + {"spec", "specs/0007-storage.md", true}, + {"root", "README.md", true}, + {"hidden doc", "notes/.draft.md", true}, + {"cyrillic", "спеки/хранилище.md", true}, + + {"no extension", "specs/0007-storage", false}, + {"wrong extension", "specs/0007-storage.markdown", false}, + {"uppercase extension", "specs/0007-storage.MD", false}, + {"extension only", ".md", false}, + {"extension only in dir", "specs/.md", false}, + {"extension in middle", "specs/a.md.txt", false}, + {"traversal wins", "../a.md", false}, + {"empty", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateDocPath(tc.in) + if (err == nil) != tc.ok { + t.Fatalf("ValidateDocPath(%q) = %v, want ok=%v", tc.in, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidPath) { + t.Fatalf("error %v is not ErrInvalidPath", err) + } + }) + } +} diff --git a/core/policy.go b/core/policy.go new file mode 100644 index 0000000000000000000000000000000000000000..18c1a19d3e6e15dbfd31bbd0c98bef103f1cbd31 --- /dev/null +++ b/core/policy.go @@ -0,0 +1,240 @@ +package core + +import ( + "bytes" + "errors" + "fmt" + "io" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +// Policy is a space's `.spec.yml`, versioned in the space itself so that policy +// changes are reviewable like any other change. +// +// review: +// auto_merge: [notes/**, reports/**] +// schema: +// required: [id, title, status] +// status: [draft, review, superseded] +type Policy struct { + Review ReviewPolicy `yaml:"review"` + Schema Schema `yaml:"schema"` +} + +// ReviewPolicy carries the one real review knob. Single-user means the owner is +// the only approver, so there is no approver list and no approval count — only +// which paths skip the gate. +type ReviewPolicy struct { + // AutoMerge lists path patterns whose proposals land immediately. This is + // what implements the bimodal cadence: `specs/` waits for a human, `notes/` + // and `reports/` flow through. + AutoMerge []string `yaml:"auto_merge"` +} + +// DefaultPolicy is what a space with no `.spec.yml` gets: the house frontmatter +// contract and nothing auto-merged. Defaulting auto_merge to empty is the +// fail-closed direction — a space that has not said anything about review must +// not be quietly laundering unreviewed agent output onto the approved branch. +func DefaultPolicy() Policy { + return Policy{Schema: DefaultSchema()} +} + +// ParsePolicy parses and validates a `.spec.yml`. +// +// Unknown keys are rejected: a `auto-merge:` typo would otherwise parse fine +// and silently do nothing, and a policy file that lies about what it enforces +// is worse than one that fails to load. +// +// A section the file omits falls back to DefaultPolicy. An omitted `schema:` +// means "the house contract", not "a space where no status is allowed and +// therefore no document can ever validate". An explicitly empty list +// (`required: []`) is honoured as written — YAML distinguishes it from an +// absent key by nil-ness, and a space that deliberately turns off required-key +// checking must be able to say so. +func ParsePolicy(data []byte) (Policy, error) { + var p Policy + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&p); err != nil { + // An empty or comment-only file decodes to io.EOF. That is a valid way + // to say "defaults", unlike a file that fails to parse. + if !errors.Is(err, io.EOF) { + return Policy{}, fmt.Errorf("%w: %v", ErrInvalidPolicy, err) + } + return DefaultPolicy(), nil + } + // A second YAML document would be silently ignored, so a policy split over + // two '---' blocks would enforce only half of what it says. + if err := dec.Decode(new(Policy)); !errors.Is(err, io.EOF) { + return Policy{}, fmt.Errorf("%w: %s must contain exactly one YAML document", ErrInvalidPolicy, PolicyFile) + } + + def := DefaultPolicy() + if p.Schema.Required == nil { + p.Schema.Required = def.Schema.Required + } + if p.Schema.Status == nil { + p.Schema.Status = def.Schema.Status + } + + if err := p.Validate(); err != nil { + return Policy{}, err + } + return p, nil +} + +// Validate reports whether the policy is internally usable: every auto_merge +// pattern well-formed, and the schema itself well-formed. +func (p Policy) Validate() error { + for _, pat := range p.Review.AutoMerge { + if err := ValidatePattern(pat); err != nil { + return fmt.Errorf("%w: review.auto_merge: %v", ErrInvalidPolicy, err) + } + } + return p.Schema.Validate() +} + +// AutoMerges reports whether path may skip human review under this policy. +// +// Fail-closed: a path that does not match, or that is not a valid path at all, +// means "needs a human". The cost of a wrong false is one click; the cost of a +// wrong true is unreviewed agent output landing on the approved branch, which +// is the exact failure this service exists to prevent. +func (p Policy) AutoMerges(path string) bool { + if err := ValidatePath(path); err != nil { + return false + } + for _, pat := range p.Review.AutoMerge { + if MatchPattern(pat, path) { + return true + } + } + return false +} + +// ValidatePattern reports whether pat is a well-formed auto_merge pattern. +// The grammar is the familiar globstar subset, and nothing more: +// +// ** as a whole component, matches zero or more path components +// * matches any run of characters within one component, never '/' +// ? matches exactly one character within one component +// +// Bracket expressions are deliberately absent, so '[' is a literal. Structural +// rules mirror ValidatePath (relative, no empty or traversal components), plus +// one of its own: "**" must be an entire component, because "a**b" has no +// obvious meaning and guessing one is how a policy comes to mean something its +// author did not intend. +func ValidatePattern(pat string) error { + if pat == "" { + return fmt.Errorf("%w: empty pattern", ErrInvalidPattern) + } + if len(pat) > MaxPathLen { + return fmt.Errorf("%w: pattern is too long (%d > %d)", ErrInvalidPattern, len(pat), MaxPathLen) + } + if !utf8.ValidString(pat) { + return fmt.Errorf("%w: pattern is not valid UTF-8", ErrInvalidPattern) + } + for _, r := range pat { + if badPathRune(r) { + return fmt.Errorf("%w: pattern %q contains disallowed rune %U", ErrInvalidPattern, pat, r) + } + } + if strings.HasPrefix(pat, "/") { + return fmt.Errorf("%w: pattern %q must be relative", ErrInvalidPattern, pat) + } + if strings.HasSuffix(pat, "/") { + return fmt.Errorf("%w: pattern %q must not end in '/' (write %q to match a subtree)", + ErrInvalidPattern, pat, pat+"**") + } + if strings.Contains(pat, `\`) { + return fmt.Errorf("%w: pattern %q must not contain a backslash", ErrInvalidPattern, pat) + } + for _, comp := range strings.Split(pat, "/") { + switch comp { + case "": + return fmt.Errorf("%w: pattern %q has an empty component", ErrInvalidPattern, pat) + case ".", "..": + return fmt.Errorf("%w: pattern %q has a traversal component %q", ErrInvalidPattern, pat, comp) + case "**": + continue + } + if strings.Contains(comp, "**") { + return fmt.Errorf("%w: pattern %q: %q must be a whole path component", ErrInvalidPattern, pat, "**") + } + } + return nil +} + +// MatchPattern reports whether path matches pat under the grammar documented on +// ValidatePattern. An invalid pattern matches nothing; Policy.Validate rejects +// those up front, so a live policy never contains one. +// +// Note that "**" matching zero components means "notes/**" also matches the +// bare path "notes". That is the price of "**/*.md" matching a document at the +// space root, which is the case that actually comes up. +func MatchPattern(pat, path string) bool { + if ValidatePattern(pat) != nil { + return false + } + return matchComponents(strings.Split(pat, "/"), strings.Split(path, "/")) +} + +func matchComponents(pats, segs []string) bool { + for len(pats) > 0 { + if pats[0] == "**" { + if len(pats) == 1 { + return true + } + for i := 0; i <= len(segs); i++ { + if matchComponents(pats[1:], segs[i:]) { + return true + } + } + return false + } + if len(segs) == 0 { + return false + } + if !matchSegment(pats[0], segs[0]) { + return false + } + pats, segs = pats[1:], segs[1:] + } + return len(segs) == 0 +} + +// matchSegment matches one path component against one pattern component using +// '*' and '?'. Iterative with a single backtrack point, so a pathological +// pattern such as "*a*a*a*a*" stays linear-ish instead of exponential — these +// patterns come from a config file, but the config file is versioned content +// that an agent can propose. +func matchSegment(pat, s string) bool { + p := []rune(pat) + t := []rune(s) + var pi, ti, resume int + star := -1 + for ti < len(t) { + switch { + case pi < len(p) && (p[pi] == '?' || p[pi] == t[ti]): + pi++ + ti++ + case pi < len(p) && p[pi] == '*': + star = pi + resume = ti + pi++ + case star >= 0: + resume++ + pi = star + 1 + ti = resume + default: + return false + } + } + for pi < len(p) && p[pi] == '*' { + pi++ + } + return pi == len(p) +} diff --git a/core/policy_test.go b/core/policy_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e3abd8c4787c495721201f1e2b6e5297b3e58018 --- /dev/null +++ b/core/policy_test.go @@ -0,0 +1,321 @@ +package core + +import ( + "errors" + "strings" + "testing" +) + +func TestValidatePattern(t *testing.T) { + tests := []struct { + name string + in string + ok bool + }{ + {"subtree", "notes/**", true}, + {"literal", "specs/0007.md", true}, + {"star in component", "specs/*.md", true}, + {"question mark", "specs/000?.md", true}, + {"leading globstar", "**/*.md", true}, + {"interior globstar", "notes/**/drafts/*.md", true}, + {"bare globstar", "**", true}, + {"bare star", "*", true}, + {"cyrillic", "спеки/**", true}, + {"bracket is literal", "specs/[draft].md", true}, + + {"empty", "", false}, + {"too long", strings.Repeat("a", MaxPathLen+1), false}, + {"absolute", "/notes/**", false}, + {"trailing slash", "notes/", false}, + {"empty component", "notes//**", false}, + {"traversal", "../notes/**", false}, + {"interior traversal", "notes/../specs/**", false}, + {"dot component", "./notes/**", false}, + {"backslash", `notes\**`, false}, + // "a**b" has no obvious meaning; guessing one is how a policy comes to + // mean something its author did not intend. + {"globstar not whole component", "notes**", false}, + {"globstar prefixed", "**notes/x", false}, + {"globstar suffixed", "notes/**x", false}, + {"triple star", "notes/***", false}, + {"nul byte", "notes/\x00**", false}, + {"newline", "notes/**\n", false}, + {"rtl override", "notes/\u202e**", false}, + {"invalid utf-8", "notes/\xff\xfe", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidatePattern(tc.in) + if (err == nil) != tc.ok { + t.Fatalf("ValidatePattern(%q) = %v, want ok=%v", tc.in, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidPattern) { + t.Fatalf("error %v is not ErrInvalidPattern", err) + } + }) + } +} + +func TestMatchPattern(t *testing.T) { + tests := []struct { + pat string + path string + want bool + }{ + // Subtree matching, the shape the design's example uses. + {"notes/**", "notes/a.md", true}, + {"notes/**", "notes/2026/07/a.md", true}, + {"notes/**", "notes", true}, // "**" matches zero components; documented + {"notes/**", "notesx/a.md", false}, + {"notes/**", "specs/a.md", false}, + {"notes/**", "a/notes/b.md", false}, + {"reports/**", "reports/weekly/2026-07.md", true}, + + // "*" never crosses a component boundary. + {"specs/*.md", "specs/0007.md", true}, + {"specs/*.md", "specs/sub/0007.md", false}, + {"specs/*", "specs/0007.md", true}, + {"specs/*", "specs", false}, + {"*", "a.md", true}, + {"*", "a/b.md", false}, + {"*.md", "a.md", true}, + {"*.md", "a.markdown", false}, + + // "?" is exactly one character, counted in runes. + {"specs/000?.md", "specs/0007.md", true}, + {"specs/000?.md", "specs/00007.md", false}, + {"?.md", "я.md", true}, + {"??.md", "я.md", false}, + + // "**" in the middle and at the front. + {"**/*.md", "a.md", true}, + {"**/*.md", "notes/a.md", true}, + {"**/*.md", "a/b/c.md", true}, + {"**/*.md", "a/b/c.png", false}, + {"notes/**/*.md", "notes/a.md", true}, + {"notes/**/*.md", "notes/2026/a.md", true}, + {"notes/**/drafts/*.md", "notes/x/y/drafts/a.md", true}, + {"notes/**/drafts/*.md", "notes/drafts/a.md", true}, + {"notes/**/drafts/*.md", "notes/drafts/sub/a.md", false}, + {"**", "anything/at/all.md", true}, + + // Backtracking within a component. + {"a*b*c", "abc", true}, + {"a*b*c", "axxbxxc", true}, + {"a*b*c", "axxbxx", false}, + {"*a*a*a*a*b", strings.Repeat("a", 40), false}, + + // Literals, including the deliberately unsupported bracket syntax. + {"specs/0007.md", "specs/0007.md", true}, + {"specs/0007.md", "specs/0008.md", false}, + {"specs/[ab].md", "specs/a.md", false}, + {"specs/[ab].md", "specs/[ab].md", true}, + + // An invalid pattern matches nothing rather than everything. + {"", "a.md", false}, + {"/notes/**", "notes/a.md", false}, + {"notes**", "notes/a.md", false}, + {"../**", "a.md", false}, + } + for _, tc := range tests { + t.Run(tc.pat+" ~ "+tc.path, func(t *testing.T) { + if got := MatchPattern(tc.pat, tc.path); got != tc.want { + t.Fatalf("MatchPattern(%q, %q) = %v, want %v", tc.pat, tc.path, got, tc.want) + } + }) + } +} + +func TestParsePolicy(t *testing.T) { + const full = `review: + auto_merge: [notes/**, reports/**] +schema: + required: [id, title, status] + status: [draft, review, superseded] +` + p, err := ParsePolicy([]byte(full)) + if err != nil { + t.Fatalf("ParsePolicy: %v", err) + } + if got, want := strings.Join(p.Review.AutoMerge, ","), "notes/**,reports/**"; got != want { + t.Fatalf("auto_merge = %q, want %q", got, want) + } + if got, want := joinStatuses(p.Schema.Status), "draft|review|superseded"; got != want { + t.Fatalf("schema.status = %q, want %q", got, want) + } + if got, want := strings.Join(p.Schema.Required, ","), "id,title,status"; got != want { + t.Fatalf("schema.required = %q, want %q", got, want) + } +} + +func TestParsePolicyDefaults(t *testing.T) { + tests := []struct { + name string + in string + }{ + {"empty file", ""}, + {"comment only", "# nothing configured yet\n"}, + {"review section only", "review:\n auto_merge: [notes/**]\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p, err := ParsePolicy([]byte(tc.in)) + if err != nil { + t.Fatalf("ParsePolicy: %v", err) + } + // An omitted schema means the house contract, not "no status is + // allowed and therefore no document can ever validate". + if got, want := joinStatuses(p.Schema.Status), "draft|review|superseded"; got != want { + t.Fatalf("schema.status = %q, want the default %q", got, want) + } + if got, want := strings.Join(p.Schema.Required, ","), "id,title,status"; got != want { + t.Fatalf("schema.required = %q, want the default %q", got, want) + } + }) + } +} + +func TestParsePolicyEmptyListIsHonoured(t *testing.T) { + // `required: []` is an explicit "no required keys", which YAML tells apart + // from an absent key by nil-ness. Overwriting it with the default would + // make the setting unexpressible. + p, err := ParsePolicy([]byte("schema:\n required: []\n")) + if err != nil { + t.Fatalf("ParsePolicy: %v", err) + } + if len(p.Schema.Required) != 0 { + t.Fatalf("schema.required = %v, want an empty list", p.Schema.Required) + } + if len(p.Schema.Status) != 3 { + t.Fatalf("schema.status = %v, want the default enum", p.Schema.Status) + } +} + +func TestParsePolicyErrors(t *testing.T) { + tests := []struct { + name string + in string + }{ + // A typo'd key would parse fine and silently enforce nothing. + {"typo'd auto_merge", "review:\n auto-merge: [notes/**]\n"}, + {"unknown top-level key", "reviews:\n auto_merge: [notes/**]\n"}, + {"unknown schema key", "schema:\n requires: [id]\n"}, + {"approvers reintroduced", "review:\n approvers: [~bigbes]\n"}, + + {"not a mapping", "- review\n"}, + {"scalar", "just a string\n"}, + {"broken yaml", "review:\n auto_merge: [notes/**\n"}, + {"tab indentation", "review:\n\tauto_merge: [a]\n"}, + {"two documents", "review:\n auto_merge: [notes/**]\n---\nschema:\n required: [id]\n"}, + {"auto_merge as scalar", "review:\n auto_merge: notes/**\n"}, + + {"invalid pattern", "review:\n auto_merge: [../etc/**]\n"}, + {"absolute pattern", "review:\n auto_merge: [/notes/**]\n"}, + {"malformed globstar", "review:\n auto_merge: [notes**]\n"}, + // The critical one: a per-space file must not be able to make + // "approved" an authored status. + {"approved status", "schema:\n status: [draft, approved]\n"}, + {"unknown status", "schema:\n status: [wip]\n"}, + {"empty status list", "schema:\n status: []\n"}, + {"duplicate required key", "schema:\n required: [id, id]\n"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p, err := ParsePolicy([]byte(tc.in)) + if err == nil { + t.Fatalf("ParsePolicy(%q) = %+v, want an error", tc.in, p) + } + if !errors.Is(err, ErrInvalidPolicy) { + t.Fatalf("error %v is not ErrInvalidPolicy", err) + } + }) + } +} + +func TestPolicyAutoMerges(t *testing.T) { + p, err := ParsePolicy([]byte("review:\n auto_merge: [notes/**, reports/**, drafts/*.md]\n")) + if err != nil { + t.Fatalf("ParsePolicy: %v", err) + } + + tests := []struct { + path string + want bool + }{ + {"notes/a.md", true}, + {"notes/2026/07/standup.md", true}, + {"reports/weekly.md", true}, + {"drafts/x.md", true}, + + {"specs/0007-storage.md", false}, + {"drafts/sub/x.md", false}, + {"README.md", false}, + {".spec.yml", false}, + {"notesx/a.md", false}, + + // Fail-closed: a path that is not a valid path cannot auto-merge, so a + // traversal attempt goes to a human rather than straight to approved. + {"notes/../specs/0007.md", false}, + {"../notes/a.md", false}, + {"/notes/a.md", false}, + {"notes/a\x00.md", false}, + {"", false}, + } + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + if got := p.AutoMerges(tc.path); got != tc.want { + t.Fatalf("AutoMerges(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} + +func TestDefaultPolicyAutoMergesNothing(t *testing.T) { + // A space that has said nothing about review must not be laundering + // unreviewed agent output onto the approved branch. + p := DefaultPolicy() + if err := p.Validate(); err != nil { + t.Fatalf("DefaultPolicy().Validate() = %v, want nil", err) + } + for _, path := range []string{"notes/a.md", "specs/a.md", "anything.md"} { + if p.AutoMerges(path) { + t.Errorf("DefaultPolicy().AutoMerges(%q) = true, want false", path) + } + } +} + +func TestPolicyValidate(t *testing.T) { + tests := []struct { + name string + in Policy + ok bool + }{ + {"default", DefaultPolicy(), true}, + { + "good patterns", + Policy{Review: ReviewPolicy{AutoMerge: []string{"notes/**", "*.md"}}, Schema: DefaultSchema()}, + true, + }, + { + "bad pattern", + Policy{Review: ReviewPolicy{AutoMerge: []string{"notes/**", "../x"}}, Schema: DefaultSchema()}, + false, + }, + { + "bad schema", + Policy{Schema: Schema{Status: []Status{"approved"}}}, + false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.in.Validate() + if (err == nil) != tc.ok { + t.Fatalf("Policy.Validate() = %v, want ok=%v", err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidPolicy) { + t.Fatalf("error %v is not ErrInvalidPolicy", err) + } + }) + } +} diff --git a/core/proposal.go b/core/proposal.go new file mode 100644 index 0000000000000000000000000000000000000000..22df5e0cbeabc31b22a3e868dc59391b78718c7e --- /dev/null +++ b/core/proposal.go @@ -0,0 +1,93 @@ +package core + +import "fmt" + +// ProposalState is the lifecycle of a proposal. +// +// open ──► merged +// └───► rejected +// +// Collapsed from the usual five-state machine because there is exactly one +// reviewer: with nobody else in the loop, "approve" is "merge now", and there +// is no one to request changes from — a proposal you dislike is rejected and +// the agent proposes again. Keeping `approved` and `merged` apart, or a +// `changes-requested` cycle, would be machinery serving a review conversation +// that has no second party. +type ProposalState string + +const ( + StateOpen ProposalState = "open" + StateMerged ProposalState = "merged" + StateRejected ProposalState = "rejected" +) + +// ProposalStates returns every state, in lifecycle order. +func ProposalStates() []ProposalState { + return []ProposalState{StateOpen, StateMerged, StateRejected} +} + +// ParseProposalState validates a state string, typically one read back from +// Postgres or an API request. +func ParseProposalState(s string) (ProposalState, error) { + switch ProposalState(s) { + case StateOpen, StateMerged, StateRejected: + return ProposalState(s), nil + } + return "", fmt.Errorf("%w: %q is not one of open|merged|rejected", ErrInvalidState, s) +} + +// Terminal reports whether the proposal has been resolved. Terminal proposals +// keep their URL — a link still resolves after merge or rejection, showing the +// outcome — but they never move again. +func (s ProposalState) Terminal() bool { + return s == StateMerged || s == StateRejected +} + +// CanTransitionTo reports whether the proposal may move from s to next, +// returning ErrInvalidTransition with both states named if it may not. +// +// Only open→merged and open→rejected are legal. Self-transitions are rejected +// too: the reconciler repairs a crashed merge by comparing the ref against the +// row and only writing when they differ, so a "merged→merged" call is a bug in +// the caller rather than an idempotent retry. +func (s ProposalState) CanTransitionTo(next ProposalState) error { + if _, err := ParseProposalState(string(s)); err != nil { + return err + } + if _, err := ParseProposalState(string(next)); err != nil { + return err + } + if !ValidTransition(s, next) { + return fmt.Errorf("%w: %s -> %s", ErrInvalidTransition, s, next) + } + return nil +} + +// ValidTransition is the transition table itself. +func ValidTransition(from, to ProposalState) bool { + return from == StateOpen && (to == StateMerged || to == StateRejected) +} + +// Approval records how a merge was authorized, and is set on merge. +// +// Auto-merged is not human-approved, and readers must be able to tell: a bot +// asking for the approved text of a spec should be able to require human +// approval and get a different answer than for a firehose note. Collapsing the +// two would quietly launder unreviewed agent output as blessed. +type Approval string + +const ( + // ApprovalHuman means the owner clicked approve. + ApprovalHuman Approval = "human" + // ApprovalPolicy means the path matched the space's auto_merge patterns. + ApprovalPolicy Approval = "policy" +) + +// ParseApproval validates an approval kind read back from Postgres or an API. +func ParseApproval(s string) (Approval, error) { + switch Approval(s) { + case ApprovalHuman, ApprovalPolicy: + return Approval(s), nil + } + return "", fmt.Errorf("%w: %q is not one of human|policy", ErrInvalidApproval, s) +} diff --git a/core/proposal_test.go b/core/proposal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..368bce7548c0465dc6b621f9ccaba0def3d74608 --- /dev/null +++ b/core/proposal_test.go @@ -0,0 +1,147 @@ +package core + +import ( + "errors" + "testing" +) + +func TestParseProposalState(t *testing.T) { + tests := []struct { + in string + ok bool + }{ + {"open", true}, + {"merged", true}, + {"rejected", true}, + + // The states that were deliberately collapsed away. Accepting either + // would resurrect a review conversation that has no second party. + {"approved", false}, + {"changes-requested", false}, + {"closed", false}, + {"draft", false}, + + {"", false}, + {"Open", false}, + {"OPEN", false}, + {"open ", false}, + {" open", false}, + {"открыт", false}, + } + for _, tc := range tests { + st, err := ParseProposalState(tc.in) + if (err == nil) != tc.ok { + t.Errorf("ParseProposalState(%q) = %q, %v, want ok=%v", tc.in, st, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidState) { + t.Errorf("ParseProposalState(%q) error %v is not ErrInvalidState", tc.in, err) + } + } +} + +func TestProposalStatesIsExactlyThree(t *testing.T) { + got := ProposalStates() + if len(got) != 3 { + t.Fatalf("ProposalStates() = %v, want exactly open/merged/rejected", got) + } + want := []ProposalState{StateOpen, StateMerged, StateRejected} + for i := range want { + if got[i] != want[i] { + t.Fatalf("ProposalStates() = %v, want %v", got, want) + } + } +} + +func TestProposalTransitions(t *testing.T) { + all := []ProposalState{StateOpen, StateMerged, StateRejected} + + // The whole table, explicitly: only the two edges out of `open` exist. + want := map[ProposalState]map[ProposalState]bool{ + StateOpen: {StateOpen: false, StateMerged: true, StateRejected: true}, + StateMerged: {StateOpen: false, StateMerged: false, StateRejected: false}, + StateRejected: {StateOpen: false, StateMerged: false, StateRejected: false}, + } + + for _, from := range all { + for _, to := range all { + t.Run(string(from)+"->"+string(to), func(t *testing.T) { + expect := want[from][to] + if got := ValidTransition(from, to); got != expect { + t.Fatalf("ValidTransition(%s, %s) = %v, want %v", from, to, got, expect) + } + err := from.CanTransitionTo(to) + if (err == nil) != expect { + t.Fatalf("%s.CanTransitionTo(%s) = %v, want ok=%v", from, to, err, expect) + } + if err != nil && !errors.Is(err, ErrInvalidTransition) { + t.Fatalf("error %v is not ErrInvalidTransition", err) + } + }) + } + } +} + +func TestCanTransitionToRejectsUnknownStates(t *testing.T) { + // An unparseable state is a different failure class from an illegal edge: + // it means something wrote garbage into the proposal row. + if err := ProposalState("approved").CanTransitionTo(StateMerged); !errors.Is(err, ErrInvalidState) { + t.Fatalf("from=approved: %v, want ErrInvalidState", err) + } + if err := StateOpen.CanTransitionTo("approved"); !errors.Is(err, ErrInvalidState) { + t.Fatalf("to=approved: %v, want ErrInvalidState", err) + } + if err := ProposalState("").CanTransitionTo(StateMerged); !errors.Is(err, ErrInvalidState) { + t.Fatalf("from=empty: %v, want ErrInvalidState", err) + } +} + +func TestProposalStateTerminal(t *testing.T) { + tests := []struct { + in ProposalState + want bool + }{ + {StateOpen, false}, + {StateMerged, true}, + {StateRejected, true}, + {"", false}, + {"approved", false}, + } + for _, tc := range tests { + if got := tc.in.Terminal(); got != tc.want { + t.Errorf("%q.Terminal() = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestParseApproval(t *testing.T) { + tests := []struct { + in string + ok bool + }{ + {"human", true}, + {"policy", true}, + + {"", false}, + {"auto", false}, + {"Human", false}, + {"human ", false}, + {"approved", false}, + } + for _, tc := range tests { + a, err := ParseApproval(tc.in) + if (err == nil) != tc.ok { + t.Errorf("ParseApproval(%q) = %q, %v, want ok=%v", tc.in, a, err, tc.ok) + } + if err != nil && !errors.Is(err, ErrInvalidApproval) { + t.Errorf("ParseApproval(%q) error %v is not ErrInvalidApproval", tc.in, err) + } + } +} + +// A policy-merged document must be distinguishable from a human-approved one: +// collapsing the two would quietly launder unreviewed agent output as blessed. +func TestApprovalKindsAreDistinct(t *testing.T) { + if ApprovalHuman == ApprovalPolicy { + t.Fatal("human and policy approval must not be the same value") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..fbdbee6edd06b177e9291f2c26dba3df3df50e4a --- /dev/null +++ b/go.mod @@ -0,0 +1,107 @@ +module sourcecraft.dev/bigbes/sr-ht-spec + +go 1.26.4 + +require ( + git.sr.ht/~bitfehler/brant v0.5.1 + github.com/blevesearch/bleve/v2 v2.6.0 + github.com/go-chi/chi/v5 v5.3.1 + github.com/go-git/go-git/v5 v5.19.1 + github.com/lib/pq v1.10.9 + github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/stretchr/testify v1.11.1 + github.com/yuin/goldmark v1.8.2 + go.bigb.es/auxilia v0.5.0 + gopkg.in/yaml.v3 v3.0.1 + sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c // indirect + git.sr.ht/~sircmpwn/getopt v1.0.0 // indirect + git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect + github.com/99designs/gqlgen v0.17.36 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect + github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/alexflint/go-arg v1.6.0 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/blevesearch/bleve_index_api v1.3.11 // indirect + github.com/blevesearch/geo v0.2.5 // indirect + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect + github.com/blevesearch/gtreap v0.1.1 // indirect + github.com/blevesearch/mmap-go v1.2.0 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.4.7 // indirect + github.com/blevesearch/segment v0.9.1 // indirect + github.com/blevesearch/snowballstem v0.9.0 // indirect + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect + github.com/blevesearch/vellum v1.2.0 // indirect + github.com/blevesearch/zapx/v11 v11.4.3 // indirect + github.com/blevesearch/zapx/v12 v12.4.3 // indirect + github.com/blevesearch/zapx/v13 v13.4.3 // indirect + github.com/blevesearch/zapx/v14 v14.4.3 // indirect + github.com/blevesearch/zapx/v15 v15.4.3 // indirect + github.com/blevesearch/zapx/v16 v16.3.4 // indirect + github.com/blevesearch/zapx/v17 v17.1.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-pgpmail v0.2.2 // indirect + github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect + github.com/emersion/go-smtp v0.21.3 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect + github.com/go-chi/cors v1.2.2 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kavu/go_reuseport v1.5.0 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mschoch/smat v0.2.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec // indirect + github.com/vektah/gqlparser/v2 v2.5.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.etcd.io/bbolt v1.4.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..87f49b4f2f099a8abdadebb6b947b279bf6ef349 --- /dev/null +++ b/go.sum @@ -0,0 +1,358 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +git.sr.ht/~bitfehler/brant v0.5.1 h1:emUbT5r1P0p2mgC/52NMAzGa2zw2To/Bim5ySeyGrks= +git.sr.ht/~bitfehler/brant v0.5.1/go.mod h1:XD6RmtKTOT/RL+2iQ+KBcPhLYAjeJWdLj3yfM5+7k/g= +git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c h1:v2opuaN0C5ZpuCifRNR9ZQ8V9IG+Ja80otK1MFj5RnI= +git.sr.ht/~sircmpwn/dowork v0.0.0-20241216125407-2b00aa42322c/go.mod h1:8neHEO3503w/rNtttnR0JFpQgM/GFhaafVwvkPsFIDw= +git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw= +git.sr.ht/~sircmpwn/getopt v1.0.0 h1:/pRHjO6/OCbBF4puqD98n6xtPEgE//oq5U8NXjP7ROc= +git.sr.ht/~sircmpwn/getopt v1.0.0/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw= +git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE= +git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA= +git.srht.bigb.es/~bigbes/core-go v0.0.0-20260718172441-c2c2f3848fa9 h1:S/CncMS83NIBz+V5OlZEHlnrl1Yd5I4C7hDQ7lzQsTM= +git.srht.bigb.es/~bigbes/core-go v0.0.0-20260718172441-c2c2f3848fa9/go.mod h1:JmathMemB+hBk1IgrMn6RuBGusd+fTg1s/X6rNVKXXU= +github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ9go= +github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw= +github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo= +github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= +github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blevesearch/bleve/v2 v2.6.0 h1:Cyd3dd4q5tCbOV8MnKUVRUDYMHOir9xn12NZzXVSEd4= +github.com/blevesearch/bleve/v2 v2.6.0/go.mod h1:gLmI8lWgHgrIYf7UpUX7JISI1CaqC6VScu46mHThuAY= +github.com/blevesearch/bleve_index_api v1.3.11 h1:x29vbV8OjWfLcrDVd7Lr1q+BkLNS0JWNEig0MCVnKH4= +github.com/blevesearch/bleve_index_api v1.3.11/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.5 h1:yJg9FX1oRwLnjXSXF+ECHfXFTF4diF02Ca/qUGVjJhE= +github.com/blevesearch/geo v0.2.5/go.mod h1:Jhq7WE2K6mJTx1xS44M2pUO6Io+wjCSHh1+co3YOgH4= +github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= +github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= +github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= +github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= +github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI= +github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0= +github.com/blevesearch/scorch_segment_api/v2 v2.4.7 h1:GlMzW08hcsM3DnLUxhyF/1PcDal1qtvvIuytuph5djw= +github.com/blevesearch/scorch_segment_api/v2 v2.4.7/go.mod h1://IJ7tG3QCf0cWW/aVSXqy77tc1AvLu3fcJLYEvOAFs= +github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= +github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= +github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= +github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= +github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0= +github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k= +github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY= +github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo= +github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE= +github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM= +github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU= +github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0= +github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg= +github.com/blevesearch/zapx/v17 v17.1.2 h1:avbOk2igaASNoiy0BE/jPgcxAnRI2PGeydeP4hg7Ikk= +github.com/blevesearch/zapx/v17 v17.1.2/go.mod h1:WQObxKrqUX7cd0G1GMvDfc/bmZzQvoy7APOPimx7DiI= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI= +github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY= +github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee h1:v6Eju/FhxsACGNipFEPBZZAzGr1F/jlRQr1qiBw2nEE= +github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk= +github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks= +github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw= +github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4= +github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.bigb.es/auxilia v0.5.0 h1:S5+btW6++4CQDOfAEZe1UxrXRl6nxtmu0rI3uIXwCaQ= +go.bigb.es/auxilia v0.5.0/go.mod h1:hBkJvydQRfmgSTR2U4PvYgJcZnh7Bj/otukrk14iJU4= +go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU= +sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=