tinyrelaycommit

Commit 9d68e8778c34

browse files at this commit | history

1diff --git a/AGENTS.md b/AGENTS.md
2index bdc0bdf..885e267 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -1,4 +1,4 @@
6 - The middle dot character (U+00B7) is disallowed in project text and interfaces.
7 - Write all documentation in AP style, with clear, concise organization. Documentation is a user-facing surface; omit incidental implementation notes.
8 - After completing work in this project, commit the changes, push them and deploy to Slate. Verify the deployment before reporting completion.
9-- Web UI markup carries no class attributes. Style ids, elements and custom element names in `internal/webui/style.css`, and render repeated or large data through the elements in `internal/webui/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript.
10+- Web UI markup carries no class attributes. Style ids, elements and custom element names in `tinyclient/style.css`, and render repeated or large data through the elements in `tinyclient/components.js` (`rpc-form`, `signed-form`, `publish-list`, `json-view`, `nostr-key`) and the `nostrEvent` and `jsonView` template partials. The shell is a rail, a content column, a panel and a command footer; `page.html` owns the rail and panel dispatch, each page template renders one `<section>` of content, and every page must render without JavaScript.
11diff --git a/Dockerfile b/Dockerfile
12index 2c3455f..9c319c6 100644
13--- a/Dockerfile
14+++ b/Dockerfile
15@@ -28,7 +28,7 @@ RUN apt-get update \
16 && mkdir -p /data \
17 && chown relay:relay /data
18 COPY --from=binary /out/tiny /usr/local/bin/tiny
19-COPY LICENSE THIRD_PARTY_NOTICES internal/webui/signer.js.license internal/webui/nostr-name.js.license /usr/share/doc/tiny/
20+COPY LICENSE THIRD_PARTY_NOTICES tinyclient/signer.js.license tinyclient/nostr-name.js.license /usr/share/doc/tiny/
21 USER relay
22 WORKDIR /home/relay
23 VOLUME ["/data"]
24diff --git a/Makefile b/Makefile
25index 38754f9..449470f 100644
26--- a/Makefile
27+++ b/Makefile
28@@ -1,7 +1,18 @@
29-.PHONY: test test-race test-internal-race benchmark verify web-test docker-test docker-build linux-test
30+.PHONY: build build-tiny build-tinygit build-tinyclient test test-race test-internal-race benchmark verify web-test docker-test docker-build linux-test
31
32 TEST_PACKAGES ?= ./...
33
34+build: build-tiny build-tinygit build-tinyclient
35+
36+build-tiny:
37+ go build -mod=readonly -o bin/tiny ./cmd/tiny
38+
39+build-tinygit:
40+ go build -mod=readonly -o bin/tinygit ./cmd/tinygit
41+
42+build-tinyclient:
43+ go build -mod=readonly -o bin/tinyclient ./cmd/tinyclient
44+
45 test:
46 go test -mod=readonly ./...
47
48diff --git a/README.md b/README.md
49index 3217e9f..f0a4420 100644
50--- a/README.md
51+++ b/README.md
52@@ -19,6 +19,20 @@ flowchart LR
53 tenant -- "inbox and outbox" --> relays((other relays))
54 ```
55
56+## Components
57+
58+The repository contains three executables in one Go module:
59+
60+| Component | Build target | Role |
61+| --- | --- | --- |
62+| `tinyrelay`, distributed as `tiny` | `make build-tiny` | Integrated relay host with Git hosting and the browser interface. |
63+| [`tinygit`](tinygit/README.md) | `make build-tinygit` | Standalone public Git host using signed repository metadata and refs. |
64+| [`tinyclient`](tinyclient/README.md) | `make build-tinyclient` | Standalone frontend connected to a compatible tinyrelay backend. |
65+
66+`make build` writes all three binaries to `bin/`. Go builds embed the checked-in browser assets and do not require Node.js. The integrated `tiny` command, wire formats and existing tenant data remain compatible. Standalone components have narrower capabilities: tinygit does not start a relay or private-repository service, and tinyclient needs a backend and preserves its public origin and tenant path.
67+
68+See [Architecture](docs/architecture.md) and [Module boundaries and protocol contracts](docs/module-contracts.md) for the current responsibilities and proposed follow-up boundaries.
69+
70 ## Run locally
71
72 Build and start the daemon:
73diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES
74index 6c98ce4..8dcda23 100644
75--- a/THIRD_PARTY_NOTICES
76+++ b/THIRD_PARTY_NOTICES
77@@ -2,7 +2,7 @@ tinyrelay third-party notices
78
79 nostr-tools browser signer bundle
80 ---------------------------------
81-The generated internal/webui/signer.js bundle is built from nostr-tools and
82+The generated tinyclient/signer.js bundle is built from nostr-tools and
83 its browser dependencies. nostr-tools is released under the Unlicense:
84
85 This is free and unencumbered software released into the public domain.
86@@ -40,14 +40,14 @@ THE SOFTWARE.
87
88 nostr-name browser bundle
89 -------------------------
90-The generated internal/webui/nostr-name.js bundle is built from
91+The generated tinyclient/nostr-name.js bundle is built from
92 nostr-web-components 0.2.2, @jsr/nostr__gadgets 0.1.9 and
93 @jsr/nostr__tools 2.23.0, as resolved by package-lock.json. The installed
94 nostr-web-components package identifies its license as the Unlicense. The
95 bundle also retains esbuild license comments for noble-curves and scure-base;
96 their installed package metadata identifies them as MIT licensed. The
97 corresponding installed license text is recorded in
98-internal/webui/nostr-name.js.license.
99+tinyclient/nostr-name.js.license.
100
101 Fixi BSD-0 notice
102 -----------------
103diff --git a/cmd/tinyclient/main.go b/cmd/tinyclient/main.go
104new file mode 100644
105index 0000000..f435045
106--- /dev/null
107+++ b/cmd/tinyclient/main.go
108@@ -0,0 +1,75 @@
109+// Command tinyclient runs the standalone, server-rendered relay website.
110+package main
111+
112+import (
113+ "context"
114+ "errors"
115+ "flag"
116+ "fmt"
117+ "io"
118+ "net"
119+ "net/http"
120+ "os"
121+ "os/signal"
122+ "syscall"
123+ "time"
124+
125+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
126+)
127+
128+func configure(args []string, output io.Writer) (*http.Server, error) {
129+ flags := flag.NewFlagSet("tinyclient", flag.ContinueOnError)
130+ flags.SetOutput(output)
131+ listen := flags.String("listen", "127.0.0.1:8081", "HTTP listen address")
132+ backend := flags.String("backend", "", "Upstream tinyrelay backend HTTP URL, including /r/<tenant> when used")
133+ public := flags.String("public-url", "", "Browser-facing HTTP(S) URL; preserve the relay's public origin and tenant path")
134+ if err := flags.Parse(args); err != nil {
135+ return nil, err
136+ }
137+ if flags.NArg() != 0 {
138+ return nil, errors.New("tinyclient accepts flags only")
139+ }
140+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: *backend, PublicURL: *public})
141+ if err != nil {
142+ return nil, err
143+ }
144+ return &http.Server{Addr: *listen, Handler: handler, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second}, nil
145+}
146+
147+func run(ctx context.Context, args []string, output io.Writer) error {
148+ server, err := configure(args, output)
149+ if err != nil {
150+ return err
151+ }
152+ listener, err := net.Listen("tcp", server.Addr)
153+ if err != nil {
154+ return err
155+ }
156+ defer listener.Close()
157+ fmt.Fprintf(output, "tinyclient listening on %s\n", listener.Addr())
158+ done := make(chan struct{})
159+ defer close(done)
160+ go func() {
161+ select {
162+ case <-ctx.Done():
163+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
164+ defer cancel()
165+ _ = server.Shutdown(shutdown)
166+ case <-done:
167+ }
168+ }()
169+ err = server.Serve(listener)
170+ if errors.Is(err, http.ErrServerClosed) {
171+ return nil
172+ }
173+ return err
174+}
175+
176+func main() {
177+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
178+ defer cancel()
179+ if err := run(ctx, os.Args[1:], os.Stderr); err != nil && !errors.Is(err, flag.ErrHelp) {
180+ fmt.Fprintln(os.Stderr, "tinyclient:", err)
181+ os.Exit(1)
182+ }
183+}
184diff --git a/cmd/tinyclient/main_test.go b/cmd/tinyclient/main_test.go
185new file mode 100644
186index 0000000..88a17df
187--- /dev/null
188+++ b/cmd/tinyclient/main_test.go
189@@ -0,0 +1,48 @@
190+package main
191+
192+import (
193+ "bytes"
194+ "net/http"
195+ "net/http/httptest"
196+ "strings"
197+ "testing"
198+
199+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
200+)
201+
202+func TestCommandRequiresBackendAndPublicOrigin(t *testing.T) {
203+ for _, args := range [][]string{nil, {"-backend", "http://relay.example"}, {"-backend", "http://relay.example", "-public-url", "not-a-url"}, {"-backend", "http://relay.example/r/one", "-public-url", "http://client.example/r/two"}} {
204+ if _, err := configure(args, &bytes.Buffer{}); err == nil {
205+ t.Fatalf("accepted incomplete or invalid configuration: %v", args)
206+ }
207+ }
208+}
209+
210+func TestCommandServesEmbeddedClient(t *testing.T) {
211+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212+ if r.URL.Path != tinyclient.BackendPath {
213+ t.Errorf("unexpected upstream page %s", r.URL.Path)
214+ http.NotFound(w, r)
215+ return
216+ }
217+ w.Header().Set("Content-Type", "application/json")
218+ if r.URL.Query().Get("method") != "" {
219+ _, _ = w.Write([]byte("null"))
220+ return
221+ }
222+ _, _ = w.Write([]byte(`{"policy":{"name":"Standalone frontend","features":{"pages":true}},"slug":"test","url":"http://client.example"}`))
223+ }))
224+ defer upstream.Close()
225+ server, err := configure([]string{"-listen", "127.0.0.1:0", "-backend", upstream.URL, "-public-url", "http://client.example"}, &bytes.Buffer{})
226+ if err != nil {
227+ t.Fatal(err)
228+ }
229+ response := httptest.NewRecorder()
230+ server.Handler.ServeHTTP(response, httptest.NewRequest("GET", "http://client.example/", nil))
231+ if response.Code != 200 || !strings.Contains(response.Body.String(), "Standalone frontend") {
232+ t.Fatalf("standalone command: %d %s", response.Code, response.Body.String())
233+ }
234+ if server.Addr != "127.0.0.1:0" {
235+ t.Fatalf("listen=%s", server.Addr)
236+ }
237+}
238diff --git a/cmd/tinygit/main.go b/cmd/tinygit/main.go
239new file mode 100644
240index 0000000..07fd6ad
241--- /dev/null
242+++ b/cmd/tinygit/main.go
243@@ -0,0 +1,88 @@
244+// Command tinygit serves signed repository metadata and native Git smart HTTP.
245+package main
246+
247+import (
248+ "context"
249+ "errors"
250+ "flag"
251+ "fmt"
252+ "io"
253+ "net"
254+ "net/http"
255+ "os"
256+ "os/exec"
257+ "os/signal"
258+ "syscall"
259+ "time"
260+
261+ "github.com/FelineStateMachine/tinyrelay/tinygit"
262+)
263+
264+func main() {
265+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
266+ defer stop()
267+ if err := run(ctx, os.Args[1:], os.Stdout, os.Stderr); err != nil {
268+ fmt.Fprintln(os.Stderr, "tinygit:", err)
269+ os.Exit(1)
270+ }
271+}
272+
273+func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
274+ flags := flag.NewFlagSet("tinygit", flag.ContinueOnError)
275+ flags.SetOutput(stderr)
276+ listen := flags.String("listen", "127.0.0.1:8082", "HTTP listen address (put TLS and request limits in front for public hosting)")
277+ data := flags.String("data", "./tinygit-data", "dedicated data directory for events.db and bare Git repositories")
278+ owner := flags.String("owner", "", "required repository owner's lowercase hex Nostr public key")
279+ publicURL := flags.String("public-url", "", "canonical public HTTP(S) URL")
280+ if err := flags.Parse(args); err != nil {
281+ if errors.Is(err, flag.ErrHelp) {
282+ return nil
283+ }
284+ return err
285+ }
286+ if flags.NArg() != 0 {
287+ return errors.New("unexpected positional arguments")
288+ }
289+ if _, err := exec.LookPath("git"); err != nil {
290+ return fmt.Errorf("native Git is required: %w", err)
291+ }
292+ handler, err := tinygit.OpenServer(ctx, tinygit.ServerConfig{DataDir: *data, Owner: *owner, PublicURL: *publicURL})
293+ if err != nil {
294+ return err
295+ }
296+ defer handler.Close()
297+ listener, err := net.Listen("tcp", *listen)
298+ if err != nil {
299+ return err
300+ }
301+ defer listener.Close()
302+ server := &http.Server{
303+ Handler: handler,
304+ ReadHeaderTimeout: 10 * time.Second,
305+ IdleTimeout: 60 * time.Second,
306+ MaxHeaderBytes: 1 << 20,
307+ BaseContext: func(net.Listener) context.Context { return ctx },
308+ }
309+ done := make(chan error, 1)
310+ go func() { done <- server.Serve(listener) }()
311+ fmt.Fprintln(stdout, "tinygit listening on http://"+listener.Addr().String())
312+ select {
313+ case err := <-done:
314+ if errors.Is(err, http.ErrServerClosed) {
315+ return nil
316+ }
317+ return err
318+ case <-ctx.Done():
319+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
320+ defer cancel()
321+ err := server.Shutdown(shutdown)
322+ if err != nil {
323+ _ = server.Close()
324+ }
325+ serveErr := <-done
326+ if errors.Is(serveErr, http.ErrServerClosed) {
327+ serveErr = nil
328+ }
329+ return errors.Join(err, serveErr)
330+ }
331+}
332diff --git a/cmd/tinygit/main_integration_test.go b/cmd/tinygit/main_integration_test.go
333new file mode 100644
334index 0000000..b1cd30c
335--- /dev/null
336+++ b/cmd/tinygit/main_integration_test.go
337@@ -0,0 +1,191 @@
338+package main
339+
340+import (
341+ "bufio"
342+ "bytes"
343+ "context"
344+ "encoding/json"
345+ "fmt"
346+ "io"
347+ "net/http"
348+ "os"
349+ "os/exec"
350+ "path/filepath"
351+ "strings"
352+ "testing"
353+ "time"
354+
355+ "github.com/FelineStateMachine/tinyrelay/internal/event"
356+)
357+
358+// This is deliberately a subprocess acceptance test: it proves cmd/tinygit is
359+// a real standalone server, not only an embeddable HTTP handler.
360+func TestStandaloneBinarySignedPushCloneAndRestart(t *testing.T) {
361+ if testing.Short() {
362+ t.Skip("builds and runs the standalone binary and native Git")
363+ }
364+ root := t.TempDir()
365+ binary := filepath.Join(root, "tinygit")
366+ build := exec.Command("go", "build", "-o", binary, ".")
367+ if out, err := build.CombinedOutput(); err != nil {
368+ t.Fatalf("build standalone binary: %v\n%s", err, out)
369+ }
370+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
371+ defer cancel()
372+ secret := strings.Repeat("0", 63) + "1"
373+ owner, err := event.PublicKey(secret)
374+ if err != nil {
375+ t.Fatal(err)
376+ }
377+ data := filepath.Join(root, "data")
378+ start := func() (string, func()) {
379+ t.Helper()
380+ cmd := exec.CommandContext(ctx, binary, "-listen", "127.0.0.1:0", "-data", data, "-owner", owner)
381+ stdout, err := cmd.StdoutPipe()
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ cmd.Stderr = os.Stderr
386+ if err := cmd.Start(); err != nil {
387+ t.Fatal(err)
388+ }
389+ done := make(chan error, 1)
390+ go func() { done <- cmd.Wait() }()
391+ stopped := false
392+ stop := func() {
393+ t.Helper()
394+ if stopped {
395+ return
396+ }
397+ stopped = true
398+ _ = cmd.Process.Signal(os.Interrupt)
399+ select {
400+ case err := <-done:
401+ if err != nil {
402+ t.Errorf("server exit: %v", err)
403+ }
404+ case <-time.After(10 * time.Second):
405+ _ = cmd.Process.Kill()
406+ <-done
407+ t.Error("server did not shut down")
408+ }
409+ }
410+ t.Cleanup(stop)
411+ line := make(chan string, 1)
412+ go func() {
413+ scanner := bufio.NewScanner(stdout)
414+ if scanner.Scan() {
415+ line <- scanner.Text()
416+ } else {
417+ line <- ""
418+ }
419+ }()
420+ var address string
421+ select {
422+ case ready := <-line:
423+ const prefix = "tinygit listening on "
424+ if !strings.HasPrefix(ready, prefix) {
425+ t.Fatalf("startup: %q", ready)
426+ }
427+ address = strings.TrimPrefix(ready, prefix)
428+ case <-ctx.Done():
429+ t.Fatal(ctx.Err())
430+ }
431+ res, err := http.Get(address + "/healthz")
432+ if err != nil {
433+ t.Fatal(err)
434+ }
435+ res.Body.Close()
436+ if res.StatusCode != http.StatusOK {
437+ t.Fatalf("health = %d", res.StatusCode)
438+ }
439+ return address, stop
440+ }
441+ address, stop := start()
442+ post := func(e event.Event) bool {
443+ t.Helper()
444+ if err := event.Sign(&e, secret); err != nil {
445+ t.Fatal(err)
446+ }
447+ body, err := json.Marshal(e)
448+ if err != nil {
449+ t.Fatal(err)
450+ }
451+ res, err := http.Post(address+"/events", "application/json", bytes.NewReader(body))
452+ if err != nil {
453+ t.Fatal(err)
454+ }
455+ defer res.Body.Close()
456+ var result struct {
457+ ID string `json:"id"`
458+ Pending bool `json:"pending"`
459+ }
460+ if res.StatusCode != http.StatusOK {
461+ body, _ := io.ReadAll(res.Body)
462+ t.Fatalf("publish = %d: %s", res.StatusCode, body)
463+ }
464+ if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
465+ t.Fatal(err)
466+ }
467+ if result.ID != e.ID {
468+ t.Fatalf("event ID = %q, want %q", result.ID, e.ID)
469+ }
470+ return result.Pending
471+ }
472+ git := func(dir string, wantSuccess bool, args ...string) string {
473+ t.Helper()
474+ cmd := exec.CommandContext(ctx, "git", args...)
475+ cmd.Dir = dir
476+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.invalid", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.invalid")
477+ out, err := cmd.CombinedOutput()
478+ if (err == nil) != wantSuccess {
479+ t.Fatalf("git %v: %v\n%s", args, err, out)
480+ }
481+ return strings.TrimSpace(string(out))
482+ }
483+ work := filepath.Join(root, "work")
484+ if err := os.Mkdir(work, 0700); err != nil {
485+ t.Fatal(err)
486+ }
487+ git(work, true, "init", "-b", "main")
488+ if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("standalone Git\n"), 0600); err != nil {
489+ t.Fatal(err)
490+ }
491+ git(work, true, "add", "hello.txt")
492+ git(work, true, "commit", "-m", "first")
493+ oid := git(work, true, "rev-parse", "HEAD")
494+ post(event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}}})
495+ cloneURL := func() string {
496+ return address + "/npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d/demo.git"
497+ }
498+ git(work, false, "push", cloneURL(), "main")
499+ state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", oid}}}
500+ if !post(state) {
501+ t.Fatal("missing objects were not staged as pending")
502+ }
503+ t.Log(git(work, true, "push", cloneURL(), "main"))
504+ if post(state) {
505+ t.Fatal("receive-pack did not promote pending state")
506+ }
507+ git(work, true, "commit", "--allow-empty", "-m", "unsigned change")
508+ git(work, false, "push", cloneURL(), "main")
509+ git(work, false, "push", cloneURL(), "HEAD:refs/heads/unauthorized")
510+ verifyClone := func(name string) {
511+ t.Helper()
512+ dest := filepath.Join(root, name)
513+ t.Log(git(root, true, "clone", cloneURL(), dest))
514+ if got := git(dest, true, "rev-parse", "HEAD"); got != oid {
515+ t.Fatalf("cloned HEAD = %s, want %s", got, oid)
516+ }
517+ body, err := os.ReadFile(filepath.Join(dest, "hello.txt"))
518+ if err != nil || string(body) != "standalone Git\n" {
519+ t.Fatalf("clone content = %q (%v)", body, err)
520+ }
521+ }
522+ verifyClone("clone")
523+ stop()
524+ address, stop = start()
525+ defer stop()
526+ verifyClone("restarted-clone")
527+ t.Log(fmt.Sprintf("verified signed push, rejected unsigned pushes, clone and durable restart at %s", oid))
528+}
529diff --git a/docs/architecture.md b/docs/architecture.md
530index d0a7eec..78a202f 100644
531--- a/docs/architecture.md
532+++ b/docs/architecture.md
533@@ -1,5 +1,7 @@
534 # Architecture
535
536+`tinyrelay` assembles the relay host. `tinygit` owns Git functionality, and `tinyclient` owns the frontend. These functional modules share one repository and Go module. See [Module boundaries and protocol contracts](module-contracts.md) for ownership rules, extension documentation and proposed follow-up seams.
537+
538 Each tenant has a SQLite store, a policy snapshot and a set of services. The daemon constructs those services, connects their contracts and manages their lifetimes. HTTP, WebSocket and management adapters establish caller identity and enter the tenant's operation gate before calling services.
539
540 ## Find your starting point
541@@ -16,9 +18,9 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
542 | [`community`](../internal/community/doc.go), [`communityread`](../internal/communityread/doc.go) | Membership, moderation, rooms, agents and the read models consumed by records. |
543 | [`records`](../internal/records/doc.go) | Relay identity, signed protocol records, projections and notifications. |
544 | [`blob`](../internal/blob/doc.go), [`sites`](../internal/sites/doc.go) | Content-addressed files, upload rules and sites backed by signed manifests. |
545-| [`gitrelay`](../internal/gitrelay/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |
546+| [`tinygit`](../tinygit/doc.go) | Repository admission, object storage, synchronization, repair and Git HTTP endpoints. |
547 | [`replication`](../internal/replication/doc.go), [`syncprotocol`](../internal/syncprotocol/doc.go) | Relay synchronization plans and transports, count sketches and reconciliation sessions. |
548-| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`webui`](../internal/webui/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |
549+| [`relay`](../internal/relay/doc.go), [`mcp`](../internal/mcp/doc.go), [`tinyclient`](../tinyclient/doc.go) | WebSocket sessions, MCP requests, HTML pages and browser interactions. |
550 | [`agentrunner`](../internal/agentrunner) | The optional agent process: room mentions, durable queue recovery, ACP or command execution, and permission prompts. |
551 | [`views`](../internal/views/doc.go), [`wiki`](../internal/wiki/doc.go), [`webpush`](../internal/webpush/doc.go) | Fenced-block parsing, article rendering and encrypted browser push delivery. |
552 | [`seedmark`](../internal/seedmark) | Deterministic decorative SVG avatars for people and rooms without a profile picture. |
553@@ -32,7 +34,7 @@ Each tenant has a SQLite store, a policy snapshot and a set of services. The dae
554
555 Services own the schema and data access for their features. `storage.Save` and `storage.SaveTx` accept transaction hooks so event data, projections and work intents can commit together. APIs that receive `*sql.Tx` use the caller's transaction. Community event handlers that accept a persistence callback open the transaction and pass it into the callback.
556
557-Consumers declare interfaces around the operations they need. `records.CommunityReader` reads membership and moderation projections through `communityread` values. `relay.Backend` supplies event operations to the WebSocket router. `webui.Backend` supplies management results, and `webui.RoomsReader` supplies typed room pages. The daemon implements these adapters and assembles the feature handlers used by `work.Worker`.
558+Consumers declare interfaces around the operations they need. `records.CommunityReader` reads membership and moderation projections through `communityread` values. `relay.Backend` supplies event operations to the WebSocket router. `tinyclient.Backend` supplies management results, and `tinyclient.RoomsReader` supplies typed room pages. The daemon implements these adapters and assembles the feature handlers used by `work.Worker`.
559
560 ## Event acceptance
561
562diff --git a/docs/extensions/README.md b/docs/extensions/README.md
563new file mode 100644
564index 0000000..d37806d
565--- /dev/null
566+++ b/docs/extensions/README.md
567@@ -0,0 +1,9 @@
568+# Project extensions
569+
570+This catalog indexes project-defined interoperability contracts. An extension is not a claim of upstream NIP, GRASP or Blossom standardization. See [Module boundaries and protocol contracts](../module-contracts.md) for the distinction between upstream rules, supported profiles, project extensions and implementation details.
571+
572+| Identifier | Owner | Status | Specification |
573+| --- | --- | --- | --- |
574+| `tinyclient-read-adapter` | `tinyclient` contract and tinyrelay authorization | Experimental | [Read-only frontend/backend HTTP adapter](tinyclient-read-adapter.md) |
575+
576+The catalog is not yet a complete inventory of existing project-specific behavior. Agent grants and requests, callbacks, custom-view transforms and other custom bindings remain documented in their feature guides pending a wire-contract audit. Add entries as those contracts are checked against their implementation and tests; do not infer compliance or compatibility from a feature's name.
577diff --git a/docs/extensions/tinyclient-read-adapter.md b/docs/extensions/tinyclient-read-adapter.md
578new file mode 100644
579index 0000000..8a1f0f1
580--- /dev/null
581+++ b/docs/extensions/tinyclient-read-adapter.md
582@@ -0,0 +1,83 @@
583+# Tinyclient read adapter
584+
585+| Field | Value |
586+| --- | --- |
587+| Identifier | `tinyclient-read-adapter` |
588+| Status | Experimental |
589+| Contract revision | 1, tied to this repository revision; no version negotiation |
590+| Owner | `tinyclient` defines the presentation contract; the tinyrelay backend owns data and authorization. |
591+| Transport | HTTP GET, JSON success bodies, plain-text error bodies. |
592+| Endpoint | `/api/tinyclient`, under the selected tenant prefix when present. |
593+
594+This project extension supports a separately running frontend. It is not a Nostr NIP, a general relay query protocol or a replacement for signed mutation APIs. Run compatible frontend and backend revisions together. No event kinds, tags or signing formats are introduced.
595+
596+## Authentication and scope
597+
598+The backend resolves the caller using its normal actor resolver. A public-key query parameter or header is not authentication. The standalone frontend forwards the browser session cookie when loading the presentation snapshot. For method and typed reads, an empty requested actor means anonymous access: the frontend omits the cookie even if the browser is signed in. A nonempty requested actor must match the actor authenticated in that request's snapshot; a mismatch fails before contacting the backend. Every backend query and typed read still resolves authentication and applies its own access checks.
599+
600+Keep the frontend's public origin and tenant path the same as the backend's configured public URL. The private backend address may differ. Existing NIP-98 proofs remain bound to their original URL, method and payload; the frontend proxies signed requests without translating or replaying them as adapter reads. Signed HTML requests use the integrated renderer.
601+
602+Responses carry `Cache-Control: private, no-store`. The frontend must not cache private snapshots for use after an authorization or backend failure. It refuses an unexpected public Host and does not follow redirects when fetching read-adapter responses.
603+
604+## Request forms
605+
606+All forms use GET. If `op` is nonempty it selects a typed read before `method` is considered.
607+
608+### Presentation snapshot
609+
610+With no `op` or `method`, the response is a JSON object with:
611+
612+- `policy`: Presentation policy, using the public `Policy` structure.
613+- `url`, `slug`, `identity`: Relay presentation identity.
614+- `actor`: Public key resolved by the backend, or an empty string for an anonymous caller.
615+- `version`, `revision`: Backend build metadata.
616+- `rooms`, `activity`: Whether the backend provides the corresponding optional typed interfaces.
617+- `share_presence`: The authenticated caller's presence preference.
618+- `views`: Optional custom-view presentation definitions.
619+- `read_error`: Optional private-access failure used to render a restricted page.
620+
621+Nonowners receive a reduced policy without owner-only operational settings. A caller barred from private relay reads receives a masked presentation snapshot with `read_error`, rather than private relay identity or configuration. The snapshot does not grant permission to perform later reads.
622+
623+The authoritative field definitions are [`remoteSnapshot`](../../tinyclient/remote.go), [`Policy`](../../tinyclient/contracts.go) and their referenced public types. Readers should tolerate additional object fields. Changes to existing field meanings require a contract revision and compatibility review.
624+
625+### Method reads
626+
627+Supply `method=<name>` and optional `params=<JSON-array>`. Method names must appear in the read-only allowlist in [`remoteReadMethods`](../../tinyclient/remote.go). The adapter passes the decoded parameters and authenticated actor to `Backend.Query` and returns its JSON result directly, without a JSON-RPC envelope.
628+
629+Methods cover presentation reads such as repository browsing, wiki, rooms, social content, approvals and management lists. The allowlist does not override method authorization. Mutations are never dispatched through this contract. Method parameter and result semantics remain those of the corresponding backend read operation; this revision is coupled to the repository's backend contracts rather than a standalone third-party API.
630+
631+### Typed reads
632+
633+| `op` | Parameters | Backend contract |
634+| --- | --- | --- |
635+| `rooms` | `cursor`, `limit` | `RoomsReader.ListRooms` |
636+| `room` | `room`, `cursor`, `limit` | `RoomsReader.ReadRoom` |
637+| `thread` | `room`, `root`, `cursor`, `limit` | `RoomsReader.ReadThread` |
638+| `activity` | `room`, `root` | `ChatActivityReader.ReadChatActivity` |
639+
640+Results use the typed interfaces in [`read_contracts.go`](../../tinyclient/read_contracts.go) and [`chat-activity.go`](../../tinyclient/chat-activity.go). The backend owns room access, cursor validation and limit bounds. The current adapter converts an absent or unparseable limit to zero; callers should send a valid decimal limit rather than rely on that fallback.
641+
642+## Errors
643+
644+| Status | Meaning |
645+| --- | --- |
646+| 200 | A successful JSON read, including a deliberately masked private snapshot. |
647+| 400 | A method outside the allowlist or malformed JSON method parameters. |
648+| 401 | The actor resolver rejected authentication. |
649+| 403 | Private access or a backend operation was refused; also used for an unsupported typed `op`. |
650+| 405 | Non-GET method; response includes `Allow: GET`. |
651+| 501 | Requested typed interface is unavailable. |
652+
653+Do not parse plain-text errors as stable machine identifiers. These statuses describe the current binding, not a universal error model for the project. The standalone frontend returns 502 if it cannot load a valid snapshot, rather than rendering stale private data.
654+
655+## Discovery and compatibility
656+
657+The endpoint is configured as part of a compatible tinyrelay deployment; it is not advertised as a NIP capability. `rooms` and `activity` describe optional read interfaces, not full protocol compliance. Clients that do not use the adapter continue using the integrated website and existing relay interfaces.
658+
659+The adapter does not migrate sessions across origins, accept another tenant path, own a database or hold a relay signing key. Each standalone frontend process serves one tenant. A root-mounted frontend returns 404 for reserved `/r/<name>` paths before rendering or forwarding any request, including signed requests and WebSocket upgrades. A prefixed frontend accepts only its configured tenant path. Unknown method names fail closed; adding a method requires an authorization review and tests before it enters the allowlist.
660+
661+## Verification
662+
663+The contract is exercised by [`remote_auth_test.go`](../../tinyclient/remote_auth_test.go), [`remote_rooms_test.go`](../../tinyclient/remote_rooms_test.go), [`remote_proxy_test.go`](../../tinyclient/remote_proxy_test.go), [`remote_browser_test.go`](../../tinyclient/remote_browser_test.go) and the [real-backend integration test](../../internal/daemon/tinyclient_integration_test.go).
664+
665+Tests cover valid reads, invalid methods, forged actors, private snapshots, session revocation, signed operation forwarding and frontend rendering parity. Regression coverage in [`remote_feed_test.go`](../../tinyclient/remote_feed_test.go), [`remote_actor_test.go`](../../tinyclient/remote_actor_test.go) and [`remote_scope_test.go`](../../tinyclient/remote_scope_test.go) verifies anonymous feeds with a browser session, actor preservation across method and typed reads, and tenant isolation before rendering or proxying. Run the commands in the [tinyclient guide](../../tinyclient/README.md#test-and-rebuild-browser-bundles).
666diff --git a/docs/fixi.md b/docs/fixi.md
667index bf81172..6594b03 100644
668--- a/docs/fixi.md
669+++ b/docs/fixi.md
670@@ -1,6 +1,6 @@
671 # Vendored Fixi integration
672
673-The web UI vendors the all-in-one Fixi bundle at `internal/webui/fixi.js`. It is copied from [the-fixi-project](https://github.com/bigskysoftware/the-fixi-project) commit `71307c9694dff3cc7f2aef087cb57b44db4bdd17` (retrieved 2026-09-07). The upstream project documents the bundle and APIs at [fixiproject.org/llms.txt](https://fixiproject.org/llms.txt). Upstream declares BSD-0 (Zero-Clause BSD) for all five libraries; see its [README license section](https://github.com/bigskysoftware/the-fixi-project#license).
674+The web UI vendors the all-in-one Fixi bundle at `tinyclient/fixi.js`. It is copied from [the-fixi-project](https://github.com/bigskysoftware/the-fixi-project) commit `71307c9694dff3cc7f2aef087cb57b44db4bdd17` (retrieved 2026-09-07). The upstream project documents the bundle and APIs at [fixiproject.org/llms.txt](https://fixiproject.org/llms.txt). Upstream declares BSD-0 (Zero-Clause BSD) for all five libraries; see its [README license section](https://github.com/bigskysoftware/the-fixi-project#license).
675
676 The vendored file's SHA-256 is:
677
678diff --git a/docs/module-contracts.md b/docs/module-contracts.md
679new file mode 100644
680index 0000000..3c8f790
681--- /dev/null
682+++ b/docs/module-contracts.md
683@@ -0,0 +1,81 @@
684+# Module boundaries and protocol contracts
685+
686+The project is a monorepo. `tinyrelay` is the relay host, `tinygit` is the Git engine and standalone Git service, and `tinyclient` is the browser interface and standalone frontend. Separate executables do not imply separate Go modules, databases or network services for every package.
687+
688+The Git and frontend extraction establishes the first boundaries. The additional boundaries below are proposed follow-up work, not claims that those packages have already been extracted.
689+
690+## Keep protocol rules separate from access policy
691+
692+Apply checks in this order:
693+
694+1. Parse the wire representation and verify its signature or request proof.
695+2. Validate the feature's protocol structure and semantics.
696+3. Apply host policy: membership, grants, visibility, quotas and enabled features.
697+4. Commit the event, feature projection and required work together.
698+5. Expose the result through the relevant transport and read model.
699+
700+A valid Nostr event can still be refused by host policy. A policy refusal is not evidence of an invalid signature or a protocol violation. Conversely, an owner or maintainer role must never make a malformed signed event valid.
701+
702+The client can validate a draft for feedback and verify a signer's result, but server-side validation remains authoritative. HTTP, WebSocket and MCP adapters must use the same feature operation rather than implement separate admission rules.
703+
704+## Recommended seams
705+
706+| Boundary | Owns | Must not own |
707+| --- | --- | --- |
708+| Shared Nostr primitives, a proposed public `protocol/nostr` package | Event and filter types, canonical serialization, event IDs, signatures and common encoding rules. | Tenant configuration, membership, SQL or feature-specific tags. |
709+| Request-proof adapters | Verification for HTTP, WebSocket and service-specific proof profiles; authenticated actor and proof scope. | Deciding what that actor may do. |
710+| Host access policy | Membership, agent grants, visibility, quotas and admission decisions. | Reinterpreting signed fields or weakening feature protocol validation. |
711+| `tinygit` | Git repository metadata, signed refs, object storage, smart HTTP, repair and Git-specific protocol rules. | Rooms, browser sessions or general relay management. |
712+| `tinyclient` | Server-rendered pages, browser interactions, draft construction and signer-result verification. | Authoritative permissions, repository mutation or event persistence. |
713+| Blob service, a candidate for a later `tinyblob` extraction | Content-addressed storage, upload and download contracts, integrity and applicable Blossom behavior. | Site manifests, room membership or a frontend. |
714+| Site service | Signed site manifests, routing and site lifecycle over a blob-store contract. | Owning another copy of blob storage or upload authorization. |
715+| Room service | Room state and message projections, with the room protocols it implements. | General agent grants or all community administration. |
716+| Host assembly | Tenant lifecycle, transaction coordination, worker ownership and adapter wiring. | A second implementation of feature rules. |
717+
718+Keep domain-specific protocol rules with their feature. Do not create a universal `nips` package containing every numbered NIP: that would recreate the coupling under a new name. A NIP that crosses authentication, transport and persistence boundaries should have one documented ownership map and shared conformance fixtures, not one oversized implementation package.
719+
720+`records` and `community` are candidates for narrower internal services before another standalone executable. Their existing contracts span several responsibilities; splitting by durable state and operation ownership is more useful than renaming the packages.
721+
722+## Define the contract before moving dependencies
723+
724+Each public feature boundary should document:
725+
726+- Inputs, outputs and stable error categories.
727+- The authenticated actor and authorization context it accepts.
728+- Which protocol checks it performs and which policy decisions the host supplies.
729+- Whether it owns a transaction or joins one supplied by the host.
730+- Idempotency, ordering and retry behavior.
731+- Cancellation, resource ownership and shutdown.
732+- Side effects and the point at which they become visible.
733+- Capability differences between standalone and integrated operation.
734+
735+Use small, consumer-owned interfaces for access decisions, persistence and optional transports. Prefer feature-specific read models to a universal backend interface. Moving packages must not break the atomicity of event persistence, projections and durable work.
736+
737+During the extraction, `tinygit` still shares internal event, policy and SQLite implementations with the host. Public aliases can preserve source compatibility, but they are transitional plumbing, not a substitute for a small public protocol and persistence contract. Independent builds are a first milestone; a dependency boundary check is the next one.
738+
739+## Describe standards and project extensions separately
740+
741+Protocol documentation should classify each behavior as one of:
742+
743+- **Upstream protocol:** Behavior specified by a named NIP, GRASP specification, Blossom document or other upstream specification. Cite the document and the revision used by conformance tests.
744+- **Supported profile:** The subset or combination implemented by a particular executable, with omissions and required options stated explicitly. Do not imply full compliance when required behavior is absent.
745+- **Project extension:** Additional wire behavior defined by this project. Describe how an ordinary client behaves without it and how peers discover support.
746+- **Local implementation detail:** Storage paths, queues, indexes or internal APIs that are not an interoperability contract.
747+
748+For each project extension, create a specification under `docs/extensions/` when its behavior has been audited. Each specification should include a stable identifier, status, version, owning module, upstream references, exact event kinds and tag shapes or endpoint schemas, validation rules, authorization requirements, errors, discovery, security considerations and compatibility rules. Include valid and invalid fixtures and link the implementation and tests.
749+
750+The extension catalog should index those specifications rather than repeat their rules. Start by auditing agent grants and grant requests, callbacks, custom-view transforms and custom HTTP/MCP bindings. Existing behavior is not automatically standardized, and an implementation detail should not be promoted into a wire extension merely because it is unusual.
751+
752+Keep transport-specific method names in their adapters. A feature operation may have HTTP, MCP and browser bindings, but its domain contract and validation must have one owner. Generate discovery and schema checks from the same definitions where practical; do not create a parallel hand-maintained registry with different defaults.
753+
754+## Compatibility and terminology
755+
756+Use `tinyrelay` for the host, `tinygit` for Git functionality and `tinyclient` for frontend functionality in package documentation, command help and architecture descriptions. Reserve “module” for a functional boundary unless explicitly discussing a Go module.
757+
758+Do not apply blanket replacements to protocol or persisted identifiers. Existing event kinds, tags, endpoints, database schemas, Git hooks, `.tinyrelay` journal paths and `tiny.*` browser APIs remain compatibility surfaces until an explicit migration is designed and tested. A historical storage name can remain while surrounding documentation identifies its current owner accurately.
759+
760+## Verification
761+
762+Keep the existing feature tests when moving implementation files. Add external-consumer build checks, standalone service tests and dependency checks that reject importing the host assembly from a standalone component. Use shared protocol fixtures for server validation and browser construction where their responsibilities overlap.
763+
764+For each extraction, compare the integrated service with the baseline on the same fixtures. Cover valid and invalid events, authorization failures, persistence and restart, HTTP behavior and rendered content. Test the standalone profile separately and state unsupported features; a passing standalone subset is not full integrated parity.
765diff --git a/docs/testing.md b/docs/testing.md
766index 2656f74..f185889 100644
767--- a/docs/testing.md
768+++ b/docs/testing.md
769@@ -11,6 +11,20 @@ make test-race
770
771 The repository also contains a multistage Docker build. `make docker-test` runs `TEST_PACKAGES` (default `./...`) under Go 1.27.1 on Debian Bookworm with the race detector. `make docker-build` builds the self-hosted runtime image.
772
773+## Component checks
774+
775+Build all executables with `make build`. Run the focused component suites with:
776+
777+```sh
778+go test -race ./tinygit ./cmd/tinygit ./tinyclient ./cmd/tinyclient
779+go test ./internal/daemon -run TestStandaloneTinyclient
780+npm run test:tinyclient
781+```
782+
783+The tinygit command test exercises a real binary through signed metadata publication, Git push, clone, unauthorized-ref rejection and restart. The tinyclient integration test compares the standalone frontend with the integrated renderer using a real backend, including session authentication, signed writes, logout and private metadata masking. Public-consumer and dependency checks guard the package boundaries.
784+
785+Run `make verify` for the full integrated regression suite before shipping. Standalone tests verify the documented component profiles, not every integrated capability. During an extraction, also compare the baseline and refactored binaries using the same fixture data and check that moved browser assets and existing tests were retained. See the [module contracts](module-contracts.md#verification).
786+
787 ## Remote Linux host
788
789 `scripts/test-linux.sh` runs the same internal race test in an isolated Docker build on the configured Linux host. It synchronizes the exact worktree, including untracked files, over SSH into a unique directory under `~/tinyrelay-tests/` in the remote home directory; it never runs global Docker cleanup and never edits host configuration.
790@@ -88,6 +102,6 @@ The raw JSON records failures as failures; interrupted or absent phases are not
791
792 ## Git collaboration checks
793
794-Run `npm run test:collaboration` to check signed issue, reply, status and pull request events. Run `go test ./internal/daemon ./internal/gitrelay ./internal/replication ./internal/webui` for collaboration queries, access controls, synchronization, Git repair and page rendering.
795+Run `npm run test:collaboration` to check signed issue, reply, status and pull request events. Run `go test ./internal/daemon ./tinygit ./internal/replication ./tinyclient` for collaboration queries, access controls, synchronization, Git repair and page rendering.
796
797 The UX fixture includes an issue, a threaded comment, a status change and a pull request with a real Git diff. Use a disposable daemon when testing signed browser publishing.
798diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
799index aa7186a..a22651b 100644
800--- a/internal/daemon/client_browse.go
801+++ b/internal/daemon/client_browse.go
802@@ -15,7 +15,7 @@ import (
803 "github.com/FelineStateMachine/tinyrelay/internal/blob"
804 "github.com/FelineStateMachine/tinyrelay/internal/community"
805 "github.com/FelineStateMachine/tinyrelay/internal/event"
806- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
807+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
808 "github.com/FelineStateMachine/tinyrelay/internal/relay"
809 "github.com/FelineStateMachine/tinyrelay/internal/storage"
810 )
811diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go
812index 99836a3..ff03f3d 100644
813--- a/internal/daemon/client_browse_http.go
814+++ b/internal/daemon/client_browse_http.go
815@@ -12,7 +12,7 @@ import (
816 "time"
817
818 "github.com/FelineStateMachine/tinyrelay/internal/blob"
819- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
820+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
821 )
822
823 func (t *Tenant) tryBrowseHTTP(w http.ResponseWriter, r *http.Request) bool {
824diff --git a/internal/daemon/client_files_test.go b/internal/daemon/client_files_test.go
825index f2fdcbb..5ac7e06 100644
826--- a/internal/daemon/client_files_test.go
827+++ b/internal/daemon/client_files_test.go
828@@ -10,7 +10,7 @@ import (
829
830 "github.com/FelineStateMachine/tinyrelay/internal/blob"
831 "github.com/FelineStateMachine/tinyrelay/internal/event"
832- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
833+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
834 "github.com/FelineStateMachine/tinyrelay/internal/relay"
835 "github.com/FelineStateMachine/tinyrelay/internal/sites"
836 )
837diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go
838index f360475..71d7561 100644
839--- a/internal/daemon/collaboration_approvals.go
840+++ b/internal/daemon/collaboration_approvals.go
841@@ -18,7 +18,7 @@ import (
842
843 "github.com/FelineStateMachine/tinyrelay/internal/community"
844 "github.com/FelineStateMachine/tinyrelay/internal/event"
845- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
846+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
847 "github.com/FelineStateMachine/tinyrelay/internal/storage"
848 )
849
850diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
851index ce09854..1202ef2 100644
852--- a/internal/daemon/collaboration_browse.go
853+++ b/internal/daemon/collaboration_browse.go
854@@ -10,7 +10,7 @@ import (
855 "time"
856
857 "github.com/FelineStateMachine/tinyrelay/internal/event"
858- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
859+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
860 "github.com/FelineStateMachine/tinyrelay/internal/relay"
861 "github.com/FelineStateMachine/tinyrelay/internal/storage"
862 )
863diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
864index ee88ea9..fe76335 100644
865--- a/internal/daemon/custom_views.go
866+++ b/internal/daemon/custom_views.go
867@@ -23,7 +23,7 @@ import (
868
869 "github.com/FelineStateMachine/tinyrelay/internal/community"
870 "github.com/FelineStateMachine/tinyrelay/internal/event"
871- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
872+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
873 "github.com/FelineStateMachine/tinyrelay/internal/records"
874 "github.com/FelineStateMachine/tinyrelay/internal/storage"
875 "github.com/FelineStateMachine/tinyrelay/internal/telemetry"
876diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
877index 3d7151e..e24b758 100644
878--- a/internal/daemon/custom_views_transform.go
879+++ b/internal/daemon/custom_views_transform.go
880@@ -27,7 +27,7 @@ import (
881 "time"
882
883 "github.com/FelineStateMachine/tinyrelay/internal/event"
884- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
885+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
886 "github.com/FelineStateMachine/tinyrelay/internal/replication"
887 "github.com/FelineStateMachine/tinyrelay/internal/views"
888 "github.com/FelineStateMachine/tinyrelay/internal/work"
889diff --git a/internal/daemon/event_commit.go b/internal/daemon/event_commit.go
890index b7fd3d6..afd4de7 100644
891--- a/internal/daemon/event_commit.go
892+++ b/internal/daemon/event_commit.go
893@@ -7,7 +7,7 @@ import (
894
895 "github.com/FelineStateMachine/tinyrelay/internal/community"
896 "github.com/FelineStateMachine/tinyrelay/internal/event"
897- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
898+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
899 "github.com/FelineStateMachine/tinyrelay/internal/policy"
900 "github.com/FelineStateMachine/tinyrelay/internal/replication"
901 "github.com/FelineStateMachine/tinyrelay/internal/sites"
902diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go
903index 1912242..3f21251 100644
904--- a/internal/daemon/git_agent_maintainer_test.go
905+++ b/internal/daemon/git_agent_maintainer_test.go
906@@ -11,7 +11,7 @@ import (
907 "time"
908
909 "github.com/FelineStateMachine/tinyrelay/internal/event"
910- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
911+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
912 )
913
914 const testOwnerNPub = "npub10xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqpkge6d"
915diff --git a/internal/daemon/git_live.go b/internal/daemon/git_live.go
916index dcbc192..8ca0fdd 100644
917--- a/internal/daemon/git_live.go
918+++ b/internal/daemon/git_live.go
919@@ -9,7 +9,7 @@ import (
920 "time"
921
922 "github.com/FelineStateMachine/tinyrelay/internal/event"
923- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
924+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
925 "github.com/FelineStateMachine/tinyrelay/internal/replication"
926 "github.com/FelineStateMachine/tinyrelay/internal/storage"
927 )
928diff --git a/internal/daemon/git_live_test.go b/internal/daemon/git_live_test.go
929index e76804b..4e47a64 100644
930--- a/internal/daemon/git_live_test.go
931+++ b/internal/daemon/git_live_test.go
932@@ -7,7 +7,7 @@ import (
933 "time"
934
935 "github.com/FelineStateMachine/tinyrelay/internal/event"
936- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
937+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
938 )
939
940 func TestGitLiveEnabledRequiresGRASP02(t *testing.T) {
941diff --git a/internal/daemon/git_outbox_sync.go b/internal/daemon/git_outbox_sync.go
942index 4251c63..d6fcaa9 100644
943--- a/internal/daemon/git_outbox_sync.go
944+++ b/internal/daemon/git_outbox_sync.go
945@@ -7,7 +7,7 @@ import (
946 "time"
947
948 "github.com/FelineStateMachine/tinyrelay/internal/event"
949- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
950+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
951 "github.com/FelineStateMachine/tinyrelay/internal/replication"
952 "github.com/FelineStateMachine/tinyrelay/internal/storage"
953 )
954diff --git a/internal/daemon/git_payload_test.go b/internal/daemon/git_payload_test.go
955index 2885b98..89c20c7 100644
956--- a/internal/daemon/git_payload_test.go
957+++ b/internal/daemon/git_payload_test.go
958@@ -13,7 +13,7 @@ import (
959 "testing"
960
961 "github.com/FelineStateMachine/tinyrelay/internal/event"
962- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
963+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
964 )
965
966 type repeatedGitByte struct{}
967diff --git a/internal/daemon/git_services.go b/internal/daemon/git_services.go
968index c99d8d0..ce6aee3 100644
969--- a/internal/daemon/git_services.go
970+++ b/internal/daemon/git_services.go
971@@ -13,7 +13,7 @@ import (
972 "time"
973
974 "github.com/FelineStateMachine/tinyrelay/internal/event"
975- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
976+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
977 "github.com/FelineStateMachine/tinyrelay/internal/replication"
978 "github.com/FelineStateMachine/tinyrelay/internal/storage"
979 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
980diff --git a/internal/daemon/git_services_test.go b/internal/daemon/git_services_test.go
981index 81b001f..1e064e9 100644
982--- a/internal/daemon/git_services_test.go
983+++ b/internal/daemon/git_services_test.go
984@@ -4,7 +4,7 @@ import (
985 "strings"
986 "testing"
987
988- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
989+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
990 )
991
992 func TestGitEventFiltersCoverRepositoryAndCollaborationScopes(t *testing.T) {
993diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go
994index 0b8be48..64630ed 100644
995--- a/internal/daemon/git_sync_acceptance_test.go
996+++ b/internal/daemon/git_sync_acceptance_test.go
997@@ -11,7 +11,7 @@ import (
998 "time"
999
1000 "github.com/FelineStateMachine/tinyrelay/internal/event"
1001- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1002+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1003 "github.com/FelineStateMachine/tinyrelay/internal/relay"
1004 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1005 "github.com/coder/websocket"
1006diff --git a/internal/daemon/git_sync_cursor_test.go b/internal/daemon/git_sync_cursor_test.go
1007index 2ecb9de..a9b1835 100644
1008--- a/internal/daemon/git_sync_cursor_test.go
1009+++ b/internal/daemon/git_sync_cursor_test.go
1010@@ -12,7 +12,7 @@ import (
1011 "time"
1012
1013 "github.com/FelineStateMachine/tinyrelay/internal/event"
1014- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1015+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1016 "github.com/FelineStateMachine/tinyrelay/internal/replication"
1017 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1018 )
1019diff --git a/internal/daemon/git_sync_integration_test.go b/internal/daemon/git_sync_integration_test.go
1020index c3f077c..1bbafe7 100644
1021--- a/internal/daemon/git_sync_integration_test.go
1022+++ b/internal/daemon/git_sync_integration_test.go
1023@@ -9,7 +9,7 @@ import (
1024 "time"
1025
1026 "github.com/FelineStateMachine/tinyrelay/internal/event"
1027- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1028+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1029 "github.com/FelineStateMachine/tinyrelay/internal/replication"
1030 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1031 )
1032diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
1033index 85d0378..d7b6692 100644
1034--- a/internal/daemon/mcp_tools.go
1035+++ b/internal/daemon/mcp_tools.go
1036@@ -23,7 +23,7 @@ import (
1037 "github.com/FelineStateMachine/tinyrelay/internal/wiki"
1038 )
1039
1040-// The MCP tool table mirrors the browser tools in internal/webui/webmcp.js.
1041+// The MCP tool table mirrors the browser tools in tinyclient/webmcp.js.
1042 // Reads run through the browse methods, controls through the NIP-86
1043 // management path, and writes through the same publish path as POST /events,
1044 // so every tool is executed as the signed-in pubkey with the tenant's
1045diff --git a/internal/daemon/private_peer_acceptance_test.go b/internal/daemon/private_peer_acceptance_test.go
1046index 6d6f029..78bf7a7 100644
1047--- a/internal/daemon/private_peer_acceptance_test.go
1048+++ b/internal/daemon/private_peer_acceptance_test.go
1049@@ -14,7 +14,7 @@ import (
1050 "time"
1051
1052 "github.com/FelineStateMachine/tinyrelay/internal/event"
1053- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1054+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1055 "github.com/FelineStateMachine/tinyrelay/internal/relay"
1056 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1057 )
1058diff --git a/internal/daemon/replication_services.go b/internal/daemon/replication_services.go
1059index 6358a99..a79ea0e 100644
1060--- a/internal/daemon/replication_services.go
1061+++ b/internal/daemon/replication_services.go
1062@@ -22,7 +22,7 @@ import (
1063
1064 "github.com/FelineStateMachine/tinyrelay/internal/configport"
1065 "github.com/FelineStateMachine/tinyrelay/internal/event"
1066- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1067+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1068 "github.com/FelineStateMachine/tinyrelay/internal/policy"
1069 "github.com/FelineStateMachine/tinyrelay/internal/records"
1070 "github.com/FelineStateMachine/tinyrelay/internal/relay"
1071diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go
1072index 70096fa..da7d58b 100644
1073--- a/internal/daemon/repository_maintainers.go
1074+++ b/internal/daemon/repository_maintainers.go
1075@@ -4,7 +4,7 @@ import (
1076 "context"
1077 "time"
1078
1079- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1080+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1081 )
1082
1083 // Repository maintainers. The owner and the keys in an announcement's
1084diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go
1085index 4e7e74c..d49fafa 100644
1086--- a/internal/daemon/rooms_browse.go
1087+++ b/internal/daemon/rooms_browse.go
1088@@ -10,7 +10,7 @@ import (
1089 "github.com/FelineStateMachine/tinyrelay/internal/community"
1090 "github.com/FelineStateMachine/tinyrelay/internal/event"
1091 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1092- "github.com/FelineStateMachine/tinyrelay/internal/webui"
1093+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
1094 )
1095
1096 var (
1097diff --git a/internal/daemon/services.go b/internal/daemon/services.go
1098index 0f05137..9d49d68 100644
1099--- a/internal/daemon/services.go
1100+++ b/internal/daemon/services.go
1101@@ -21,7 +21,7 @@ import (
1102 "github.com/FelineStateMachine/tinyrelay/internal/community"
1103 "github.com/FelineStateMachine/tinyrelay/internal/configport"
1104 "github.com/FelineStateMachine/tinyrelay/internal/event"
1105- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1106+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1107 "github.com/FelineStateMachine/tinyrelay/internal/policy"
1108 "github.com/FelineStateMachine/tinyrelay/internal/records"
1109 "github.com/FelineStateMachine/tinyrelay/internal/relay"
1110@@ -29,7 +29,7 @@ import (
1111 "github.com/FelineStateMachine/tinyrelay/internal/sites"
1112 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1113 "github.com/FelineStateMachine/tinyrelay/internal/views"
1114- "github.com/FelineStateMachine/tinyrelay/internal/webui"
1115+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
1116 "github.com/FelineStateMachine/tinyrelay/internal/work"
1117 )
1118
1119diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
1120index 8ce2e04..f9a5159 100644
1121--- a/internal/daemon/tenant.go
1122+++ b/internal/daemon/tenant.go
1123@@ -19,7 +19,7 @@ import (
1124 "github.com/FelineStateMachine/tinyrelay/internal/configport"
1125 "github.com/FelineStateMachine/tinyrelay/internal/event"
1126 "github.com/FelineStateMachine/tinyrelay/internal/gates"
1127- "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
1128+ gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
1129 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
1130 "github.com/FelineStateMachine/tinyrelay/internal/policy"
1131 "github.com/FelineStateMachine/tinyrelay/internal/records"
1132@@ -29,7 +29,7 @@ import (
1133 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1134 "github.com/FelineStateMachine/tinyrelay/internal/syncprotocol"
1135 "github.com/FelineStateMachine/tinyrelay/internal/webpush"
1136- "github.com/FelineStateMachine/tinyrelay/internal/webui"
1137+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
1138 )
1139
1140 type tenantConfig struct {
1141diff --git a/internal/daemon/tinyclient_integration_test.go b/internal/daemon/tinyclient_integration_test.go
1142new file mode 100644
1143index 0000000..18108dd
1144--- /dev/null
1145+++ b/internal/daemon/tinyclient_integration_test.go
1146@@ -0,0 +1,109 @@
1147+package daemon
1148+
1149+import (
1150+ "context"
1151+ "encoding/json"
1152+ "io"
1153+ "net/http"
1154+ "net/http/httptest"
1155+ "net/url"
1156+ "strings"
1157+ "testing"
1158+
1159+ "github.com/FelineStateMachine/tinyrelay/tinyclient"
1160+)
1161+
1162+func TestStandaloneTinyclientAuthenticatesAgainstRealRelay(t *testing.T) {
1163+ _, tenant := testTenant(t)
1164+ upstream := httptest.NewServer(tenant)
1165+ defer upstream.Close()
1166+ handler, err := tinyclient.NewRemote(tinyclient.RemoteOptions{BackendURL: upstream.URL, PublicURL: "http://relay.test"})
1167+ if err != nil {
1168+ t.Fatal(err)
1169+ }
1170+ frontend := httptest.NewServer(handler)
1171+ defer frontend.Close()
1172+ frontendURL, _ := url.Parse(frontend.URL)
1173+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
1174+ issue := func(method, path, body string, cookie *http.Cookie, signed bool) (*http.Response, string) {
1175+ t.Helper()
1176+ request := httptest.NewRequest(method, "http://relay.test"+path, strings.NewReader(body))
1177+ request.Header.Set("Content-Type", "application/json")
1178+ request.Header.Set("Origin", "http://relay.test")
1179+ if cookie != nil {
1180+ request.AddCookie(cookie)
1181+ }
1182+ if signed {
1183+ signRequest(t, request, body)
1184+ }
1185+ request.URL.Scheme, request.URL.Host = frontendURL.Scheme, frontendURL.Host
1186+ request.RequestURI = ""
1187+ response, err := client.Do(request)
1188+ if err != nil {
1189+ t.Fatal(err)
1190+ }
1191+ raw, err := io.ReadAll(response.Body)
1192+ response.Body.Close()
1193+ if err != nil {
1194+ t.Fatal(err)
1195+ }
1196+ return response, string(raw)
1197+ }
1198+ login, body := issue("POST", "/session", "", nil, true)
1199+ if login.StatusCode != 200 || len(login.Cookies()) != 1 {
1200+ t.Fatalf("signed login: %d %s", login.StatusCode, body)
1201+ }
1202+ cookie := login.Cookies()[0]
1203+ if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode {
1204+ t.Fatalf("unsafe cookie: %+v", cookie)
1205+ }
1206+ for _, path := range []string{"/", "/account", "/manage/rules", "/chat", "/scripts/bridge.js", "/manifest.webmanifest"} {
1207+ response, body := issue("GET", path, "", cookie, false)
1208+ integrated := httptest.NewRecorder()
1209+ request := httptest.NewRequest("GET", "http://relay.test"+path, nil)
1210+ request.AddCookie(cookie)
1211+ tenant.ServeHTTP(integrated, request)
1212+ if response.StatusCode != integrated.Code || body != integrated.Body.String() {
1213+ t.Fatalf("standalone parity %s: status=%d want=%d body lengths=%d/%d", path, response.StatusCode, integrated.Code, len(body), integrated.Body.Len())
1214+ }
1215+ }
1216+ denied, _ := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, false)
1217+ if denied.StatusCode != 401 {
1218+ t.Fatalf("cookie-only write accepted: %d", denied.StatusCode)
1219+ }
1220+ signed, body := issue("POST", "/manage/rpc", `{"method":"stats"}`, cookie, true)
1221+ if signed.StatusCode != 200 {
1222+ t.Fatalf("signed management request lost URL binding: %d %s", signed.StatusCode, body)
1223+ }
1224+ // The new read bridge ignores claimed pubkeys and still requires a real session.
1225+ forged, body := issue("GET", tinyclient.BackendPath+"?actor="+tenant.Policy().Owner, "", nil, false)
1226+ var snapshot struct {
1227+ Actor string `json:"actor"`
1228+ }
1229+ if forged.StatusCode != 200 || json.Unmarshal([]byte(body), &snapshot) != nil || snapshot.Actor != "" {
1230+ t.Fatalf("forged actor accepted: %d %s", forged.StatusCode, body)
1231+ }
1232+ logout, body := issue("POST", "/session/logout", "", cookie, false)
1233+ if logout.StatusCode != 200 {
1234+ t.Fatalf("logout: %d %s", logout.StatusCode, body)
1235+ }
1236+ response, body := issue("GET", "/account", "", cookie, false)
1237+ if response.StatusCode != 200 || !strings.Contains(body, "Sign in to open your account.") {
1238+ t.Fatalf("revoked session survived: %d", response.StatusCode)
1239+ }
1240+ p := tenant.Policy()
1241+ p.Features.Grasp, p.Features.Grasp08, p.Reads, p.Name = true, true, "members", "private secret project"
1242+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
1243+ t.Fatal(err)
1244+ }
1245+ for _, path := range []string{"/", tinyclient.BackendPath} {
1246+ response, body := issue("GET", path, "", cookie, false)
1247+ if response.StatusCode != 200 || strings.Contains(body, p.Name) || strings.Contains(body, p.Owner) {
1248+ t.Fatalf("private metadata leaked through %s: %d", path, response.StatusCode)
1249+ }
1250+ }
1251+ response, _ = issue("GET", "/social", "", cookie, false)
1252+ if response.StatusCode != 401 {
1253+ t.Fatalf("private read without membership: %d", response.StatusCode)
1254+ }
1255+}
1256diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go
1257index dab78e1..9999f16 100644
1258--- a/internal/daemon/webui_rooms.go
1259+++ b/internal/daemon/webui_rooms.go
1260@@ -8,7 +8,7 @@ import (
1261 "github.com/FelineStateMachine/tinyrelay/internal/community"
1262 "github.com/FelineStateMachine/tinyrelay/internal/event"
1263 "github.com/FelineStateMachine/tinyrelay/internal/storage"
1264- "github.com/FelineStateMachine/tinyrelay/internal/webui"
1265+ webui "github.com/FelineStateMachine/tinyrelay/tinyclient"
1266 )
1267
1268 var _ webui.RoomsReader = (*Tenant)(nil)
1269diff --git a/internal/gitrelay/admission.go b/internal/gitrelay/admission.go
1270deleted file mode 100644
1271index 4c072ad..0000000
1272--- a/internal/gitrelay/admission.go
1273+++ /dev/null
1274@@ -1,674 +0,0 @@
1275-package gitrelay
1276-
1277-import (
1278- "context"
1279- "database/sql"
1280- "encoding/json"
1281- "errors"
1282- "fmt"
1283- "os"
1284- "os/exec"
1285- "path/filepath"
1286- "sort"
1287- "strings"
1288- "time"
1289-
1290- "github.com/FelineStateMachine/tinyrelay/internal/event"
1291- "github.com/FelineStateMachine/tinyrelay/internal/storage"
1292-)
1293-
1294-// ValidateEvent checks the event's signature and structural validity.
1295-func (g *GitRelay) ValidateEvent(e event.Event) error { return event.Validate(e) }
1296-
1297-// Validate performs GRASP admission and returns the repository authority
1298-// that the caller must associate with its event transaction. It has no store
1299-// side effects.
1300-func (g *GitRelay) Validate(ctx context.Context, e event.Event) (Repository, error) {
1301- if err := event.Validate(e); err != nil {
1302- return Repository{}, err
1303- }
1304- if e.Kind != 30617 && e.Kind != 30618 {
1305- return Repository{}, fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
1306- }
1307- r, err := g.parseRepository(e)
1308- if err != nil {
1309- return Repository{}, err
1310- }
1311- if e.Kind == 30617 {
1312- if err := g.validateArchiveAnnouncement(r); err != nil {
1313- return Repository{}, err
1314- }
1315- }
1316- if g.authorize != nil {
1317- if err := g.authorize(ctx, e, r); err != nil {
1318- return Repository{}, err
1319- }
1320- } else if !g.IsMaintainer(ctx, r, e.PubKey) {
1321- return Repository{}, errors.New("blocked: repository metadata must be signed by owner")
1322- }
1323- if e.Kind == 30618 {
1324- if r.EventID == "" {
1325- return Repository{}, errors.New("blocked: repository announcement is missing")
1326- }
1327- if err := g.validateRefs(r.Refs); err != nil {
1328- return Repository{}, err
1329- }
1330- // Missing objects are a durable pending transition. The signed state is
1331- // accepted, but remains invisible until receive-pack supplies every tip.
1332- }
1333- return r, nil
1334-}
1335-
1336-// CommitAfterStore applies the Git-side transition after the caller has
1337-// durably saved the event. It intentionally does not call Store.Save.
1338-func (g *GitRelay) CommitAfterStore(ctx context.Context, e event.Event, repo Repository) error {
1339- return g.commitAfterStore(ctx, e, repo, true)
1340-}
1341-
1342-// CommitAfterStoreNoNotify stages the Git transition without invoking the
1343-// host fanout callback. Use this while already inside the relay publish
1344-// fence; the durable work intent will release visibility afterward.
1345-func (g *GitRelay) CommitAfterStoreNoNotify(ctx context.Context, e event.Event, repo Repository) error {
1346- return g.commitAfterStore(ctx, e, repo, false)
1347-}
1348-
1349-func (g *GitRelay) commitAfterStore(ctx context.Context, e event.Event, repo Repository, notify bool) error {
1350- g.commitMu.Lock()
1351- err := g.stageAfterStore(ctx, e, repo)
1352- pending := g.IsPending(e.ID)
1353- g.commitMu.Unlock()
1354- // The host callback enters its publish fence. Never hold the Git lock
1355- // here: synchronous publication already enters these locks in reverse.
1356- if err == nil && !pending && notify && g.onPromote != nil {
1357- return g.onPromote(ctx, e.ID, repo)
1358- }
1359- return err
1360-}
1361-
1362-func (g *GitRelay) stageAfterStore(ctx context.Context, e event.Event, repo Repository) error {
1363- current, err := g.currentMetadata(ctx, e)
1364- if err != nil {
1365- return err
1366- }
1367- if !current {
1368- // A queued intent may outlive a newer addressable announcement/state.
1369- // Never let that stale intent replace the currently authorized hook.
1370- return nil
1371- }
1372- if e.Kind == 30617 {
1373- // Announcements and ref state are separate addressable events. An
1374- // announcement update must retain the current signed refs and HEAD.
1375- var raw string
1376- err := g.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE kind=30618 AND pubkey=? AND d=? ORDER BY created_at DESC,id ASC LIMIT 1`, e.PubKey, event.Tag(e, "d")).Scan(&raw)
1377- if err != nil && !errors.Is(err, sql.ErrNoRows) {
1378- return err
1379- }
1380- if err == nil {
1381- state, err := event.Parse([]byte(raw))
1382- if err != nil {
1383- return err
1384- }
1385- stateRepo, err := g.parseRepository(state)
1386- if err != nil {
1387- return err
1388- }
1389- repo.Refs, repo.Head, repo.EventID = stateRepo.Refs, stateRepo.Head, state.ID
1390- }
1391- }
1392- jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: e.ID, Kind: e.Kind, Refs: repo.Refs, Head: repo.Head}
1393- if err := g.writeJournal(repo, jr); err != nil {
1394- return err
1395- }
1396- g.mu.Lock()
1397- g.repos[key(repo.Owner, repo.Identifier)] = repo
1398- g.mu.Unlock()
1399- if err := g.ensureRepo(repo); err != nil {
1400- return err
1401- }
1402- pending := false
1403- if e.Kind == 30618 {
1404- var err error
1405- if g.stateObjectsPresent(ctx, repo) {
1406- err = g.writeState(repo)
1407- } else {
1408- err = g.writePendingState(repo)
1409- g.mu.Lock()
1410- g.pending[e.ID] = struct{}{}
1411- g.mu.Unlock()
1412- pending = true
1413- }
1414- if err != nil {
1415- return err
1416- }
1417- }
1418- if pending {
1419- return nil
1420- }
1421- if err := g.commitJournal(repo, jr); err != nil {
1422- return err
1423- }
1424- return nil
1425-}
1426-
1427-func (g *GitRelay) currentMetadata(ctx context.Context, e event.Event) (bool, error) {
1428- if e.Kind != 30617 && e.Kind != 30618 {
1429- return true, nil
1430- }
1431- var id string
1432- err := g.store.DB().QueryRowContext(ctx, `SELECT events.id FROM events JOIN tags ON tags.event_id=events.id AND tags.name='d' AND tags.value=? WHERE events.pubkey=? AND events.kind=? ORDER BY events.created_at DESC,events.id ASC LIMIT 1`, event.Tag(e, "d"), e.PubKey, e.Kind).Scan(&id)
1433- if errors.Is(err, sql.ErrNoRows) {
1434- return false, nil
1435- }
1436- if err != nil {
1437- return false, err
1438- }
1439- return id == e.ID, nil
1440-}
1441-
1442-// IsPending lets the event admission/read layer hide a metadata event until
1443-// its signed Git tips are verified locally.
1444-func (g *GitRelay) IsPending(eventID string) bool {
1445- g.mu.RLock()
1446- _, ok := g.pending[eventID]
1447- g.mu.RUnlock()
1448- return ok
1449-}
1450-
1451-// ExpirePending removes the file and in-memory authorities for state events
1452-// whose retained deadline has elapsed. The SQL event sweep calls this before
1453-// deleting the corresponding event so a stale journal cannot promote it after
1454-// a restart.
1455-func (g *GitRelay) ExpirePending(ctx context.Context, eventIDs []string) error {
1456- if g == nil || len(eventIDs) == 0 {
1457- return nil
1458- }
1459- want := make(map[string]struct{}, len(eventIDs))
1460- for _, id := range eventIDs {
1461- want[id] = struct{}{}
1462- }
1463- entries, err := os.ReadDir(g.journalDir())
1464- if err != nil && !errors.Is(err, os.ErrNotExist) {
1465- return err
1466- }
1467- for _, entry := range entries {
1468- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
1469- continue
1470- }
1471- path := filepath.Join(g.journalDir(), entry.Name())
1472- body, err := os.ReadFile(path)
1473- if err != nil {
1474- return err
1475- }
1476- var jr journalRecord
1477- if err := json.Unmarshal(body, &jr); err != nil {
1478- return err
1479- }
1480- if _, ok := want[jr.EventID]; !ok {
1481- continue
1482- }
1483- owner, identifier, ok := strings.Cut(jr.Repository, "\x00")
1484- if ok {
1485- _ = os.Remove(g.pendingStatePath(Repository{Owner: owner, Identifier: identifier}))
1486- _ = os.Remove(g.pendingHeadPath(Repository{Owner: owner, Identifier: identifier}))
1487- }
1488- if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
1489- return err
1490- }
1491- }
1492- g.mu.Lock()
1493- for id := range want {
1494- delete(g.pending, id)
1495- }
1496- g.mu.Unlock()
1497- return ctx.Err()
1498-}
1499-
1500-// PruneRef removes an expired native Git ref before its retained metadata is
1501-// deleted. repository may be the storage key or its identifier.
1502-func (g *GitRelay) PruneRef(ctx context.Context, repository, ref string) error {
1503- if g == nil || !validRef(ref) {
1504- return errors.New("git relay: invalid expired ref")
1505- }
1506- g.mu.RLock()
1507- var found *Repository
1508- for _, candidate := range g.repos {
1509- if key(candidate.Owner, candidate.Identifier) == repository || candidate.Identifier == repository {
1510- copy := candidate
1511- found = &copy
1512- break
1513- }
1514- }
1515- g.mu.RUnlock()
1516- if found == nil {
1517- return nil
1518- }
1519- if err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(*found), "update-ref", "-d", ref).Run(); err != nil {
1520- return err
1521- }
1522- g.mu.Lock()
1523- if current, ok := g.repos[key(found.Owner, found.Identifier)]; ok {
1524- delete(current.Refs, ref)
1525- g.repos[key(found.Owner, found.Identifier)] = current
1526- }
1527- g.mu.Unlock()
1528- return nil
1529-}
1530-
1531-// PromotePending verifies and exposes a previously accepted state event. It
1532-// is useful for local receive-pack integrations; ServeHTTP invokes it after a
1533-// smart push as well.
1534-func (g *GitRelay) PromotePending(ctx context.Context, repo Repository) error {
1535- g.commitMu.Lock()
1536- promoted, err := g.promotePending(ctx, repo)
1537- g.commitMu.Unlock()
1538- if err == nil && promoted && g.onPromote != nil {
1539- return g.onPromote(ctx, repo.EventID, repo)
1540- }
1541- return err
1542-}
1543-
1544-func (g *GitRelay) promotePending(ctx context.Context, repo Repository) (bool, error) {
1545- var current int
1546- if err := g.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM events WHERE id=? AND kind=30618`, repo.EventID).Scan(&current); err != nil {
1547- return false, err
1548- }
1549- if current == 0 {
1550- return false, nil
1551- }
1552- if !g.stateObjectsPresent(ctx, repo) {
1553- return false, nil
1554- }
1555- g.mu.RLock()
1556- _, tracked := g.pending[repo.EventID]
1557- g.mu.RUnlock()
1558- if _, err := os.Stat(g.pendingStatePath(repo)); err != nil && !tracked {
1559- return false, nil
1560- }
1561- if err := g.writeState(repo); err != nil {
1562- return false, err
1563- }
1564- _ = os.Remove(g.pendingStatePath(repo))
1565- _ = os.Remove(g.pendingHeadPath(repo))
1566- g.mu.Lock()
1567- delete(g.pending, repo.EventID)
1568- g.mu.Unlock()
1569- jr := journalRecord{Repository: key(repo.Owner, repo.Identifier), EventID: repo.EventID, Kind: 30618, Refs: repo.Refs, Head: repo.Head}
1570- if err := g.commitJournal(repo, jr); err != nil {
1571- return false, err
1572- }
1573- return true, nil
1574-}
1575-
1576-// Publish ingests a signed repository announcement or state event. Callers
1577-// must validate the event before invoking this method; ValidateEvent is
1578-// provided for callers that want one explicit boundary.
1579-func (g *GitRelay) Publish(ctx context.Context, e event.Event) error {
1580- if err := event.Validate(e); err != nil {
1581- return err
1582- }
1583- if e.Kind != 30617 && e.Kind != 30618 {
1584- if !((g.grasp06 || g.policy().Features.Grasp06) && (e.Kind == 1617 || e.Kind == 1618 || e.Kind == 1619)) {
1585- return fmt.Errorf("unsupported: event kind %d is not GRASP repository metadata", e.Kind)
1586- }
1587- g.commitMu.Lock()
1588- defer g.commitMu.Unlock()
1589- return g.publishPR(ctx, e)
1590- }
1591- repo, err := g.Validate(ctx, e)
1592- if err != nil {
1593- return err
1594- }
1595- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
1596- return err
1597- }
1598- return g.CommitAfterStore(ctx, e, repo)
1599-}
1600-
1601-func (g *GitRelay) parseRepository(e event.Event) (Repository, error) {
1602- id := event.Tag(e, "d")
1603- if !validIdentifier(id) {
1604- return Repository{}, errors.New("invalid: repository identifier")
1605- }
1606- r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}}
1607- if e.Kind == 30617 {
1608- for _, tag := range e.Tags {
1609- if len(tag) < 2 {
1610- continue
1611- }
1612- switch tag[0] {
1613- case "private":
1614- r.Private = tag[1] == "true"
1615- case "clone":
1616- r.Clone = append(r.Clone, tag[1:]...)
1617- case "relays":
1618- r.Relays = append(r.Relays, tag[1:]...)
1619- case "maintainers":
1620- r.Maintainers = append(r.Maintainers, tag[1:]...)
1621- }
1622- }
1623- return r, nil
1624- }
1625- // State is replaceable: bind it to the current announcement owner and
1626- // carry the announcement's identity into the hook/state file.
1627- state := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Refs: map[string]string{}}
1628- for _, tag := range e.Tags {
1629- if len(tag) == 0 {
1630- continue
1631- }
1632- if tag[0] == "HEAD" {
1633- if len(tag) != 2 || state.Head != "" {
1634- return Repository{}, errors.New("invalid: repository state has multiple HEAD tags")
1635- }
1636- state.Head = tag[1]
1637- continue
1638- }
1639- if len(tag) < 2 {
1640- continue
1641- }
1642- if strings.HasPrefix(tag[0], "refs/") {
1643- if _, duplicate := state.Refs[tag[0]]; duplicate || tag[1] == "" || !isObjectID(tag[1]) || strings.Trim(tag[1], "0") == "" {
1644- return Repository{}, fmt.Errorf("invalid: ref %s is not a SHA-1", tag[0])
1645- }
1646- state.Refs[tag[0]] = tag[1]
1647- }
1648- }
1649- g.mu.RLock()
1650- ann := g.repos[key(e.PubKey, id)]
1651- g.mu.RUnlock()
1652- if ann.Owner == "" {
1653- q, err := g.store.Query(context.Background(), event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {id}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
1654- if err != nil {
1655- return Repository{}, err
1656- }
1657- if len(q.Events) == 0 {
1658- return Repository{}, errors.New("blocked: repository announcement is missing")
1659- }
1660- for _, candidate := range q.Events {
1661- ann, err = g.parseRepository(candidate)
1662- if err == nil && g.IsMaintainer(context.Background(), ann, e.PubKey) {
1663- break
1664- }
1665- ann = Repository{}
1666- }
1667- if ann.Owner == "" {
1668- return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
1669- }
1670- }
1671- if !g.IsMaintainer(context.Background(), ann, e.PubKey) {
1672- return Repository{}, errors.New("blocked: state author is not an accepted repository maintainer")
1673- }
1674- state.Owner, state.Private = ann.Owner, ann.Private
1675- state.Clone = append([]string(nil), ann.Clone...)
1676- state.Relays = append([]string(nil), ann.Relays...)
1677- state.Maintainers = append([]string(nil), ann.Maintainers...)
1678- state.EventID = e.ID
1679- if err := validateHead(state.Head, state.Refs); err != nil {
1680- return Repository{}, err
1681- }
1682- return state, nil
1683-}
1684-
1685-func validateHead(head string, refs map[string]string) error {
1686- if head == "" {
1687- return nil
1688- }
1689- if !strings.HasPrefix(head, "ref: ") {
1690- return errors.New("invalid: HEAD must use ref: syntax")
1691- }
1692- ref := strings.TrimPrefix(head, "ref: ")
1693- if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") {
1694- return errors.New("invalid: HEAD must name a branch or tag")
1695- }
1696- if !validRef(ref) {
1697- return errors.New("invalid: HEAD ref")
1698- }
1699- return nil
1700-}
1701-
1702-func (g *GitRelay) validateRefs(refs map[string]string) error {
1703- for ref, oid := range refs {
1704- if !validRef(ref) || oid != "" && !isObjectID(oid) {
1705- return fmt.Errorf("invalid: ref %s", ref)
1706- }
1707- }
1708- return nil
1709-}
1710-
1711-// ErrIncomplete reports a synchronization pass that saved progress and has
1712-// more history to fetch. The scheduler continues it soon without counting a
1713-// failure.
1714-var ErrIncomplete = errors.New("synchronization incomplete")
1715-
1716-// Capabilities returns only protocol surfaces that this instance can serve.
1717-func (g *GitRelay) Capabilities() []string {
1718- p := g.policy()
1719- c := []string{"GRASP-01"}
1720- if p.Features.Grasp02 && g.eventSync != nil {
1721- c = append(c, "GRASP-02")
1722- if p.Features.Grasp03 {
1723- c = append(c, "GRASP-03")
1724- }
1725- }
1726- if g.grasp06 || p.Features.Grasp06 {
1727- c = append(c, "GRASP-06")
1728- }
1729- if p.PrivateServiceEnabled() {
1730- c = append(c, "GRASP-08")
1731- }
1732- return c
1733-}
1734-
1735-// SupportedGRASPs is the NIP-11-facing capability list. It deliberately
1736-// mirrors Capabilities so callers cannot advertise preview protocols by hand.
1737-func (g *GitRelay) SupportedGRASPs() []string { return g.Capabilities() }
1738-
1739-// DeletePRRef removes a temporary GRASP-06 ref before its Nostr metadata is
1740-// deleted. Identity accepts the source coordinates used by delivery jobs:
1741-// pr:<owner>:<identifier> or 30617:<owner>:<identifier>.
1742-func (g *GitRelay) DeletePRRef(ctx context.Context, identity, ref string) error {
1743- parts := strings.SplitN(identity, ":", 3)
1744- if len(parts) != 3 || (parts[0] != "pr" && parts[0] != "30617") || !isPubKey(parts[1]) || parts[2] == "" || strings.ContainsAny(parts[2], `/\\`) {
1745- return errors.New("invalid: PR repository identity")
1746- }
1747- if !strings.HasPrefix(ref, "refs/nostr/") || len(strings.TrimPrefix(ref, "refs/nostr/")) != 64 {
1748- return errors.New("invalid: PR ref")
1749- }
1750- r := Repository{Owner: parts[1], Identifier: parts[2], Alternative: true, Refs: map[string]string{}}
1751- if err := g.ensureRepo(r); err != nil {
1752- return err
1753- }
1754- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "update-ref", "-d", ref)
1755- if out, err := cmd.CombinedOutput(); err != nil {
1756- return fmt.Errorf("delete PR ref: %w: %s", err, strings.TrimSpace(string(out)))
1757- }
1758- return g.removeRefFromPending(r, ref)
1759-}
1760-
1761-func (g *GitRelay) removeRefFromPending(r Repository, ref string) error {
1762- b, err := os.ReadFile(g.pendingStatePath(r))
1763- if errors.Is(err, os.ErrNotExist) {
1764- return nil
1765- }
1766- if err != nil {
1767- return err
1768- }
1769- var kept map[string]string = map[string]string{}
1770- for _, line := range strings.Split(string(b), "\n") {
1771- fields := strings.Fields(line)
1772- if len(fields) == 2 && fields[0] != ref {
1773- kept[fields[0]] = fields[1]
1774- }
1775- }
1776- if len(kept) == 0 {
1777- return os.Remove(g.pendingStatePath(r))
1778- }
1779- return g.writeRefsFile(g.pendingStatePath(r), kept)
1780-}
1781-
1782-// Reload rebuilds repository and pending indexes after a restore has inserted
1783-// events and native Git repositories on disk.
1784-func (g *GitRelay) Reload(ctx context.Context) error {
1785- g.commitMu.Lock()
1786- defer g.commitMu.Unlock()
1787- g.repoConfigMu.Lock()
1788- g.configured = nil
1789- g.repoConfigMu.Unlock()
1790- if err := g.recoverJournals(); err != nil {
1791- return err
1792- }
1793- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
1794- if err != nil {
1795- return err
1796- }
1797- repos := make(map[string]Repository)
1798- for _, raw := range q.Events {
1799- r, parseErr := g.parseRepository(raw)
1800- if parseErr == nil {
1801- repos[key(r.Owner, r.Identifier)] = r
1802- }
1803- }
1804- // Recover only native refs that match the published signed authority.
1805- // Incoming receive-pack refs may still be awaiting promotion.
1806- for coordinate, announcement := range repos {
1807- if err := g.ensureRepo(announcement); err != nil {
1808- return err
1809- }
1810- refs, refsErr := g.publishedNativeRefs(announcement)
1811- if refsErr != nil {
1812- return refsErr
1813- }
1814- if len(refs) > 0 {
1815- announcement.Refs = refs
1816- if head, headErr := exec.Command("git", "--git-dir", g.repoPath(announcement), "symbolic-ref", "-q", "HEAD").Output(); headErr == nil {
1817- announcement.Head = "ref: " + strings.TrimSpace(string(head))
1818- }
1819- repos[coordinate] = announcement
1820- }
1821- }
1822- // If no native refs exist (for example an older store written before native
1823- // projection), recover the newest complete signed state as a fallback.
1824- for coordinate, announcement := range repos {
1825- if _, err := os.Stat(g.statePath(announcement)); err == nil {
1826- continue
1827- } else if !errors.Is(err, os.ErrNotExist) {
1828- return err
1829- }
1830- if _, err := os.Stat(g.pendingStatePath(announcement)); err == nil {
1831- continue
1832- } else if !errors.Is(err, os.ErrNotExist) {
1833- return err
1834- }
1835- states, stateErr := g.store.Query(ctx, event.Filter{Authors: append([]string{announcement.Owner}, announcement.Maintainers...), Kinds: []int{30618}, Tags: map[string][]string{"d": {announcement.Identifier}}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
1836- if stateErr != nil {
1837- return stateErr
1838- }
1839- if len(states.Events) == 0 {
1840- continue
1841- }
1842- sort.Slice(states.Events, func(i, j int) bool { return states.Events[i].CreatedAt > states.Events[j].CreatedAt })
1843- state, parseErr := g.parseRepository(states.Events[0])
1844- if parseErr != nil || state.Owner != announcement.Owner || !g.stateObjectsPresent(ctx, state) {
1845- continue
1846- }
1847- repos[coordinate] = state
1848- }
1849- prs, prErr := g.store.Query(ctx, event.Filter{Kinds: []int{1617, 1618, 1619}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 0})
1850- if prErr != nil {
1851- return prErr
1852- }
1853- for _, raw := range prs.Events {
1854- identifier := event.Tag(raw, "d")
1855- if identifier == "" {
1856- identifier = event.Tag(raw, "a")
1857- }
1858- commit := event.Tag(raw, "c")
1859- if !validIdentifier(identifier) || !isObjectID(commit) {
1860- continue
1861- }
1862- pr := Repository{Owner: raw.PubKey, Identifier: identifier, EventID: raw.ID, Alternative: true, Refs: map[string]string{}}
1863- if _, err := os.Stat(g.repoPath(pr)); errors.Is(err, os.ErrNotExist) {
1864- continue
1865- } else if err != nil {
1866- return err
1867- }
1868- if refs, refsErr := g.publishedNativeRefs(pr); refsErr == nil && len(refs) > 0 {
1869- pr.Refs = refs
1870- }
1871- repos[key(pr.Owner, pr.Identifier)] = pr
1872- }
1873- entries, err := os.ReadDir(g.journalDir())
1874- if err != nil && !errors.Is(err, os.ErrNotExist) {
1875- return err
1876- }
1877- refreshedPending := make(map[string]struct{})
1878- for _, entry := range entries {
1879- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
1880- continue
1881- }
1882- b, readErr := os.ReadFile(filepath.Join(g.journalDir(), entry.Name()))
1883- if readErr != nil {
1884- return readErr
1885- }
1886- var jr journalRecord
1887- if json.Unmarshal(b, &jr) == nil && jr.EventID != "" {
1888- refreshedPending[jr.EventID] = struct{}{}
1889- }
1890- }
1891- g.mu.Lock()
1892- g.repos = repos
1893- g.pending = refreshedPending
1894- g.mu.Unlock()
1895- return nil
1896-}
1897-
1898-func (g *GitRelay) publishPR(ctx context.Context, e event.Event) error {
1899- id := event.Tag(e, "d")
1900- if id == "" {
1901- id = event.Tag(e, "a")
1902- }
1903- if !validIdentifier(id) {
1904- return errors.New("invalid: PR repository identifier")
1905- }
1906- r := Repository{Owner: e.PubKey, Identifier: id, EventID: e.ID, Alternative: true, Refs: map[string]string{}}
1907- commit := event.Tag(e, "c")
1908- if commit != "" && !isObjectID(commit) {
1909- return errors.New("invalid: PR commit is not a SHA-1")
1910- }
1911- if commit != "" {
1912- r.Refs["refs/nostr/"+e.ID] = commit
1913- }
1914- if g.authorize != nil {
1915- if err := g.authorize(ctx, e, r); err != nil {
1916- return err
1917- }
1918- }
1919- if err := g.writeJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head}); err != nil {
1920- return err
1921- }
1922- if _, err := g.store.Save(ctx, e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
1923- return err
1924- }
1925- if err := g.ensureRepo(r); err != nil {
1926- return err
1927- }
1928- g.mu.Lock()
1929- g.repos[key(r.Owner, r.Identifier)] = r
1930- g.mu.Unlock()
1931- pending := !g.stateObjectsPresent(ctx, r)
1932- if !pending {
1933- if err := g.writeState(r); err != nil {
1934- return err
1935- }
1936- } else {
1937- if err := g.writePendingState(r); err != nil {
1938- return err
1939- }
1940- g.mu.Lock()
1941- g.pending[e.ID] = struct{}{}
1942- g.mu.Unlock()
1943- }
1944- if pending {
1945- return nil
1946- }
1947- return g.commitJournal(r, journalRecord{Repository: key(r.Owner, r.Identifier), EventID: e.ID, Kind: e.Kind, Refs: r.Refs, Head: r.Head})
1948-}
1949diff --git a/internal/gitrelay/archive.go b/internal/gitrelay/archive.go
1950deleted file mode 100644
1951index 854c260..0000000
1952--- a/internal/gitrelay/archive.go
1953+++ /dev/null
1954@@ -1,79 +0,0 @@
1955-package gitrelay
1956-
1957-import (
1958- "context"
1959- "errors"
1960- "net/url"
1961- "strings"
1962-
1963- "github.com/FelineStateMachine/tinyrelay/internal/event"
1964- "github.com/FelineStateMachine/tinyrelay/internal/policy"
1965- "github.com/FelineStateMachine/tinyrelay/internal/storage"
1966-)
1967-
1968-// validateArchiveAnnouncement implements the GRASP-05 admission distinction:
1969-// an archive may announce a repository without clone or relay URLs, but the
1970-// archive profile depends on GRASP-02. A configured service keeps the normal
1971-// GRASP-01 requirement for announcements unless the operator enables both
1972-// extensions. Embedded GitRelay users without a public service URL retain the
1973-// permissive library behavior used by local integrations.
1974-func (g *GitRelay) validateArchiveAnnouncement(r Repository) error {
1975- if g.serviceURL == "" {
1976- return nil
1977- }
1978- if g.serviceListed(r) {
1979- return nil
1980- }
1981- if g.archiveRelated(context.Background(), r) {
1982- return nil
1983- }
1984- p := policy.Policy{}
1985- if g.policy != nil {
1986- p = g.policy()
1987- }
1988- if len(r.Clone) == 0 && len(r.Relays) == 0 && p.Features.Grasp02 && p.Features.Grasp05 {
1989- return nil
1990- }
1991- return errors.New("blocked: archive repository admission requires GRASP-02 and GRASP-05")
1992-}
1993-
1994-func (g *GitRelay) serviceListed(r Repository) bool {
1995- base, err := url.Parse(g.serviceURL)
1996- if err != nil || base.Host == "" {
1997- return false
1998- }
1999- cloneOK, relayOK := false, false
2000- for _, raw := range r.Clone {
2001- u, parseErr := url.Parse(raw)
2002- if parseErr == nil && (u.Scheme == "http" || u.Scheme == "https") && strings.EqualFold(u.Host, base.Host) && strings.HasSuffix(strings.TrimRight(u.Path, "/"), "/"+r.Identifier+".git") {
2003- cloneOK = true
2004- }
2005- }
2006- for _, raw := range r.Relays {
2007- u, parseErr := url.Parse(raw)
2008- if parseErr == nil && (u.Scheme == "ws" || u.Scheme == "wss") && strings.EqualFold(u.Host, base.Host) {
2009- relayOK = true
2010- }
2011- }
2012- return cloneOK && relayOK
2013-}
2014-
2015-func (g *GitRelay) archiveRelated(ctx context.Context, r Repository) bool {
2016- if g.store == nil {
2017- return false
2018- }
2019- q, err := g.store.Query(ctx, event.Filter{Kinds: []int{30617}, Tags: map[string][]string{"d": {r.Identifier}}}, storage.QueryOptions{Now: 0, Access: storage.Access{All: true}, Limit: 0})
2020- if err != nil {
2021- return false
2022- }
2023- for _, candidate := range q.Events {
2024- parsed, parseErr := g.parseRepository(candidate)
2025- if parseErr != nil || parsed.Identifier != r.Identifier {
2026- continue
2027- }
2028- if candidate.PubKey == r.Owner || contains(parsed.Maintainers, r.Owner) {
2029- return true
2030- }
2031- }
2032- return false
2033-}
2034diff --git a/internal/gitrelay/archive_test.go b/internal/gitrelay/archive_test.go
2035deleted file mode 100644
2036index 02afda1..0000000
2037--- a/internal/gitrelay/archive_test.go
2038+++ /dev/null
2039@@ -1,86 +0,0 @@
2040-package gitrelay
2041-
2042-import (
2043- "context"
2044- "path/filepath"
2045- "strings"
2046- "testing"
2047-
2048- "github.com/FelineStateMachine/tinyrelay/internal/event"
2049- "github.com/FelineStateMachine/tinyrelay/internal/policy"
2050- "github.com/FelineStateMachine/tinyrelay/internal/storage"
2051-)
2052-
2053-func TestArchiveAnnouncementRequiresOptInWhenServiceConfigured(t *testing.T) {
2054- root := t.TempDir()
2055- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
2056- if err != nil {
2057- t.Fatal(err)
2058- }
2059- defer store.Close()
2060- secret := strings.Repeat("0", 63) + "1"
2061- makeEvent := func() event.Event {
2062- e := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "archive"}}}
2063- if err := event.Sign(&e, secret); err != nil {
2064- t.Fatal(err)
2065- }
2066- return e
2067- }
2068- for name, p := range map[string]policy.Policy{
2069- "disabled": {},
2070- "enabled": {Features: policy.Features{Grasp02: true, Grasp05: true}},
2071- } {
2072- t.Run(name, func(t *testing.T) {
2073- g, err := New(Config{Store: store, Root: filepath.Join(root, name), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }})
2074- if err != nil {
2075- t.Fatal(err)
2076- }
2077- _, err = g.Validate(context.Background(), makeEvent())
2078- if name == "disabled" && err == nil {
2079- t.Fatal("archive announcement accepted while GRASP-05 is disabled")
2080- }
2081- if name == "enabled" && err != nil {
2082- t.Fatalf("archive announcement rejected: %v", err)
2083- }
2084- })
2085- }
2086-}
2087-
2088-func TestArchiveAdmissionRecognizesServiceAndMaintainerExceptions(t *testing.T) {
2089- root := t.TempDir()
2090- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
2091- if err != nil {
2092- t.Fatal(err)
2093- }
2094- defer store.Close()
2095- ownerSecret := strings.Repeat("0", 63) + "1"
2096- maintainerSecret := strings.Repeat("0", 63) + "2"
2097- _, _ = event.PublicKey(ownerSecret)
2098- maintainer, _ := event.PublicKey(maintainerSecret)
2099- p := policy.Policy{Features: policy.Features{Grasp02: true, Grasp05: true}}
2100- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), PublicURL: "https://relay.example", Policy: func() policy.Policy { return p }, Authorize: func(context.Context, event.Event, Repository) error { return nil }})
2101- if err != nil {
2102- t.Fatal(err)
2103- }
2104- ownerAnnouncement := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "repo"}, {"clone", "https://relay.example/repo.git"}, {"relays", "wss://relay.example"}, {"maintainers", maintainer}}}
2105- if err := event.Sign(&ownerAnnouncement, ownerSecret); err != nil {
2106- t.Fatal(err)
2107- }
2108- if err := g.Publish(context.Background(), ownerAnnouncement); err != nil {
2109- t.Fatal(err)
2110- }
2111- maintainerAnnouncement := event.Event{Kind: 30617, CreatedAt: 2, Tags: [][]string{{"d", "repo"}}}
2112- if err := event.Sign(&maintainerAnnouncement, maintainerSecret); err != nil {
2113- t.Fatal(err)
2114- }
2115- if _, err := g.Validate(context.Background(), maintainerAnnouncement); err != nil {
2116- t.Fatalf("recognized maintainer archive announcement rejected: %v", err)
2117- }
2118- wrong := event.Event{Kind: 30617, CreatedAt: 3, Tags: [][]string{{"d", "other"}, {"clone", "https://elsewhere.example/other.git"}, {"relays", "wss://elsewhere.example"}}}
2119- if err := event.Sign(&wrong, ownerSecret); err != nil {
2120- t.Fatal(err)
2121- }
2122- if _, err := g.Validate(context.Background(), wrong); err == nil {
2123- t.Fatal("announcement naming another service was accepted")
2124- }
2125-}
2126diff --git a/internal/gitrelay/backup_import.go b/internal/gitrelay/backup_import.go
2127deleted file mode 100644
2128index 8a515b1..0000000
2129--- a/internal/gitrelay/backup_import.go
2130+++ /dev/null
2131@@ -1,209 +0,0 @@
2132-package gitrelay
2133-
2134-// This is the delivery-job seam for converting bind.ws git-sqlite/1 backups
2135-// into native bare repositories. Validation happens before any object or ref
2136-// is written; Git remains the final object database and ref transaction.
2137-
2138-import (
2139- "bytes"
2140- "compress/zlib"
2141- "context"
2142- "crypto/sha1"
2143- "encoding/base64"
2144- "encoding/hex"
2145- "encoding/json"
2146- "errors"
2147- "fmt"
2148- "os/exec"
2149- "sort"
2150- "strings"
2151-)
2152-
2153-// BackupObject is one validated Git object and its compressed content chunks.
2154-type BackupObject struct {
2155- OID string `json:"oid"`
2156- Type string `json:"type"`
2157- Size int `json:"size"`
2158- Chunks []string `json:"chunks"`
2159-}
2160-
2161-// BackupRepository contains the objects and refs for one imported repository.
2162-type BackupRepository struct {
2163- Key string `json:"key"`
2164- Owner string `json:"owner"`
2165- Identifier string `json:"identifier"`
2166- Alternative bool `json:"alternative"`
2167- Objects []BackupObject `json:"objects"`
2168- Refs map[string]string `json:"refs"`
2169-}
2170-
2171-// GitBackup is the complete bind.ws/git-sqlite/1 import document.
2172-type GitBackup struct {
2173- Format string `json:"format"`
2174- Repositories []BackupRepository `json:"repositories"`
2175-}
2176-
2177-// ValidateGitBackup validates an import document without writing Git state.
2178-func ValidateGitBackup(input []byte) (GitBackup, error) {
2179- var backup GitBackup
2180- if err := json.Unmarshal(input, &backup); err != nil {
2181- return GitBackup{}, fmt.Errorf("invalid Git backup JSON: %w", err)
2182- }
2183- if backup.Format != "bind.ws/git-sqlite/1" {
2184- return GitBackup{}, errors.New("invalid Git backup format")
2185- }
2186- seen := make(map[string]struct{}, len(backup.Repositories))
2187- for i := range backup.Repositories {
2188- r := &backup.Repositories[i]
2189- if !isObjectID64(r.Key) || !isPubKey(r.Owner) || !validIdentifier(r.Identifier) {
2190- return GitBackup{}, errors.New("invalid Git repository identity")
2191- }
2192- if _, ok := seen[r.Key]; ok {
2193- return GitBackup{}, errors.New("duplicate Git repository")
2194- }
2195- seen[r.Key] = struct{}{}
2196- for _, obj := range r.Objects {
2197- if err := validateBackupObject(obj); err != nil {
2198- return GitBackup{}, err
2199- }
2200- }
2201- for ref, oid := range r.Refs {
2202- if !validRef(ref) || !isObjectID(oid) {
2203- return GitBackup{}, errors.New("invalid Git backup ref")
2204- }
2205- }
2206- }
2207- return backup, nil
2208-}
2209-
2210-func validateBackupObject(obj BackupObject) error {
2211- if !isObjectID(obj.OID) || obj.Size < 0 || (obj.Type != "blob" && obj.Type != "tree" && obj.Type != "commit" && obj.Type != "tag") || len(obj.Chunks) == 0 {
2212- return errors.New("invalid Git backup object")
2213- }
2214- data, err := decodeBackupObject(obj)
2215- if err != nil {
2216- return err
2217- }
2218- h := sha1.Sum(append([]byte(fmt.Sprintf("%s %d\x00", obj.Type, len(data))), data...))
2219- if len(data) != obj.Size || hex.EncodeToString(h[:]) != obj.OID {
2220- return errors.New("Git backup object hash mismatch")
2221- }
2222- return nil
2223-}
2224-
2225-func decodeBackupObject(obj BackupObject) ([]byte, error) {
2226- var compressed bytes.Buffer
2227- for _, chunk := range obj.Chunks {
2228- b, err := base64.StdEncoding.DecodeString(chunk)
2229- if err != nil {
2230- return nil, errors.New("invalid Git backup chunk")
2231- }
2232- compressed.Write(b)
2233- }
2234- r, err := zlib.NewReader(bytes.NewReader(compressed.Bytes()))
2235- if err != nil {
2236- return nil, errors.New("invalid Git backup compression")
2237- }
2238- var data bytes.Buffer
2239- if _, err := data.ReadFrom(r); err != nil {
2240- _ = r.Close()
2241- return nil, err
2242- }
2243- if err := r.Close(); err != nil {
2244- return nil, err
2245- }
2246- return data.Bytes(), nil
2247-}
2248-
2249-func isObjectID64(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }
2250-func isPubKey(s string) bool { return len(s) == 64 && strings.Trim(s, "0123456789abcdef") == "" }
2251-func validRef(s string) bool {
2252- if !strings.HasPrefix(s, "refs/") || s == "refs/" || strings.Contains(s, "//") || strings.Contains(s, "..") || strings.Contains(s, "@{") {
2253- return false
2254- }
2255- if strings.ContainsAny(s, "~^:?*[\\") {
2256- return false
2257- }
2258- for _, r := range s {
2259- if r <= ' ' || r == 127 {
2260- return false
2261- }
2262- }
2263- for _, part := range strings.Split(s, "/") {
2264- if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".") || strings.HasSuffix(part, ".lock") {
2265- return false
2266- }
2267- }
2268- return true
2269-}
2270-
2271-func validIdentifier(id string) bool {
2272- if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\\\"'$`+"`;&|()<>") {
2273- return false
2274- }
2275- for _, r := range id {
2276- if r <= ' ' {
2277- return false
2278- }
2279- }
2280- return true
2281-}
2282-
2283-// ImportGitBackup installs a validated backup into empty native repositories.
2284-// It refuses pre-existing refs or repositories to avoid silently mixing
2285-// namespaces; callers can stage an import into a fresh Git root and swap it.
2286-func (g *GitRelay) ImportGitBackup(ctx context.Context, input []byte) error {
2287- backup, err := ValidateGitBackup(input)
2288- if err != nil {
2289- return err
2290- }
2291- for _, r := range backup.Repositories {
2292- repo := Repository{Owner: r.Owner, Identifier: r.Identifier, Alternative: r.Alternative, Refs: r.Refs}
2293- if err := g.ensureRepo(repo); err != nil {
2294- return err
2295- }
2296- for _, ref := range r.Refs {
2297- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "show-ref", "--verify", ref).CombinedOutput(); err == nil {
2298- return fmt.Errorf("Git backup target is not empty: %s", strings.TrimSpace(string(out)))
2299- }
2300- }
2301- for _, obj := range r.Objects {
2302- data, _ := decodeBackupObject(obj)
2303- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "hash-object", "-w", "--stdin", "-t", obj.Type)
2304- cmd.Stdin = bytes.NewReader(data)
2305- if out, err := cmd.CombinedOutput(); err != nil || strings.TrimSpace(string(out)) != obj.OID {
2306- return fmt.Errorf("install Git object %s: %w", obj.OID, err)
2307- }
2308- }
2309- for ref, oid := range r.Refs {
2310- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "update-ref", ref, oid).CombinedOutput(); err != nil {
2311- return fmt.Errorf("install Git ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))
2312- }
2313- }
2314- if head := backupHead(r.Refs); head != "" {
2315- if out, err := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(repo), "symbolic-ref", "HEAD", head).CombinedOutput(); err != nil {
2316- return fmt.Errorf("install Git HEAD: %w: %s", err, strings.TrimSpace(string(out)))
2317- }
2318- }
2319- }
2320- return nil
2321-}
2322-
2323-func backupHead(refs map[string]string) string {
2324- for _, candidate := range []string{"refs/heads/main", "refs/heads/master"} {
2325- if _, ok := refs[candidate]; ok {
2326- return candidate
2327- }
2328- }
2329- branches := make([]string, 0)
2330- for ref := range refs {
2331- if strings.HasPrefix(ref, "refs/heads/") {
2332- branches = append(branches, ref)
2333- }
2334- }
2335- sort.Strings(branches)
2336- if len(branches) > 0 {
2337- return branches[0]
2338- }
2339- return ""
2340-}
2341diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go
2342deleted file mode 100644
2343index d85c4df..0000000
2344--- a/internal/gitrelay/browse.go
2345+++ /dev/null
2346@@ -1,415 +0,0 @@
2347-package gitrelay
2348-
2349-import (
2350- "bufio"
2351- "bytes"
2352- "context"
2353- "errors"
2354- "fmt"
2355- "io"
2356- "os"
2357- "os/exec"
2358- "path"
2359- "sort"
2360- "strconv"
2361- "strings"
2362- "unicode/utf8"
2363-)
2364-
2365-const previewBytes = 256 * 1024
2366-
2367-type BrowseRequest struct {
2368- Owner string `json:"owner"`
2369- Repo string `json:"repo"`
2370- Ref string `json:"ref"`
2371- Path string `json:"path"`
2372- View string `json:"view"`
2373- Offset int `json:"offset"`
2374- Limit int `json:"limit"`
2375-}
2376-
2377-type BrowseEntry struct {
2378- Name string `json:"name"`
2379- Path string `json:"path"`
2380- Mode string `json:"mode"`
2381- Type string `json:"type"`
2382- OID string `json:"oid"`
2383- Size int64 `json:"size"`
2384-}
2385-
2386-type BrowseCommit struct {
2387- OID string `json:"oid"`
2388- Parents []string `json:"parents"`
2389- Author string `json:"author"`
2390- Time int64 `json:"time"`
2391- Subject string `json:"subject"`
2392-}
2393-
2394-type BrowsePage struct {
2395- Owner string `json:"owner"`
2396- Identifier string `json:"identifier"`
2397- Head string `json:"head"`
2398- Private bool `json:"private"`
2399- Clone []string `json:"clone"`
2400- Relays []string `json:"relays"`
2401- // Maintainers is filled by the host, which knows about agent grants.
2402- Maintainers []Maintainer `json:"maintainers,omitempty"`
2403- Refs map[string]string `json:"refs"`
2404- Commit string `json:"commit"`
2405- Path string `json:"path"`
2406- View string `json:"view"`
2407- Entries []BrowseEntry `json:"entries"`
2408- Content string `json:"content"`
2409- Binary bool `json:"binary"`
2410- Truncated bool `json:"truncated"`
2411- Commits []BrowseCommit `json:"commits"`
2412- Diff string `json:"diff"`
2413- NextOffset int `json:"next_offset"`
2414-}
2415-
2416-// Repositories returns detached snapshots. Callers must enforce tenant and
2417-// repository read permissions before revealing these records.
2418-func (g *GitRelay) Repositories() []Repository {
2419- g.mu.RLock()
2420- defer g.mu.RUnlock()
2421- rows := make([]Repository, 0, len(g.repos))
2422- for _, r := range g.repos {
2423- rows = append(rows, cloneBrowseRepo(r))
2424- }
2425- sort.Slice(rows, func(i, j int) bool {
2426- return key(rows[i].Owner, rows[i].Identifier) < key(rows[j].Owner, rows[j].Identifier)
2427- })
2428- return rows
2429-}
2430-
2431-func cloneBrowseRepo(r Repository) Repository {
2432- refs := make(map[string]string, len(r.Refs))
2433- for name, oid := range r.Refs {
2434- refs[name] = oid
2435- }
2436- r.Refs = refs
2437- r.Clone = append([]string(nil), r.Clone...)
2438- r.Relays = append([]string(nil), r.Relays...)
2439- r.Maintainers = append([]string(nil), r.Maintainers...)
2440- return r
2441-}
2442-
2443-func (g *GitRelay) BrowseRepository(owner, identifier string) (Repository, error) {
2444- g.mu.RLock()
2445- defer g.mu.RUnlock()
2446- r, ok := g.repos[key(owner, identifier)]
2447- if !ok {
2448- return Repository{}, os.ErrNotExist
2449- }
2450- return cloneBrowseRepo(r), nil
2451-}
2452-
2453-func validBrowsePath(value string) bool {
2454- return value == "" || (!strings.ContainsRune(value, 0) && !strings.HasPrefix(value, "/") && path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../"))
2455-}
2456-
2457-func newBrowsePage(r Repository, q BrowseRequest) BrowsePage {
2458- return BrowsePage{Owner: r.Owner, Identifier: r.Identifier, Head: r.Head, Private: r.Private, Clone: r.Clone, Relays: r.Relays, Refs: r.Refs, Path: q.Path, View: q.View, NextOffset: -1}
2459-}
2460-
2461-// Browse only reads objects reachable from materialized, signed public refs.
2462-// It never checks out a working tree, invokes a shell, or follows a symlink.
2463-func (g *GitRelay) Browse(ctx context.Context, q BrowseRequest) (BrowsePage, error) {
2464- if !validBrowsePath(q.Path) || q.Offset < 0 {
2465- return BrowsePage{}, errors.New("invalid: repository path or offset")
2466- }
2467- if q.View == "" {
2468- q.View = "tree"
2469- }
2470- if q.Limit <= 0 || q.Limit > 200 {
2471- q.Limit = 50
2472- }
2473- r, err := g.BrowseRepository(q.Owner, q.Repo)
2474- if err != nil {
2475- return BrowsePage{}, err
2476- }
2477- page := newBrowsePage(r, q)
2478- r.Refs, err = g.browseRefs(ctx, r)
2479- if err != nil {
2480- return page, err
2481- }
2482- page.Refs = r.Refs
2483- if len(r.Refs) == 0 {
2484- return page, nil
2485- }
2486- page.Commit, err = g.browseCommit(ctx, r, q.Ref)
2487- if err != nil {
2488- return page, err
2489- }
2490- switch q.View {
2491- case "tree":
2492- err = g.browseTree(ctx, r, q, &page)
2493- case "file":
2494- err = g.browseFile(ctx, r, q, &page)
2495- case "history":
2496- err = g.browseHistory(ctx, r, q, &page)
2497- case "commit":
2498- err = g.browseDiff(ctx, r, &page)
2499- case "activity":
2500- default:
2501- err = errors.New("invalid: unknown repository view")
2502- }
2503- return page, err
2504-}
2505-
2506-func (g *GitRelay) browseCommand(ctx context.Context, r Repository, args ...string) *exec.Cmd {
2507- base := []string{"--no-pager", "--literal-pathspecs", "-c", "core.quotePath=false", "-c", "log.showSignature=false", "--git-dir", g.repoPath(r)}
2508- return exec.CommandContext(ctx, "git", append(base, args...)...)
2509-}
2510-
2511-func (g *GitRelay) browseRefs(ctx context.Context, r Repository) (map[string]string, error) {
2512- b, err := g.browseCommand(ctx, r, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/", "refs/tags/", "refs/nostr/").Output()
2513- if err != nil {
2514- return nil, fmt.Errorf("read repository refs: %w", err)
2515- }
2516- refs := make(map[string]string)
2517- for _, line := range strings.Split(string(b), "\n") {
2518- fields := strings.Fields(line)
2519- if len(fields) == 2 && r.Refs[fields[0]] == fields[1] {
2520- refs[fields[0]] = fields[1]
2521- }
2522- }
2523- return refs, nil
2524-}
2525-
2526-func (g *GitRelay) browseCommit(ctx context.Context, r Repository, ref string) (string, error) {
2527- if ref == "" || ref == "HEAD" {
2528- ref = strings.TrimPrefix(r.Head, "ref: ")
2529- }
2530- for _, candidate := range []string{ref, "refs/heads/" + ref, "refs/tags/" + ref} {
2531- if oid, ok := r.Refs[candidate]; ok {
2532- return g.peelBrowseCommit(ctx, r, oid)
2533- }
2534- }
2535- if !hexObject(ref) {
2536- return "", errors.New("not found: published ref")
2537- }
2538- oid, err := g.peelBrowseCommit(ctx, r, ref)
2539- if err != nil {
2540- return "", err
2541- }
2542- cmd := g.browseCommand(ctx, r, "rev-list", "--max-count=1", "--stdin")
2543- var input strings.Builder
2544- input.WriteString(oid + "\n")
2545- for _, tip := range r.Refs {
2546- input.WriteString("^" + tip + "\n")
2547- }
2548- cmd.Stdin = strings.NewReader(input.String())
2549- b, err := cmd.Output()
2550- if err != nil || len(bytes.TrimSpace(b)) != 0 {
2551- return "", errors.New("not found: published commit")
2552- }
2553- return oid, nil
2554-}
2555-
2556-func hexObject(value string) bool {
2557- if len(value) != 40 && len(value) != 64 {
2558- return false
2559- }
2560- for _, ch := range value {
2561- if !(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'f') {
2562- return false
2563- }
2564- }
2565- return true
2566-}
2567-
2568-func (g *GitRelay) peelBrowseCommit(ctx context.Context, r Repository, oid string) (string, error) {
2569- if !hexObject(oid) {
2570- return "", errors.New("invalid: object id")
2571- }
2572- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", oid+"^{commit}").Output()
2573- if err != nil {
2574- return "", errors.New("not found: commit")
2575- }
2576- return strings.TrimSpace(string(b)), nil
2577-}
2578-
2579-func readBrowseOutput(cmd *exec.Cmd, limit int64) ([]byte, bool, error) {
2580- out, err := cmd.StdoutPipe()
2581- if err != nil {
2582- return nil, false, err
2583- }
2584- if err = cmd.Start(); err != nil {
2585- return nil, false, err
2586- }
2587- b, readErr := io.ReadAll(io.LimitReader(out, limit+1))
2588- truncated := int64(len(b)) > limit
2589- if truncated || readErr != nil {
2590- _ = out.Close()
2591- _ = cmd.Process.Kill()
2592- }
2593- waitErr := cmd.Wait()
2594- if readErr != nil {
2595- return nil, false, readErr
2596- }
2597- if truncated {
2598- return b[:limit], true, nil
2599- }
2600- if waitErr != nil {
2601- return nil, false, fmt.Errorf("read Git object: %w", waitErr)
2602- }
2603- return b, false, nil
2604-}
2605-
2606-func (g *GitRelay) browseTree(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
2607- tree := page.Commit + ":" + q.Path
2608- cmd := g.browseCommand(ctx, r, "ls-tree", "-z", "-l", tree)
2609- out, err := cmd.StdoutPipe()
2610- if err != nil {
2611- return err
2612- }
2613- if err := cmd.Start(); err != nil {
2614- return err
2615- }
2616- reader := bufio.NewReader(out)
2617- readErr := readBrowseTree(reader, q, page)
2618- if readErr != nil || page.NextOffset >= 0 {
2619- // Closing this page cancels only its read-only Git child.
2620- _ = out.Close()
2621- _ = cmd.Process.Kill()
2622- }
2623- waitErr := cmd.Wait()
2624- if readErr != nil {
2625- return readErr
2626- }
2627- if page.NextOffset >= 0 {
2628- return nil
2629- }
2630- return waitErr
2631-}
2632-
2633-func readBrowseTree(reader *bufio.Reader, q BrowseRequest, page *BrowsePage) error {
2634- for index := 0; ; index++ {
2635- row, err := reader.ReadString(0)
2636- if errors.Is(err, io.EOF) && row == "" {
2637- return nil
2638- }
2639- if err != nil {
2640- return err
2641- }
2642- if index < q.Offset {
2643- continue
2644- }
2645- if len(page.Entries) >= q.Limit {
2646- page.NextOffset = index
2647- return nil
2648- }
2649- meta, name, ok := strings.Cut(strings.TrimSuffix(row, "\x00"), "\t")
2650- fields := strings.Fields(meta)
2651- if !ok || len(fields) != 4 {
2652- return errors.New("invalid Git tree entry")
2653- }
2654- var size int64
2655- if fields[3] != "-" {
2656- size, err = strconv.ParseInt(fields[3], 10, 64)
2657- if err != nil {
2658- return err
2659- }
2660- }
2661- page.Entries = append(page.Entries, BrowseEntry{Name: name, Path: path.Join(q.Path, name), Mode: fields[0], Type: fields[1], OID: fields[2], Size: size})
2662- }
2663-}
2664-
2665-func (g *GitRelay) fileObject(ctx context.Context, r Repository, commit, file string) (string, error) {
2666- if file == "" || !validBrowsePath(file) {
2667- return "", errors.New("invalid: file path")
2668- }
2669- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", commit+":"+file).Output()
2670- if err != nil {
2671- return "", os.ErrNotExist
2672- }
2673- oid := strings.TrimSpace(string(b))
2674- if !hexObject(oid) {
2675- return "", os.ErrNotExist
2676- }
2677- kind, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
2678- if err != nil || strings.TrimSpace(string(kind)) != "blob" {
2679- return "", errors.New("not found: source file")
2680- }
2681- return oid, nil
2682-}
2683-
2684-func (g *GitRelay) browseFile(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
2685- oid, err := g.fileObject(ctx, r, page.Commit, q.Path)
2686- if err != nil {
2687- return err
2688- }
2689- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "cat-file", "blob", oid), previewBytes)
2690- if err != nil {
2691- return err
2692- }
2693- page.Binary = bytes.IndexByte(b, 0) >= 0 || (!utf8.Valid(b) && !truncated)
2694- page.Truncated = truncated
2695- if !page.Binary {
2696- page.Content = strings.ToValidUTF8(string(b), "�")
2697- }
2698- return nil
2699-}
2700-
2701-func (g *GitRelay) browseHistory(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
2702- args := []string{"log", "--format=%H%x00%P%x00%an%x00%at%x00%s%x00", "--max-count=" + strconv.Itoa(q.Limit+1), "--skip=" + strconv.Itoa(q.Offset), page.Commit, "--"}
2703- if q.Path != "" {
2704- args = append(args, q.Path)
2705- }
2706- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, args...), previewBytes)
2707- if err != nil {
2708- return err
2709- }
2710- if truncated {
2711- return errors.New("commit metadata exceeds preview capacity")
2712- }
2713- fields := strings.Split(string(b), "\x00")
2714- for i := 0; i+4 < len(fields); i += 5 {
2715- if len(page.Commits) >= q.Limit {
2716- page.NextOffset = q.Offset + q.Limit
2717- break
2718- }
2719- stamp, err := strconv.ParseInt(fields[i+3], 10, 64)
2720- if err != nil {
2721- return err
2722- }
2723- page.Commits = append(page.Commits, BrowseCommit{OID: strings.TrimSpace(fields[i]), Parents: strings.Fields(fields[i+1]), Author: fields[i+2], Time: stamp, Subject: fields[i+4]})
2724- }
2725- return nil
2726-}
2727-
2728-func (g *GitRelay) browseDiff(ctx context.Context, r Repository, page *BrowsePage) error {
2729- if err := g.browseHistory(ctx, r, BrowseRequest{Limit: 1}, page); err != nil {
2730- return err
2731- }
2732- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "show", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", page.Commit, "--"), previewBytes)
2733- page.Diff, page.Truncated = strings.ToValidUTF8(string(b), "�"), truncated
2734- return err
2735-}
2736-
2737-// WriteSource streams a published source blob directly to the destination.
2738-func (g *GitRelay) WriteSource(ctx context.Context, q BrowseRequest, dst io.Writer) error {
2739- r, err := g.BrowseRepository(q.Owner, q.Repo)
2740- if err != nil {
2741- return err
2742- }
2743- r.Refs, err = g.browseRefs(ctx, r)
2744- if err != nil {
2745- return err
2746- }
2747- commit, err := g.browseCommit(ctx, r, q.Ref)
2748- if err != nil {
2749- return err
2750- }
2751- oid, err := g.fileObject(ctx, r, commit, q.Path)
2752- if err != nil {
2753- return err
2754- }
2755- cmd := g.browseCommand(ctx, r, "cat-file", "blob", oid)
2756- cmd.Stdout = dst
2757- if err := cmd.Run(); err != nil {
2758- return fmt.Errorf("stream source: %w", err)
2759- }
2760- return nil
2761-}
2762diff --git a/internal/gitrelay/browse_test.go b/internal/gitrelay/browse_test.go
2763deleted file mode 100644
2764index 7ca8753..0000000
2765--- a/internal/gitrelay/browse_test.go
2766+++ /dev/null
2767@@ -1,190 +0,0 @@
2768-package gitrelay
2769-
2770-import (
2771- "bytes"
2772- "context"
2773- "os"
2774- "os/exec"
2775- "path/filepath"
2776- "strings"
2777- "testing"
2778-)
2779-
2780-func browseFixture(t *testing.T) (*GitRelay, Repository, string) {
2781- t.Helper()
2782- root := t.TempDir()
2783- r := Repository{Owner: strings.Repeat("a", 64), Identifier: "browse", Head: "refs/heads/main"}
2784- g := &GitRelay{root: root, repos: map[string]Repository{}}
2785- dir := g.repoPath(r)
2786- if err := os.MkdirAll(dir, 0700); err != nil {
2787- t.Fatal(err)
2788- }
2789- run := func(args ...string) string {
2790- t.Helper()
2791- c := exec.Command("git", append([]string{"-C", dir}, args...)...)
2792- c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Browser Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Browser Test", "GIT_COMMITTER_EMAIL=test@example.test")
2793- b, err := c.CombinedOutput()
2794- if err != nil {
2795- t.Fatalf("git %v: %v %s", args, err, b)
2796- }
2797- return strings.TrimSpace(string(b))
2798- }
2799- run("init", "-b", "main")
2800- for name, body := range map[string]string{"source.go": "package main\n// <script>alert(1)</script>\n", "[literal]*.txt": "literal path", "large.txt": strings.Repeat("x", 300*1024), "binary.bin": "a\x00b"} {
2801- if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil {
2802- t.Fatal(err)
2803- }
2804- }
2805- run("add", ".")
2806- run("commit", "-m", "first source")
2807- first := run("rev-parse", "HEAD")
2808- if err := os.WriteFile(filepath.Join(dir, "source.go"), []byte("package main\n// second\n"), 0600); err != nil {
2809- t.Fatal(err)
2810- }
2811- run("commit", "-am", "second source")
2812- r.Refs = map[string]string{"refs/heads/main": run("rev-parse", "HEAD")}
2813- g.repos[key(r.Owner, r.Identifier)] = r
2814- // Browsing normally uses a bare repository; this fixture switches --git-dir to .git.
2815- g.root = filepath.Join(root, "bare")
2816- if err := os.MkdirAll(filepath.Dir(g.repoPath(r)), 0700); err != nil {
2817- t.Fatal(err)
2818- }
2819- cmd := exec.Command("git", "clone", "--bare", dir, g.repoPath(r))
2820- if b, err := cmd.CombinedOutput(); err != nil {
2821- t.Fatalf("bare clone: %v %s", err, b)
2822- }
2823- return g, r, first
2824-}
2825-
2826-func TestBrowseRepositorySourceAndHistory(t *testing.T) {
2827- g, r, first := browseFixture(t)
2828- ctx := context.Background()
2829- q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier}
2830- page, err := g.Browse(ctx, q)
2831- if err != nil || len(page.Entries) != 4 {
2832- t.Fatalf("tree: %+v %v", page, err)
2833- }
2834- q.View, q.Path, q.Ref = "file", "[literal]*.txt", first
2835- page, err = g.Browse(ctx, q)
2836- if err != nil || page.Content != "literal path" {
2837- t.Fatalf("literal: %+v %v", page, err)
2838- }
2839- q.Path = "source.go"
2840- page, err = g.Browse(ctx, q)
2841- if err != nil || !strings.Contains(page.Content, "<script>") {
2842- t.Fatalf("historical source: %+v %v", page, err)
2843- }
2844- q.View, q.Ref, q.Path, q.Limit = "history", "main", "", 1
2845- page, err = g.Browse(ctx, q)
2846- if err != nil || len(page.Commits) != 1 || page.NextOffset != 1 {
2847- t.Fatalf("history: %+v %v", page, err)
2848- }
2849- q.View = "commit"
2850- page, err = g.Browse(ctx, q)
2851- if err != nil || !strings.Contains(page.Diff, "+// second") {
2852- t.Fatalf("diff: %+v %v", page, err)
2853- }
2854-}
2855-
2856-func TestFetchMissingUsesCompleteLocalObjects(t *testing.T) {
2857- g, repo, _ := browseFixture(t)
2858- expected := map[string]string{"refs/heads/repaired": repo.Refs["refs/heads/main"]}
2859- if err := g.FetchMissing(context.Background(), repo, nil, expected); err != nil {
2860- t.Fatalf("complete local inventory should not need a network source: %v", err)
2861- }
2862- native, err := g.nativeRefs(repo)
2863- if err != nil || native["refs/heads/repaired"] != expected["refs/heads/repaired"] {
2864- t.Fatalf("locally available objects were not projected to expected refs: %v %v", native, err)
2865- }
2866-}
2867-
2868-func TestBrowseRejectsUnsafeOrUnpublishedObjects(t *testing.T) {
2869- g, r, _ := browseFixture(t)
2870- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "hash-object", "-w", "--stdin")
2871- cmd.Stdin = strings.NewReader("unpublished secret")
2872- id, err := cmd.Output()
2873- if err != nil {
2874- t.Fatal(err)
2875- }
2876- for _, q := range []BrowseRequest{
2877- {Owner: r.Owner, Repo: r.Identifier, Ref: "--output=/tmp/nope"},
2878- {Owner: r.Owner, Repo: r.Identifier, Ref: strings.TrimSpace(string(id))},
2879- {Owner: r.Owner, Repo: r.Identifier, Path: "../config"},
2880- {Owner: r.Owner, Repo: r.Identifier, Path: "/etc/passwd"},
2881- } {
2882- if _, err := g.Browse(context.Background(), q); err == nil {
2883- t.Fatalf("accepted unsafe request: %+v", q)
2884- }
2885- }
2886-}
2887-
2888-func TestBrowseFilePreviewIsBoundedAndBinaryAware(t *testing.T) {
2889- g, r, _ := browseFixture(t)
2890- for _, name := range []string{"large.txt", "binary.bin"} {
2891- page, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, View: "file", Path: name})
2892- if err != nil {
2893- t.Fatal(err)
2894- }
2895- if name == "large.txt" && (!page.Truncated || len(page.Content) > 256*1024) {
2896- t.Fatal("unbounded preview")
2897- }
2898- if name == "binary.bin" && (!page.Binary || page.Content != "") {
2899- t.Fatal("binary shown as source")
2900- }
2901- }
2902-}
2903-
2904-func TestBrowseHiddenCommitAndFullBinaryDownload(t *testing.T) {
2905- g, r, _ := browseFixture(t)
2906- cmd := g.browseCommand(context.Background(), r, "rev-parse", "HEAD^{tree}")
2907- tree, err := cmd.Output()
2908- if err != nil {
2909- t.Fatal(err)
2910- }
2911- cmd = g.browseCommand(context.Background(), r, "commit-tree", strings.TrimSpace(string(tree)))
2912- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.test")
2913- cmd.Stdin = strings.NewReader("unpublished commit")
2914- hidden, err := cmd.Output()
2915- if err != nil {
2916- t.Fatal(err)
2917- }
2918- oid := strings.TrimSpace(string(hidden))
2919- if err := g.browseCommand(context.Background(), r, "update-ref", "refs/tinyrelay/pending/secret", oid).Run(); err != nil {
2920- t.Fatal(err)
2921- }
2922- if _, err := g.Browse(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Ref: oid}); err == nil {
2923- t.Fatal("unpublished commit exposed")
2924- }
2925- var result bytes.Buffer
2926- if err := g.WriteSource(context.Background(), BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Path: "large.txt"}, &result); err != nil {
2927- t.Fatal(err)
2928- }
2929- if result.Len() != 300*1024 {
2930- t.Fatalf("download truncated: %d", result.Len())
2931- }
2932-}
2933-
2934-func TestBrowseTreePaginationHasNoMissingEntries(t *testing.T) {
2935- g, r, _ := browseFixture(t)
2936- names := map[string]bool{}
2937- q := BrowseRequest{Owner: r.Owner, Repo: r.Identifier, Limit: 1}
2938- for {
2939- page, err := g.Browse(context.Background(), q)
2940- if err != nil {
2941- t.Fatal(err)
2942- }
2943- for _, entry := range page.Entries {
2944- if names[entry.Name] {
2945- t.Fatal("duplicate entry")
2946- }
2947- names[entry.Name] = true
2948- }
2949- if page.NextOffset < 0 {
2950- break
2951- }
2952- q.Offset = page.NextOffset
2953- }
2954- if len(names) != 4 {
2955- t.Fatalf("missing entries: %v", names)
2956- }
2957-}
2958diff --git a/internal/gitrelay/capabilities_test.go b/internal/gitrelay/capabilities_test.go
2959deleted file mode 100644
2960index e4a33b6..0000000
2961--- a/internal/gitrelay/capabilities_test.go
2962+++ /dev/null
2963@@ -1,34 +0,0 @@
2964-package gitrelay
2965-
2966-import (
2967- "context"
2968- "reflect"
2969- "testing"
2970-
2971- "github.com/FelineStateMachine/tinyrelay/internal/policy"
2972-)
2973-
2974-func TestCapabilitiesRequireEventSyncTransport(t *testing.T) {
2975- p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true}}
2976- g := &GitRelay{policy: func() policy.Policy { return p }}
2977- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
2978- t.Fatalf("without event transport: %v", got)
2979- }
2980- g.eventSync = func(context.Context, Repository) error { return nil }
2981- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06"}) {
2982- t.Fatalf("with event transport: %v", got)
2983- }
2984- p.Features.Grasp02 = false
2985- if got := g.Capabilities(); !reflect.DeepEqual(got, []string{"GRASP-01", "GRASP-06"}) {
2986- t.Fatalf("without base sync: %v", got)
2987- }
2988-}
2989-
2990-func TestCapabilitiesPreservePrivateProfiles(t *testing.T) {
2991- p := policy.Policy{Reads: "members", Features: policy.Features{Grasp: true, Grasp02: true, Grasp03: true, Grasp06: true, Grasp08: true}}
2992- g := &GitRelay{policy: func() policy.Policy { return p }, eventSync: func(context.Context, Repository) error { return nil }}
2993- want := []string{"GRASP-01", "GRASP-02", "GRASP-03", "GRASP-06", "GRASP-08"}
2994- if got := g.SupportedGRASPs(); !reflect.DeepEqual(got, want) {
2995- t.Fatalf("private profiles: %v", got)
2996- }
2997-}
2998diff --git a/internal/gitrelay/cgi_test.go b/internal/gitrelay/cgi_test.go
2999deleted file mode 100644
3000index 86e567c..0000000
3001--- a/internal/gitrelay/cgi_test.go
3002+++ /dev/null
3003@@ -1,116 +0,0 @@
3004-package gitrelay
3005-
3006-import (
3007- "bytes"
3008- "compress/gzip"
3009- "context"
3010- "errors"
3011- "io"
3012- "net/http"
3013- "net/http/httptest"
3014- "os"
3015- "path/filepath"
3016- "strconv"
3017- "strings"
3018- "testing"
3019- "time"
3020-)
3021-
3022-func TestCGIFailureAfterHeadersPreservesPartialPack(t *testing.T) {
3023- dir := t.TempDir()
3024- script := "#!/bin/sh\nprintf 'Content-Type: application/octet-stream\\r\\n\\r\\npartial-pack'\nprintf 'backend failed' >&2\nexit 1\n"
3025- if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0700); err != nil {
3026- t.Fatal(err)
3027- }
3028- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
3029- g := &GitRelay{root: dir}
3030- w := httptest.NewRecorder()
3031- err := g.cgi(httptest.NewRequest(http.MethodGet, "/", nil), w, Repository{Owner: "owner", Identifier: "test"}, "/git-upload-pack")
3032- var streamed *cgiStreamError
3033- if !errors.As(err, &streamed) || !strings.Contains(err.Error(), "backend failed") {
3034- t.Fatalf("backend failure did not retain committed-response state: %v", err)
3035- }
3036- if got := w.Body.String(); got != "partial-pack" {
3037- t.Fatalf("error corrupted pack response: %q", got)
3038- }
3039-}
3040-
3041-func TestCGIGzipInputDecodesAndRemovesSpool(t *testing.T) {
3042- want := strings.Repeat("object negotiation\n", 10000)
3043- var compressed bytes.Buffer
3044- zw := gzip.NewWriter(&compressed)
3045- if _, err := io.WriteString(zw, want); err != nil {
3046- t.Fatal(err)
3047- }
3048- if err := zw.Close(); err != nil {
3049- t.Fatal(err)
3050- }
3051- req := httptest.NewRequest(http.MethodPost, "/", &compressed)
3052- req.Header.Set("Content-Encoding", "gzip")
3053- body, length, cleanup, err := cgiInput(req)
3054- if err != nil {
3055- t.Fatal(err)
3056- }
3057- defer cleanup()
3058- got, err := io.ReadAll(body)
3059- if err != nil || string(got) != want || length != strconv.Itoa(len(want)) {
3060- t.Fatalf("decoded request: length=%s bytes=%d err=%v", length, len(got), err)
3061- }
3062- file, ok := body.(*os.File)
3063- if !ok {
3064- t.Fatal("compressed request did not use a disk spool")
3065- }
3066- cleanup()
3067- if _, err := os.Stat(file.Name()); !errors.Is(err, os.ErrNotExist) {
3068- t.Fatalf("temporary request remains after cleanup: %v", err)
3069- }
3070-}
3071-
3072-// A backend that waits for more input after its first output must not require
3073-// the whole pack to fit in memory before the client can receive any of it.
3074-func TestCGIStreamsBeforeBackendExits(t *testing.T) {
3075- dir := t.TempDir()
3076- script := "#!/bin/sh\nprintf 'Content-Type: application/octet-stream\\r\\n\\r\\nfirst'\nread release\nprintf 'last'\n"
3077- if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0700); err != nil {
3078- t.Fatal(err)
3079- }
3080- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
3081- input, release := io.Pipe()
3082- defer input.Close()
3083- defer release.Close()
3084- g := &GitRelay{root: dir}
3085- finished := make(chan error, 1)
3086- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3087- r.Body = input
3088- finished <- g.cgi(r, w, Repository{Owner: "owner", Identifier: "test"}, "/git-upload-pack")
3089- }))
3090- defer server.Close()
3091- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
3092- defer cancel()
3093- req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
3094- if err != nil {
3095- t.Fatal(err)
3096- }
3097- response, err := server.Client().Do(req)
3098- if err != nil {
3099- _ = release.Close()
3100- t.Fatalf("backend output was not streamed: %v", err)
3101- }
3102- defer response.Body.Close()
3103- first := make([]byte, 5)
3104- if _, err := io.ReadFull(response.Body, first); err != nil || string(first) != "first" {
3105- _ = release.Close()
3106- t.Fatalf("first backend chunk = %q, %v", first, err)
3107- }
3108- if _, err := io.WriteString(release, "continue\n"); err != nil {
3109- t.Fatal(err)
3110- }
3111- _ = release.Close()
3112- rest, err := io.ReadAll(response.Body)
3113- if err != nil || string(rest) != "last" {
3114- t.Fatalf("remaining backend output = %q, %v", rest, err)
3115- }
3116- if err := <-finished; err != nil {
3117- t.Fatal(err)
3118- }
3119-}
3120diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go
3121deleted file mode 100644
3122index 1fdf8df..0000000
3123--- a/internal/gitrelay/collaboration_diff.go
3124+++ /dev/null
3125@@ -1,118 +0,0 @@
3126-package gitrelay
3127-
3128-import (
3129- "context"
3130- "errors"
3131- "fmt"
3132- "strings"
3133- "time"
3134-)
3135-
3136-const collaborationDiffBytes = 512 * 1024
3137-const collaborationDiffTimeout = 5 * time.Second
3138-
3139-// CollaborationDiff is the result of rendering a pull request's local Git
3140-// objects. Status is "ok" when Diff is authoritative and
3141-// "diff_unavailable" when one or both commits are not present locally.
3142-type CollaborationDiff struct {
3143- Status string `json:"status"`
3144- Base string `json:"base,omitempty"`
3145- Tip string `json:"tip,omitempty"`
3146- MergeBase string `json:"merge_base,omitempty"`
3147- Diff string `json:"diff,omitempty"`
3148- Truncated bool `json:"truncated,omitempty"`
3149- Reason string `json:"reason,omitempty"`
3150-}
3151-
3152-// PRDiff renders a bounded diff from commits already present in the
3153-// repository. It never fetches or resolves a remote ref. A supplied base is
3154-// used only after it is verified as a local commit; when it is empty, Git's
3155-// merge-base with the repository target is used.
3156-func (g *GitRelay) PRDiff(ctx context.Context, r Repository, base, tip string) CollaborationDiff {
3157- ctx, cancel := context.WithTimeout(ctx, collaborationDiffTimeout)
3158- defer cancel()
3159- result := CollaborationDiff{Status: "diff_unavailable", Base: base, Tip: tip}
3160- if !isObjectID(base) && base != "" {
3161- result.Reason = "invalid base commit"
3162- return result
3163- }
3164- if !isObjectID(tip) {
3165- result.Reason = "invalid tip commit"
3166- return result
3167- }
3168- if err := g.localCommit(ctx, r, tip); err != nil {
3169- result.Reason = "tip commit is unavailable locally"
3170- return result
3171- }
3172- if base != "" {
3173- if err := g.localCommit(ctx, r, base); err != nil {
3174- result.Reason = "base commit is unavailable locally"
3175- return result
3176- }
3177- }
3178- target, err := g.targetCommit(ctx, r)
3179- if err != nil {
3180- result.Reason = "repository target commit is unavailable locally"
3181- return result
3182- }
3183- if base != "" {
3184- // A PR base is meaningful only when it is reachable from both sides of
3185- // the comparison. Checking the current target as well prevents an old
3186- // or malicious PR-only ancestor from being presented as the repository
3187- // diff base.
3188- if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, tip).Run(); err != nil {
3189- result.Reason = "base is not an ancestor of tip"
3190- return result
3191- }
3192- if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, target).Run(); err != nil {
3193- result.Reason = "base is not an ancestor of repository target"
3194- return result
3195- }
3196- result.MergeBase = base
3197- } else {
3198- b, err := g.browseCommand(ctx, r, "merge-base", "--", tip, target).Output()
3199- if err != nil || !isObjectID(strings.TrimSpace(string(b))) {
3200- result.Reason = "merge base is unavailable locally"
3201- return result
3202- }
3203- result.MergeBase = strings.TrimSpace(string(b))
3204- result.Base = result.MergeBase
3205- }
3206-
3207- b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "diff", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", "--binary", result.MergeBase, tip, "--"), collaborationDiffBytes)
3208- if err != nil {
3209- result.Reason = "unable to render local diff"
3210- return result
3211- }
3212- result.Status = "ok"
3213- result.Diff = strings.ToValidUTF8(string(b), "�")
3214- result.Truncated = truncated
3215- return result
3216-}
3217-
3218-func (g *GitRelay) targetCommit(ctx context.Context, r Repository) (string, error) {
3219- target := strings.TrimPrefix(r.Head, "ref: ")
3220- if target == "" || !validBrowsePath(target) {
3221- return "", errors.New("missing repository target")
3222- }
3223- b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", target+"^{commit}").Output()
3224- if err != nil {
3225- return "", err
3226- }
3227- target = strings.TrimSpace(string(b))
3228- if !isObjectID(target) || g.localCommit(ctx, r, target) != nil {
3229- return "", errors.New("target commit unavailable")
3230- }
3231- return target, nil
3232-}
3233-
3234-func (g *GitRelay) localCommit(ctx context.Context, r Repository, oid string) error {
3235- if !isObjectID(oid) {
3236- return errors.New("invalid object id")
3237- }
3238- b, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
3239- if err != nil || strings.TrimSpace(string(b)) != "commit" {
3240- return fmt.Errorf("commit unavailable: %s", oid)
3241- }
3242- return nil
3243-}
3244diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go
3245deleted file mode 100644
3246index b08a4cf..0000000
3247--- a/internal/gitrelay/collaboration_diff_test.go
3248+++ /dev/null
3249@@ -1,112 +0,0 @@
3250-package gitrelay
3251-
3252-import (
3253- "context"
3254- "os"
3255- "os/exec"
3256- "strings"
3257- "testing"
3258-)
3259-
3260-func TestPRDiffUsesLocalCommitsAndReportsMetadata(t *testing.T) {
3261- g, r, base := browseFixture(t)
3262- tip := r.Refs["refs/heads/main"]
3263- got := g.PRDiff(context.Background(), r, base, tip)
3264- if got.Status != "ok" || got.Base != base || got.Tip != tip || got.MergeBase != base {
3265- t.Fatalf("result = %+v", got)
3266- }
3267- if !strings.Contains(got.Diff, "+// second") || got.Truncated {
3268- t.Fatalf("diff = %+v", got)
3269- }
3270-}
3271-
3272-func TestPRDiffDoesNotTurnMissingOrUnrelatedObjectsIntoEmptyDiff(t *testing.T) {
3273- g, r, base := browseFixture(t)
3274- tip := r.Refs["refs/heads/main"]
3275- missing := strings.Repeat("f", 40)
3276- if got := g.PRDiff(context.Background(), r, base, missing); got.Status != "diff_unavailable" || got.Reason == "" {
3277- t.Fatalf("missing tip result = %+v", got)
3278- }
3279- if got := g.PRDiff(context.Background(), r, missing, r.Refs["refs/heads/main"]); got.Status != "diff_unavailable" || got.Reason == "" {
3280- t.Fatalf("missing base result = %+v", got)
3281- }
3282- // The first commit in the fixture is not related to an independently
3283- // created root. The backend must disclose that it cannot establish a PR
3284- // base instead of presenting a whole-tree comparison as a normal diff.
3285- tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()
3286- if err != nil {
3287- t.Fatal(err)
3288- }
3289- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", "unrelated")
3290- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
3291- otherBytes, err := cmd.Output()
3292- if err != nil {
3293- t.Fatal(err)
3294- }
3295- other := strings.TrimSpace(string(otherBytes))
3296- if got := g.PRDiff(context.Background(), r, other, base); got.Status != "diff_unavailable" {
3297- t.Fatalf("unrelated result = %+v", got)
3298- }
3299- // This base is an ancestor of the PR tip but belongs only to that PR;
3300- // it is not an ancestor of the repository target.
3301- treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
3302- if err != nil {
3303- t.Fatal(err)
3304- }
3305- commit := func(parents ...string) string {
3306- args := []string{"--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes))}
3307- for _, parent := range parents {
3308- args = append(args, "-p", parent)
3309- }
3310- args = append(args, "-m", "PR-only")
3311- cmd := exec.Command("git", args...)
3312- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
3313- out, err := cmd.Output()
3314- if err != nil {
3315- t.Fatal(err)
3316- }
3317- return strings.TrimSpace(string(out))
3318- }
3319- prBase := commit(base)
3320- prTip := commit(prBase)
3321- if got := g.PRDiff(context.Background(), r, prBase, prTip); got.Status != "diff_unavailable" {
3322- t.Fatalf("PR-only ancestor result = %+v (main tip %s)", got, tip)
3323- }
3324-}
3325-
3326-func TestPRDiffAcceptsBaseWhenTargetBranchDiverged(t *testing.T) {
3327- g, r, base := browseFixture(t)
3328- tip := r.Refs["refs/heads/main"]
3329- treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
3330- if err != nil {
3331- t.Fatal(err)
3332- }
3333- commit := func(message string) string {
3334- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes)), "-p", base, "-m", message)
3335- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")
3336- out, err := cmd.Output()
3337- if err != nil {
3338- t.Fatal(err)
3339- }
3340- return strings.TrimSpace(string(out))
3341- }
3342- target := commit("target branch")
3343- if err := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "refs/heads/target", target).Run(); err != nil {
3344- t.Fatal(err)
3345- }
3346- r.Head = "refs/heads/target"
3347- got := g.PRDiff(context.Background(), r, base, tip)
3348- if got.Status != "ok" || got.MergeBase != base {
3349- t.Fatalf("diverged target result = %+v", got)
3350- }
3351-}
3352-
3353-func TestPRDiffFindsMergeBaseFromRepositoryHead(t *testing.T) {
3354- g, r, base := browseFixture(t)
3355- tip := r.Refs["refs/heads/main"]
3356- r.Head = "refs/heads/main"
3357- got := g.PRDiff(context.Background(), r, "", tip)
3358- if got.Status != "ok" || got.MergeBase != tip || got.Base != tip {
3359- t.Fatalf("result = %+v (base fixture commit %s)", got, base)
3360- }
3361-}
3362diff --git a/internal/gitrelay/doc.go b/internal/gitrelay/doc.go
3363deleted file mode 100644
3364index f16166c..0000000
3365--- a/internal/gitrelay/doc.go
3366+++ /dev/null
3367@@ -1,24 +0,0 @@
3368-// Package gitrelay coordinates signed Nostr repository events with native
3369-// Git object storage and smart HTTP. Signed events establish repository refs,
3370-// metadata and maintainer authority.
3371-//
3372-// GitRelay stores bare repositories under Config.Root. Journal files under
3373-// .tinyrelay/journal make event-to-ref transitions recoverable, while pending
3374-// state files keep a signed ref announcement out of the visible repository
3375-// until every advertised tip object is present. New creates the root,
3376-// recovers journals and reloads repository metadata from the event store.
3377-// Synchronization runs through Service.Tick or transport callbacks invoked by
3378-// the caller.
3379-//
3380-// Validate checks event and maintainer admission. After the caller durably
3381-// stores the event, CommitAfterStore stages the corresponding Git transition.
3382-// CommitAfterStoreNoNotify serves callers already holding the host publication
3383-// fence. PromotePending completes a receive-pack repair and invokes OnPromote
3384-// after state becomes visible.
3385-//
3386-// ServeHTTP exposes Git smart HTTP for public and private repositories.
3387-// Private requests pass through AuthorizeHTTP. GRASP-08 private peer traffic
3388-// is limited to Config.PrivatePeers and is signed by HTTPAuth. GitSync and
3389-// EventSync supply synchronization transports; the daemon supplies the relay
3390-// client and WebSocket lifecycle.
3391-package gitrelay
3392diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
3393deleted file mode 100644
3394index e030113..0000000
3395--- a/internal/gitrelay/gitrelay.go
3396+++ /dev/null
3397@@ -1,162 +0,0 @@
3398-package gitrelay
3399-
3400-import (
3401- "context"
3402- "errors"
3403- "fmt"
3404- "net/http"
3405- "os"
3406- "strings"
3407- "sync"
3408-
3409- "github.com/FelineStateMachine/tinyrelay/internal/event"
3410- "github.com/FelineStateMachine/tinyrelay/internal/policy"
3411- "github.com/FelineStateMachine/tinyrelay/internal/storage"
3412-)
3413-
3414-// Repository is the resolved authority and Git state for one hosted project.
3415-type Repository struct {
3416- Owner string
3417- Identifier string
3418- EventID string
3419- Private bool
3420- Clone []string
3421- Relays []string
3422- Maintainers []string
3423- Refs map[string]string
3424- Head string
3425- Alternative bool
3426-}
3427-
3428-// Maintainer is one key that may sign repository state, push refs and change
3429-// status. Role is "owner", "maintainer" for a key in the announcement's
3430-// maintainers tag, or "agent" for a key the host vouches for under an agent
3431-// grant; Name carries the grant's label when it has one.
3432-type Maintainer struct {
3433- PubKey string `json:"pubkey"`
3434- Role string `json:"role"`
3435- Name string `json:"name,omitempty"`
3436-}
3437-
3438-// MaintainerSource answers whether a key maintains a repository beyond the
3439-// owner and the announcement's maintainers tag. The host relay supplies it so
3440-// authority it records elsewhere, such as an agent grant, counts in every
3441-// place this package checks maintainers.
3442-type MaintainerSource interface {
3443- IsMaintainer(ctx context.Context, r Repository, pubkey string) bool
3444-}
3445-
3446-// Config supplies the event store, Git root, authorization and transport hooks.
3447-type Config struct {
3448- Store *storage.Store
3449- Root string
3450- Policy func() policy.Policy
3451- Authorize func(context.Context, event.Event, Repository) error
3452- // Maintainers extends maintainer checks with host-side authority. It is
3453- // consulted only after the owner and the maintainers tag.
3454- Maintainers MaintainerSource
3455- // AuthorizeHTTP is called for private repositories and for operators that
3456- // want signed/NIP-98 authorization at the Git HTTP boundary.
3457- AuthorizeHTTP func(context.Context, *http.Request, Repository) error
3458- // ServiceURL is used to reject recursive GRASP sources and to construct
3459- // advertised clone URLs. It is optional for an embedded relay.
3460- ServiceURL string
3461- // PublicURL is the canonical HTTPS URL used in repository advertisements;
3462- // ServiceURL is used when PublicURL is empty.
3463- PublicURL string
3464- // EnableGRASP06 enables the alternative PR repository surface. It is kept
3465- // opt-in because publishing a capability creates an interoperability
3466- // promise beyond the base GRASP-01 HTTP endpoint.
3467- EnableGRASP06 bool
3468- // AllowMissingObjects is ignored.
3469- // Missing objects enter the durable pending path and stay hidden until
3470- // receive-pack or repair supplies every advertised tip.
3471- AllowMissingObjects bool
3472- // AllowPrivateRelays permits operator-controlled private DNS targets for
3473- // self-hosted networks. Public deployments should leave it disabled.
3474- AllowPrivateRelays bool
3475- // PrivatePeers are operator-configured GRASP-08 origins. Only these
3476- // sources may receive signed outbound Git requests.
3477- PrivatePeers []string
3478- // HTTPAuth signs requests to configured private peers. It is never used for
3479- // sources that are merely present in a repository announcement.
3480- HTTPAuth HTTPAuthSigner
3481- // GitSync and EventSync are operator-owned transport callbacks. Keeping
3482- // transport outside this package allows native websocket/NIP-77, HTTP, or
3483- // an internal queue without making GRASP depend on one client library.
3484- GitSync func(context.Context, Repository) error
3485- EventSync func(context.Context, Repository) error
3486- // OnPromote is called after a pending state becomes object-complete and
3487- // visible. The host relay uses it to release event visibility/live fanout.
3488- OnPromote func(context.Context, string, Repository) error
3489-}
3490-
3491-// GitRelay coordinates signed repository events with native bare repositories.
3492-type GitRelay struct {
3493- store *storage.Store
3494- root string
3495- policy func() policy.Policy
3496- authorize func(context.Context, event.Event, Repository) error
3497- authorizeHTTP func(context.Context, *http.Request, Repository) error
3498- maintainers MaintainerSource
3499- serviceURL string
3500- gitSync func(context.Context, Repository) error
3501- eventSync func(context.Context, Repository) error
3502- onPromote func(context.Context, string, Repository) error
3503- grasp06 bool
3504- allowMissing bool
3505- allowPrivate bool
3506- privatePeers []string
3507- httpAuth HTTPAuthSigner
3508- mu sync.RWMutex
3509- repos map[string]Repository
3510- pending map[string]struct{}
3511- commitMu sync.Mutex
3512- tickMu sync.Mutex
3513- repoConfigMu sync.Mutex
3514- configured map[string]struct{}
3515-}
3516-
3517-func New(cfg Config) (*GitRelay, error) {
3518- if cfg.Store == nil {
3519- return nil, errors.New("git relay: store is required")
3520- }
3521- if cfg.Root == "" {
3522- return nil, errors.New("git relay: root is required")
3523- }
3524- if err := os.MkdirAll(cfg.Root, 0700); err != nil {
3525- return nil, fmt.Errorf("git relay root: %w", err)
3526- }
3527- p := cfg.Policy
3528- if p == nil {
3529- p = func() policy.Policy { return policy.Defaults("") }
3530- }
3531- serviceURL := cfg.PublicURL
3532- if serviceURL == "" {
3533- serviceURL = cfg.ServiceURL
3534- }
3535- g := &GitRelay{store: cfg.Store, root: cfg.Root, policy: p, authorize: cfg.Authorize, authorizeHTTP: cfg.AuthorizeHTTP, maintainers: cfg.Maintainers, serviceURL: strings.TrimRight(serviceURL, "/"), grasp06: cfg.EnableGRASP06, allowMissing: cfg.AllowMissingObjects, allowPrivate: cfg.AllowPrivateRelays, privatePeers: append([]string(nil), cfg.PrivatePeers...), httpAuth: cfg.HTTPAuth, gitSync: cfg.GitSync, eventSync: cfg.EventSync, onPromote: cfg.OnPromote, repos: make(map[string]Repository), pending: make(map[string]struct{})}
3536- if err := g.recoverJournals(); err != nil {
3537- return nil, err
3538- }
3539- if err := g.Reload(context.Background()); err != nil {
3540- return nil, err
3541- }
3542- return g, nil
3543-}
3544-
3545-func key(owner, identifier string) string { return owner + "\x00" + identifier }
3546-
3547-// IsMaintainer reports whether pubkey may sign state and push refs for a
3548-// repository: the owner, a key in the announcement's maintainers tag, or a key
3549-// the configured MaintainerSource vouches for. Every maintainer check in this
3550-// package goes through here so the host's answer applies uniformly.
3551-func (g *GitRelay) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {
3552- if pubkey == "" {
3553- return false
3554- }
3555- if pubkey == r.Owner || contains(r.Maintainers, pubkey) {
3556- return true
3557- }
3558- return g.maintainers != nil && g.maintainers.IsMaintainer(ctx, r, pubkey)
3559-}
3560diff --git a/internal/gitrelay/gitrelay_test.go b/internal/gitrelay/gitrelay_test.go
3561deleted file mode 100644
3562index 080bc87..0000000
3563--- a/internal/gitrelay/gitrelay_test.go
3564+++ /dev/null
3565@@ -1,820 +0,0 @@
3566-package gitrelay
3567-
3568-import (
3569- "context"
3570- "encoding/json"
3571- "errors"
3572- "io"
3573- "net/http"
3574- "net/http/httptest"
3575- "os"
3576- "os/exec"
3577- "path/filepath"
3578- "strings"
3579- "testing"
3580- "time"
3581-
3582- "github.com/FelineStateMachine/tinyrelay/internal/event"
3583- "github.com/FelineStateMachine/tinyrelay/internal/policy"
3584- "github.com/FelineStateMachine/tinyrelay/internal/storage"
3585-)
3586-
3587-func TestParsePathAndNpub(t *testing.T) {
3588- if _, _, _, ok := parsePath("/not-a-repo"); ok {
3589- t.Fatal("accepted malformed repository path")
3590- }
3591- // This is the bech32 encoding of 32 zero bytes.
3592- npub := "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
3593- if _, err := decodeNPub(npub); err == nil {
3594- t.Fatal("accepted an invalid checksum")
3595- }
3596-}
3597-
3598-func TestPublishStagesRepositoryAndSignedStateHook(t *testing.T) {
3599- root := t.TempDir()
3600- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3601- if err != nil {
3602- t.Fatal(err)
3603- }
3604- defer store.Close()
3605- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3606- if err != nil {
3607- t.Fatal(err)
3608- }
3609- secret := strings.Repeat("0", 63) + "1"
3610- owner, err := event.PublicKey(secret)
3611- if err != nil {
3612- t.Fatal(err)
3613- }
3614- announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"clone", "https://relay.example/" + owner + "/demo.git"}}}
3615- if err := event.Sign(&announcement, secret); err != nil {
3616- t.Fatal(err)
3617- }
3618- if err := g.Publish(context.Background(), announcement); err != nil {
3619- t.Fatal(err)
3620- }
3621- state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.Repeat("a", 40)}}}
3622- if err := event.Sign(&state, secret); err != nil {
3623- t.Fatal(err)
3624- }
3625- if err := g.Publish(context.Background(), state); err != nil {
3626- t.Fatal(err)
3627- }
3628- repo := filepath.Join(root, "git", owner, "demo.git")
3629- if _, err := os.Stat(filepath.Join(repo, "HEAD")); err != nil {
3630- t.Fatalf("bare repository missing: %v", err)
3631- }
3632- hook, err := os.ReadFile(filepath.Join(repo, "hooks", "pre-receive"))
3633- if err != nil || !strings.Contains(string(hook), "tinyrelay.refs") {
3634- t.Fatalf("state hook missing: %v", err)
3635- }
3636- refs, err := os.ReadFile(filepath.Join(repo, "tinyrelay.pending"))
3637- if err != nil || !strings.Contains(string(refs), "refs/heads/main "+strings.Repeat("a", 40)) {
3638- t.Fatalf("signed state was not staged: %v", err)
3639- }
3640-}
3641-
3642-func TestStateHEADSelectsNonMainBranch(t *testing.T) {
3643- root := t.TempDir()
3644- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3645- if err != nil {
3646- t.Fatal(err)
3647- }
3648- defer store.Close()
3649- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3650- if err != nil {
3651- t.Fatal(err)
3652- }
3653- secret := strings.Repeat("0", 63) + "1"
3654- owner, _ := event.PublicKey(secret)
3655- announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "feature"}}}
3656- if err := event.Sign(&announcement, secret); err != nil {
3657- t.Fatal(err)
3658- }
3659- if err := g.Publish(context.Background(), announcement); err != nil {
3660- t.Fatal(err)
3661- }
3662- state := event.Event{Kind: 30618, CreatedAt: 101, Tags: [][]string{{"d", "feature"}, {"HEAD", "ref: refs/heads/feat/atlas"}}}
3663- if err := event.Sign(&state, secret); err != nil {
3664- t.Fatal(err)
3665- }
3666- if err := g.Publish(context.Background(), state); err != nil {
3667- t.Fatal(err)
3668- }
3669- repo := filepath.Join(root, "git", owner, "feature.git")
3670- out, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()
3671- if err != nil {
3672- t.Fatal(err)
3673- }
3674- if strings.TrimSpace(string(out)) != "refs/heads/feat/atlas" {
3675- t.Fatalf("HEAD=%q", strings.TrimSpace(string(out)))
3676- }
3677-}
3678-
3679-func TestStateProjectsExistingObjectsIntoNativeRefs(t *testing.T) {
3680- root := t.TempDir()
3681- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3682- if err != nil {
3683- t.Fatal(err)
3684- }
3685- defer store.Close()
3686- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3687- if err != nil {
3688- t.Fatal(err)
3689- }
3690- secret := strings.Repeat("0", 63) + "1"
3691- owner, _ := event.PublicKey(secret)
3692- ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "existing"}}}
3693- if err := event.Sign(&ann, secret); err != nil {
3694- t.Fatal(err)
3695- }
3696- if err := g.Publish(context.Background(), ann); err != nil {
3697- t.Fatal(err)
3698- }
3699- repo := filepath.Join(root, "git", owner, "existing.git")
3700- tree, err := exec.Command("git", "--git-dir", repo, "mktree").Output()
3701- if err != nil {
3702- t.Fatal(err)
3703- }
3704- commitCmd := exec.Command("git", "--git-dir", repo, "commit-tree", strings.TrimSpace(string(tree)), "-m", "one")
3705- commitCmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")
3706- commit, err := commitCmd.Output()
3707- if err != nil {
3708- t.Fatal(err)
3709- }
3710- oid := strings.TrimSpace(string(commit))
3711- state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "existing"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", oid}}}
3712- if err := event.Sign(&state, secret); err != nil {
3713- t.Fatal(err)
3714- }
3715- if err := g.Publish(context.Background(), state); err != nil {
3716- t.Fatal(err)
3717- }
3718- if out, err := exec.Command("git", "ls-remote", repo).CombinedOutput(); err != nil || !strings.Contains(string(out), oid+"\trefs/heads/main") {
3719- t.Fatalf("ls-remote did not advertise signed state: %s (%v)", out, err)
3720- }
3721- ref, err := exec.Command("git", "--git-dir", repo, "rev-parse", "refs/heads/main").Output()
3722- if err != nil || strings.TrimSpace(string(ref)) != oid {
3723- t.Fatalf("native ref not projected: %s (%v)", ref, err)
3724- }
3725-
3726- state2 := event.Event{Kind: 30618, CreatedAt: 3, Tags: [][]string{{"d", "existing"}, {"HEAD", "ref: refs/heads/next"}, {"refs/heads/next", oid}}}
3727- if err := event.Sign(&state2, secret); err != nil {
3728- t.Fatal(err)
3729- }
3730- if err := g.Publish(context.Background(), state2); err != nil {
3731- t.Fatal(err)
3732- }
3733- if out, err := exec.Command("git", "ls-remote", repo).CombinedOutput(); err != nil || strings.Contains(string(out), "refs/heads/main") || !strings.Contains(string(out), oid+"\trefs/heads/next") {
3734- t.Fatalf("ls-remote after state replacement = %s (%v)", out, err)
3735- }
3736- refs, err := exec.Command("git", "--git-dir", repo, "for-each-ref", "--format=%(refname)", "refs/heads").Output()
3737- if err != nil {
3738- t.Fatal(err)
3739- }
3740- if got := strings.TrimSpace(string(refs)); got != "refs/heads/next" {
3741- t.Fatalf("native refs after replacement = %q", got)
3742- }
3743- head, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()
3744- if err != nil || strings.TrimSpace(string(head)) != "refs/heads/next" {
3745- t.Fatalf("HEAD after replacement = %q (%v)", strings.TrimSpace(string(head)), err)
3746- }
3747-}
3748-
3749-func TestPendingPromotionRemovesRefsDroppedByState(t *testing.T) {
3750- root := t.TempDir()
3751- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3752- if err != nil {
3753- t.Fatal(err)
3754- }
3755- defer store.Close()
3756- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3757- if err != nil {
3758- t.Fatal(err)
3759- }
3760- r := Repository{Owner: strings.Repeat("a", 64), Identifier: "pending"}
3761- if err := g.ensureRepo(r); err != nil {
3762- t.Fatal(err)
3763- }
3764- tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()
3765- if err != nil {
3766- t.Fatal(err)
3767- }
3768- objects := make([]string, 2)
3769- for i, message := range []string{"old", "new"} {
3770- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", message)
3771- cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")
3772- out, err := cmd.Output()
3773- if err != nil {
3774- t.Fatal(err)
3775- }
3776- objects[i] = strings.TrimSpace(string(out))
3777- }
3778- old := Repository{Owner: r.Owner, Identifier: r.Identifier, Refs: map[string]string{"refs/heads/old": objects[0]}}
3779- if err := g.writeState(old); err != nil {
3780- t.Fatal(err)
3781- }
3782- next := Repository{Owner: r.Owner, Identifier: r.Identifier, Refs: map[string]string{"refs/heads/new": objects[1]}}
3783- if err := g.writePendingState(next); err != nil {
3784- t.Fatal(err)
3785- }
3786- if err := os.Rename(g.pendingStatePath(next), g.statePath(next)); err != nil {
3787- t.Fatal(err)
3788- }
3789- if err := g.writeState(next); err != nil {
3790- t.Fatal(err)
3791- }
3792- refs, err := exec.Command("git", "--git-dir", g.repoPath(r), "for-each-ref", "--format=%(refname)", "refs/heads").Output()
3793- if err != nil {
3794- t.Fatal(err)
3795- }
3796- if got := strings.TrimSpace(string(refs)); got != "refs/heads/new" {
3797- t.Fatalf("native refs after pending promotion = %q", got)
3798- }
3799-}
3800-
3801-func TestStaleStateWorkerCannotRewindHEAD(t *testing.T) {
3802- root := t.TempDir()
3803- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3804- if err != nil {
3805- t.Fatal(err)
3806- }
3807- defer store.Close()
3808- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3809- if err != nil {
3810- t.Fatal(err)
3811- }
3812- secret := strings.Repeat("0", 63) + "1"
3813- owner, _ := event.PublicKey(secret)
3814- ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "rewind"}}}
3815- if err := event.Sign(&ann, secret); err != nil {
3816- t.Fatal(err)
3817- }
3818- if err := g.Publish(context.Background(), ann); err != nil {
3819- t.Fatal(err)
3820- }
3821- first := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "rewind"}, {"HEAD", "ref: refs/heads/first"}}}
3822- second := event.Event{Kind: 30618, CreatedAt: 3, Tags: [][]string{{"d", "rewind"}, {"HEAD", "ref: refs/heads/second"}}}
3823- for _, item := range []*event.Event{&first, &second} {
3824- if err := event.Sign(item, secret); err != nil {
3825- t.Fatal(err)
3826- }
3827- if _, err := store.Save(context.Background(), *item, storage.SaveOptions{Now: item.CreatedAt}); err != nil {
3828- t.Fatal(err)
3829- }
3830- }
3831- r1, err := g.parseRepository(first)
3832- if err != nil {
3833- t.Fatal(err)
3834- }
3835- r2, err := g.parseRepository(second)
3836- if err != nil {
3837- t.Fatal(err)
3838- }
3839- if err := g.CommitAfterStoreNoNotify(context.Background(), second, r2); err != nil {
3840- t.Fatal(err)
3841- }
3842- if err := g.CommitAfterStoreNoNotify(context.Background(), first, r1); err != nil {
3843- t.Fatal(err)
3844- }
3845- repo := filepath.Join(root, "git", owner, "rewind.git")
3846- head, err := exec.Command("git", "--git-dir", repo, "symbolic-ref", "HEAD").Output()
3847- if err != nil {
3848- t.Fatal(err)
3849- }
3850- if strings.TrimSpace(string(head)) != "refs/heads/second" {
3851- t.Fatalf("stale worker rewound HEAD to %q", strings.TrimSpace(string(head)))
3852- }
3853-}
3854-
3855-func TestObjectsPresentBatchesManyRefsAndDetectsMissing(t *testing.T) {
3856- root := t.TempDir()
3857- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3858- if err != nil {
3859- t.Fatal(err)
3860- }
3861- defer store.Close()
3862- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3863- if err != nil {
3864- t.Fatal(err)
3865- }
3866- r := Repository{Owner: strings.Repeat("a", 64), Identifier: "many"}
3867- if err := g.ensureRepo(r); err != nil {
3868- t.Fatal(err)
3869- }
3870- var objects []string
3871- for i := 0; i < 128; i++ {
3872- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "hash-object", "-w", "--stdin")
3873- cmd.Stdin = strings.NewReader(strings.Repeat("object", i+1))
3874- id, err := cmd.Output()
3875- if err != nil {
3876- t.Fatal(err)
3877- }
3878- objects = append(objects, strings.TrimSpace(string(id)))
3879- }
3880- if !g.objectsPresent(context.Background(), r, objects) {
3881- t.Fatal("batched object check rejected present objects")
3882- }
3883- objects = append(objects, strings.Repeat("f", 40))
3884- if g.objectsPresent(context.Background(), r, objects) {
3885- t.Fatal("batched object check accepted a missing object")
3886- }
3887-}
3888-
3889-func TestAdmitSourceRejectsUnsafeEndpoints(t *testing.T) {
3890- root := t.TempDir()
3891- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3892- if err != nil {
3893- t.Fatal(err)
3894- }
3895- defer store.Close()
3896- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), ServiceURL: "https://relay.example"})
3897- if err != nil {
3898- t.Fatal(err)
3899- }
3900- for _, raw := range []string{"http://example.com/repo.git", "https://localhost/repo.git", "https://127.0.0.1/repo.git", "https://user@example.com/repo.git", "https://relay.example/"} {
3901- if _, err := g.AdmitSource(raw); err == nil {
3902- t.Errorf("accepted unsafe source %q", raw)
3903- }
3904- }
3905- got, err := g.AdmitSource("https://git.example/repo.git/")
3906- if err != nil || got != "https://git.example/repo.git" {
3907- t.Fatalf("admitted source = %q, %v", got, err)
3908- }
3909-}
3910-
3911-func TestAlternativePRPublicationUsesSeparateRepository(t *testing.T) {
3912- root := t.TempDir()
3913- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3914- if err != nil {
3915- t.Fatal(err)
3916- }
3917- defer store.Close()
3918- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), EnableGRASP06: true})
3919- if err != nil {
3920- t.Fatal(err)
3921- }
3922- secret := strings.Repeat("0", 63) + "1"
3923- owner, err := event.PublicKey(secret)
3924- if err != nil {
3925- t.Fatal(err)
3926- }
3927- object := "pending PR object"
3928- hashCommand := exec.Command("git", "hash-object", "--stdin")
3929- hashCommand.Stdin = strings.NewReader(object)
3930- hashOutput, err := hashCommand.Output()
3931- if err != nil {
3932- t.Fatal(err)
3933- }
3934- hash := strings.TrimSpace(string(hashOutput))
3935- pr := event.Event{Kind: 1617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"c", hash}}}
3936- if err := event.Sign(&pr, secret); err != nil {
3937- t.Fatal(err)
3938- }
3939- if err := g.Publish(context.Background(), pr); err != nil {
3940- t.Fatal(err)
3941- }
3942- if _, err := os.Stat(filepath.Join(root, "git", "prs", owner, "demo.git", "HEAD")); err != nil {
3943- t.Fatal(err)
3944- }
3945- prRepo := filepath.Join(root, "git", "prs", owner, "demo.git")
3946- if _, err := os.Stat(filepath.Join(prRepo, "tinyrelay.pending")); err != nil {
3947- t.Fatalf("missing-object PR was not retained in pending state: %v", err)
3948- }
3949- if refs, err := exec.Command("git", "--git-dir", prRepo, "for-each-ref", "refs/nostr").Output(); err != nil {
3950- t.Fatal(err)
3951- } else if len(strings.TrimSpace(string(refs))) != 0 {
3952- t.Fatalf("pending PR ref became visible: %s", refs)
3953- }
3954- if err := g.Reload(context.Background()); err != nil {
3955- t.Fatal(err)
3956- }
3957- if !g.IsPending(pr.ID) {
3958- t.Fatal("pending PR was not recovered after reload")
3959- }
3960- storeObject := exec.Command("git", "--git-dir", prRepo, "hash-object", "-w", "--stdin")
3961- storeObject.Stdin = strings.NewReader(object)
3962- if out, err := storeObject.CombinedOutput(); err != nil {
3963- t.Fatalf("store PR object: %v: %s", err, out)
3964- }
3965- ref := "refs/nostr/" + pr.ID
3966- if out, err := exec.Command("git", "--git-dir", prRepo, "update-ref", ref, hash).CombinedOutput(); err != nil {
3967- t.Fatalf("install PR ref: %v: %s", err, out)
3968- }
3969- hook := filepath.Join(prRepo, "hooks", "post-receive")
3970- cmd := exec.Command("sh", hook)
3971- cmd.Dir = prRepo
3972- cmd.Env = append(os.Environ(), "GIT_DIR="+prRepo)
3973- cmd.Stdin = strings.NewReader("0 " + hash + " " + ref + "\n")
3974- if out, err := cmd.CombinedOutput(); err != nil {
3975- t.Fatalf("post-receive promotion: %v: %s", err, out)
3976- }
3977- got, err := exec.Command("git", "--git-dir", prRepo, "rev-parse", ref).Output()
3978- if err != nil || strings.TrimSpace(string(got)) != hash {
3979- t.Fatalf("promoted PR ref = %q, %v", got, err)
3980- }
3981- content, err := exec.Command("git", "--git-dir", prRepo, "cat-file", "blob", hash).Output()
3982- if err != nil || strings.TrimSpace(string(content)) != object {
3983- t.Fatalf("promoted PR object content = %q, %v", content, err)
3984- }
3985- if len(g.Capabilities()) != 2 || g.Capabilities()[1] != "GRASP-06" {
3986- t.Fatalf("capabilities = %#v", g.Capabilities())
3987- }
3988-}
3989-
3990-func TestJournalRecoveryCompletesStateFile(t *testing.T) {
3991- root := t.TempDir()
3992- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
3993- if err != nil {
3994- t.Fatal(err)
3995- }
3996- defer store.Close()
3997- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
3998- if err != nil {
3999- t.Fatal(err)
4000- }
4001- secret := strings.Repeat("0", 63) + "1"
4002- owner, err := event.PublicKey(secret)
4003- if err != nil {
4004- t.Fatal(err)
4005- }
4006- state := event.Event{Kind: 30618, CreatedAt: 1, Tags: [][]string{{"d", "demo"}, {"refs/heads/main", strings.Repeat("c", 40)}}}
4007- if err := event.Sign(&state, secret); err != nil {
4008- t.Fatal(err)
4009- }
4010- if _, err := store.Save(context.Background(), state, storage.SaveOptions{Now: 1}); err != nil {
4011- t.Fatal(err)
4012- }
4013- r := Repository{Owner: owner, Identifier: "demo", Refs: map[string]string{"refs/heads/main": strings.Repeat("c", 40)}}
4014- jr := journalRecord{Repository: key(r.Owner, r.Identifier), EventID: state.ID, Kind: 30618, Refs: r.Refs}
4015- if err := g.writeJournal(r, jr); err != nil {
4016- t.Fatal(err)
4017- }
4018- if _, err := New(Config{Store: store, Root: filepath.Join(root, "git")}); err != nil {
4019- t.Fatal(err)
4020- }
4021- b, err := os.ReadFile(filepath.Join(root, "git", r.Owner, "demo.git", "tinyrelay.pending"))
4022- if err != nil || !strings.Contains(string(b), "refs/heads/main "+strings.Repeat("c", 40)) {
4023- t.Fatalf("recovery did not restore state: %q %v", b, err)
4024- }
4025-}
4026-
4027-func TestNewRebuildsRepositoriesAfterRestart(t *testing.T) {
4028- root := t.TempDir()
4029- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4030- if err != nil {
4031- t.Fatal(err)
4032- }
4033- defer store.Close()
4034- secret := strings.Repeat("0", 63) + "1"
4035- owner, _ := event.PublicKey(secret)
4036- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
4037- if err != nil {
4038- t.Fatal(err)
4039- }
4040- announcement := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "restart"}}}
4041- if err := event.Sign(&announcement, secret); err != nil {
4042- t.Fatal(err)
4043- }
4044- if err := g.Publish(context.Background(), announcement); err != nil {
4045- t.Fatal(err)
4046- }
4047- repoPath := filepath.Join(root, "git", owner, "restart.git")
4048- blobCmd := exec.Command("git", "--git-dir", repoPath, "hash-object", "-w", "--stdin")
4049- blobCmd.Stdin = strings.NewReader("restart content\n")
4050- content, err := blobCmd.Output()
4051- if err != nil {
4052- t.Fatal(err)
4053- }
4054- treeCmd := exec.Command("git", "--git-dir", repoPath, "mktree")
4055- treeCmd.Stdin = strings.NewReader("100644 blob " + strings.TrimSpace(string(content)) + "\tREADME\n")
4056- tree, err := treeCmd.Output()
4057- if err != nil {
4058- t.Fatal(err)
4059- }
4060- commitCmd := exec.Command("git", "--git-dir", repoPath, "commit-tree", strings.TrimSpace(string(tree)), "-m", "restart")
4061- commitCmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com")
4062- commit, err := commitCmd.Output()
4063- if err != nil {
4064- t.Fatal(err)
4065- }
4066- state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "restart"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.TrimSpace(string(commit))}}}
4067- if err := event.Sign(&state, secret); err != nil {
4068- t.Fatal(err)
4069- }
4070- if err := g.Publish(context.Background(), state); err != nil {
4071- t.Fatal(err)
4072- }
4073- if out, err := exec.Command("git", "--git-dir", repoPath, "update-ref", "refs/heads/unannounced", strings.TrimSpace(string(commit))).CombinedOutput(); err != nil {
4074- t.Fatalf("stage unannounced ref: %v %s", err, out)
4075- }
4076- restarted, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
4077- if err != nil {
4078- t.Fatal(err)
4079- }
4080- r, err := restarted.lookup(owner, "restart")
4081- if err != nil {
4082- t.Fatalf("repository missing after New restart: %v", err)
4083- }
4084- if r.Refs["refs/heads/main"] != strings.TrimSpace(string(commit)) || r.Head != "ref: refs/heads/main" {
4085- t.Fatalf("signed state missing after restart: %#v", r)
4086- }
4087- if _, visible := r.Refs["refs/heads/unannounced"]; visible {
4088- t.Fatal("restart exposed a ref outside published signed authority")
4089- }
4090- page, err := restarted.Browse(context.Background(), BrowseRequest{Owner: owner, Repo: "restart", View: "file", Path: "README"})
4091- if err != nil || page.Content != "restart content\n" {
4092- t.Fatalf("browser content after restart = %q, %v", page.Content, err)
4093- }
4094- readme, err := exec.Command("git", "--git-dir", repoPath, "show", "refs/heads/main:README").Output()
4095- if err != nil || string(readme) != "restart content\n" {
4096- t.Fatalf("restarted content = %q, %v", readme, err)
4097- }
4098-}
4099-
4100-func TestGRASPServiceTickPersistsProgressAndRepairOrder(t *testing.T) {
4101- root := t.TempDir()
4102- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4103- if err != nil {
4104- t.Fatal(err)
4105- }
4106- defer store.Close()
4107- called := 0
4108- p := policy.Defaults("")
4109- p.Features.Grasp = true
4110- p.Features.Grasp02 = true
4111- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, GitSync: func(context.Context, Repository) error { called++; return nil }})
4112- if err != nil {
4113- t.Fatal(err)
4114- }
4115- r := Repository{Owner: strings.Repeat("d", 64), Identifier: "repo", Clone: []string{"https://clone.example/repo.git"}, Relays: []string{"https://relay.example"}}
4116- g.mu.Lock()
4117- g.repos[key(r.Owner, r.Identifier)] = r
4118- g.mu.Unlock()
4119- if err := g.GRASPService().Tick(context.Background()); err != nil {
4120- t.Fatal(err)
4121- }
4122- if called != 1 {
4123- t.Fatalf("Git sync callback count = %d", called)
4124- }
4125- if _, err := os.Stat(filepath.Join(root, "git", ".tinyrelay", "grasp-progress.json")); err != nil {
4126- t.Fatal(err)
4127- }
4128- sources := RepairSources(r)
4129- if len(sources) != 2 || sources[0] != r.Clone[0] || sources[1] != r.Relays[0] {
4130- t.Fatalf("repair sources = %#v", sources)
4131- }
4132-}
4133-
4134-func TestGRASPServiceTickContinuesAfterRepositoryFailure(t *testing.T) {
4135- root := t.TempDir()
4136- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4137- if err != nil {
4138- t.Fatal(err)
4139- }
4140- defer store.Close()
4141- p := policy.Defaults("")
4142- p.Features.Grasp = true
4143- p.Features.Grasp02 = true
4144- var synced []string
4145- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, GitSync: func(_ context.Context, r Repository) error {
4146- synced = append(synced, r.Identifier)
4147- if r.Identifier == "first" {
4148- return errors.New("temporary source failure")
4149- }
4150- return nil
4151- }})
4152- if err != nil {
4153- t.Fatal(err)
4154- }
4155- for _, id := range []string{"first", "second"} {
4156- r := Repository{Owner: strings.Repeat("e", 64), Identifier: id, Clone: []string{"https://clone.example/" + id}}
4157- g.mu.Lock()
4158- g.repos[key(r.Owner, r.Identifier)] = r
4159- g.mu.Unlock()
4160- }
4161- if err := g.GRASPService().Tick(context.Background()); err == nil {
4162- t.Fatal("Tick hid repository failure")
4163- }
4164- if len(synced) != 2 || synced[0] != "first" || synced[1] != "second" {
4165- t.Fatalf("sync order/calls = %#v", synced)
4166- }
4167- b, err := os.ReadFile(filepath.Join(root, "git", ".tinyrelay", "grasp-progress.json"))
4168- if err != nil {
4169- t.Fatal(err)
4170- }
4171- var progress tickProgress
4172- if err := json.Unmarshal(b, &progress); err != nil {
4173- t.Fatal(err)
4174- }
4175- if progress.Repos[key(strings.Repeat("e", 64), "first")].Error == "" || progress.Repos[key(strings.Repeat("e", 64), "second")].SucceededAt == 0 {
4176- t.Fatalf("per-repository progress = %#v", progress.Repos)
4177- }
4178- status, err := g.GRASPService().SyncStatus(context.Background())
4179- if err != nil || status.Repositories[key(strings.Repeat("e", 64), "second")].SucceededAt == 0 {
4180- t.Fatalf("SyncStatus = %#v, %v", status, err)
4181- }
4182- g.mu.Lock()
4183- changed := g.repos[key(strings.Repeat("e", 64), "first")]
4184- changed.EventID = "new-state"
4185- g.repos[key(changed.Owner, changed.Identifier)] = changed
4186- g.mu.Unlock()
4187- if err := g.GRASPService().Tick(context.Background()); err == nil {
4188- t.Fatal("changed repository state did not bypass backoff")
4189- }
4190- if len(synced) != 3 {
4191- t.Fatalf("changed state sync calls = %#v", synced)
4192- }
4193- if err := g.GRASPService().Tick(context.Background()); err != nil {
4194- t.Fatalf("second tick ignored durable backoff: %v", err)
4195- }
4196- status, err = g.GRASPService().SyncStatus(context.Background())
4197- if err != nil || status.Repositories[key(strings.Repeat("e", 64), "first")].Attempts != 2 || status.Repositories[key(strings.Repeat("e", 64), "first")].NextAt <= time.Now().Unix() {
4198- t.Fatalf("retry progress = %#v, %v", status, err)
4199- }
4200- p.Features.Grasp03 = true
4201- if err := g.GRASPService().Tick(context.Background()); err == nil {
4202- t.Fatal("policy profile change did not invalidate backoff")
4203- }
4204- if len(synced) != 5 {
4205- t.Fatalf("policy change sync calls = %#v", synced)
4206- }
4207-}
4208-
4209-func TestValidateAndCommitAfterStoreAvoidDuplicateSave(t *testing.T) {
4210- root := t.TempDir()
4211- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4212- if err != nil {
4213- t.Fatal(err)
4214- }
4215- defer store.Close()
4216- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
4217- if err != nil {
4218- t.Fatal(err)
4219- }
4220- secret := strings.Repeat("0", 63) + "1"
4221- e := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "manual"}}}
4222- if err := event.Sign(&e, secret); err != nil {
4223- t.Fatal(err)
4224- }
4225- r, err := g.Validate(context.Background(), e)
4226- if err != nil {
4227- t.Fatal(err)
4228- }
4229- if _, err := store.Save(context.Background(), e, storage.SaveOptions{Now: time.Now().Unix()}); err != nil {
4230- t.Fatal(err)
4231- }
4232- if err := g.CommitAfterStore(context.Background(), e, r); err != nil {
4233- t.Fatal(err)
4234- }
4235- q, err := store.Query(context.Background(), event.Filter{IDs: []string{e.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 10})
4236- if err != nil || len(q.Events) != 1 {
4237- t.Fatalf("stored events = %d, err=%v", len(q.Events), err)
4238- }
4239-}
4240-
4241-func TestSmartHTTPServesBareRepository(t *testing.T) {
4242- root := t.TempDir()
4243- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4244- if err != nil {
4245- t.Fatal(err)
4246- }
4247- defer store.Close()
4248- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
4249- if err != nil {
4250- t.Fatal(err)
4251- }
4252- owner := "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
4253- r := Repository{Owner: owner, Identifier: "http", Refs: map[string]string{}}
4254- g.mu.Lock()
4255- g.repos[key(owner, "http")] = r
4256- g.mu.Unlock()
4257- if err := g.ensureRepo(r); err != nil {
4258- t.Fatal(err)
4259- }
4260- if _, err := os.Stat(filepath.Join(root, "git", owner, "http.git", "HEAD")); err != nil {
4261- t.Fatalf("repo: %v", err)
4262- }
4263- // Git 2.39 does not include upload-pack capabilities in an advertisement
4264- // for an empty repository. Seed one valid commit so this exercises the
4265- // capability negotiation used by browser clients.
4266- repo := g.repoPath(r)
4267- treeCmd := exec.Command("git", "--git-dir", repo, "mktree")
4268- treeCmd.Stdin = strings.NewReader("")
4269- tree, err := treeCmd.Output()
4270- if err != nil {
4271- t.Fatalf("empty tree: %v", err)
4272- }
4273- commitCmd := exec.Command("git", "--git-dir", repo, "commit-tree", strings.TrimSpace(string(tree)), "-m", "initial state")
4274- commitCmd.Env = append(os.Environ(),
4275- "GIT_AUTHOR_NAME=tinyrelay test", "GIT_AUTHOR_EMAIL=test@example.invalid",
4276- "GIT_COMMITTER_NAME=tinyrelay test", "GIT_COMMITTER_EMAIL=test@example.invalid",
4277- )
4278- commit, err := commitCmd.Output()
4279- if err != nil {
4280- t.Fatalf("initial commit: %v", err)
4281- }
4282- update := exec.Command("git", "--git-dir", repo, "update-ref", "refs/heads/main", strings.TrimSpace(string(commit)))
4283- if out, err := update.CombinedOutput(); err != nil {
4284- t.Fatalf("initial ref: %v: %s", err, out)
4285- }
4286- req := httptest.NewRequest(http.MethodGet, "/npub/npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6/http.git/info/refs?service=git-upload-pack", nil)
4287- if got, err := decodeNPub("npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"); err != nil {
4288- t.Fatalf("known npub: %v", err)
4289- } else if got != owner {
4290- t.Fatalf("known npub owner: %s", got)
4291- }
4292- po, pi, ps, pa, pok := parsePathMode(req.URL.Path)
4293- if !pok || po != owner || pi != "http" || ps != "/info/refs" || pa {
4294- t.Fatalf("parsed path %s: %q %q %q %v %v", req.URL.Path, po, pi, ps, pa, pok)
4295- }
4296- res := httptest.NewRecorder()
4297- g.ServeHTTP(res, req)
4298- if res.Code != http.StatusOK {
4299- t.Fatalf("smart HTTP status = %d: %s", res.Code, res.Body.String())
4300- }
4301- body, _ := io.ReadAll(res.Result().Body)
4302- if len(body) == 0 {
4303- t.Fatal("smart HTTP returned an empty advertisement")
4304- }
4305- // gitworkshop.dev and other browser clients require these capabilities.
4306- for _, capability := range []string{" filter", " allow-tip-sha1-in-want", " allow-reachable-sha1-in-want", " object-format=sha1"} {
4307- if !strings.Contains(string(body), capability) {
4308- t.Errorf("advertisement lacks %q: %s", strings.TrimSpace(capability), body)
4309- }
4310- }
4311-}
4312-
4313-func TestPendingStatePromotesAfterAuthorizedGitPush(t *testing.T) {
4314- root := t.TempDir()
4315- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4316- if err != nil {
4317- t.Fatal(err)
4318- }
4319- defer store.Close()
4320- g, err := New(Config{Store: store, Root: filepath.Join(root, "git")})
4321- if err != nil {
4322- t.Fatal(err)
4323- }
4324- secret := strings.Repeat("0", 63) + "1"
4325- owner, _ := event.PublicKey(secret)
4326- ann := event.Event{Kind: 30617, CreatedAt: 1, Tags: [][]string{{"d", "push"}}}
4327- if err := event.Sign(&ann, secret); err != nil {
4328- t.Fatal(err)
4329- }
4330- if err := g.Publish(context.Background(), ann); err != nil {
4331- t.Fatal(err)
4332- }
4333- src := filepath.Join(root, "source.git")
4334- if out, err := exec.Command("git", "init", "--bare", src).CombinedOutput(); err != nil {
4335- t.Fatalf("init: %s", out)
4336- }
4337- work := filepath.Join(root, "work")
4338- if out, err := exec.Command("git", "clone", src, work).CombinedOutput(); err != nil {
4339- t.Fatalf("clone: %s", out)
4340- }
4341- for _, args := range [][]string{{"-C", work, "config", "user.email", "test@example.com"}, {"-C", work, "config", "user.name", "Test"}} {
4342- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
4343- t.Fatalf("config: %s", out)
4344- }
4345- }
4346- if err := os.WriteFile(filepath.Join(work, "README"), []byte("pending\n"), 0600); err != nil {
4347- t.Fatal(err)
4348- }
4349- if out, err := exec.Command("git", "-C", work, "add", "README").CombinedOutput(); err != nil {
4350- t.Fatalf("add: %s", out)
4351- }
4352- if out, err := exec.Command("git", "-C", work, "commit", "-m", "pending").CombinedOutput(); err != nil {
4353- t.Fatalf("commit: %s", out)
4354- }
4355- if out, err := exec.Command("git", "-C", work, "tag", "v1").CombinedOutput(); err != nil {
4356- t.Fatalf("tag: %s", out)
4357- }
4358- shaBytes, err := exec.Command("git", "-C", work, "rev-parse", "HEAD").Output()
4359- if err != nil {
4360- t.Fatal(err)
4361- }
4362- sha := strings.TrimSpace(string(shaBytes))
4363- tagBytes, err := exec.Command("git", "-C", work, "rev-parse", "refs/tags/v1").Output()
4364- if err != nil {
4365- t.Fatal(err)
4366- }
4367- tagOID := strings.TrimSpace(string(tagBytes))
4368- state := event.Event{Kind: 30618, CreatedAt: 2, Tags: [][]string{{"d", "push"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", sha}, {"refs/tags/v1", tagOID}}}
4369- if err := event.Sign(&state, secret); err != nil {
4370- t.Fatal(err)
4371- }
4372- if err := g.Publish(context.Background(), state); err != nil {
4373- t.Fatal(err)
4374- }
4375- target := filepath.Join(root, "git", owner, "push.git")
4376- if _, err := os.Stat(filepath.Join(target, "tinyrelay.pending")); err != nil {
4377- t.Fatal(err)
4378- }
4379- if out, err := exec.Command("git", "-C", work, "push", target, "HEAD:refs/heads/main", "refs/tags/v1:refs/tags/v1").CombinedOutput(); err != nil {
4380- t.Fatalf("push: %s", out)
4381- }
4382- if _, err := os.Stat(filepath.Join(target, "tinyrelay.refs")); err != nil {
4383- t.Fatalf("pending state was not promoted: %v", err)
4384- }
4385-}
4386diff --git a/internal/gitrelay/http.go b/internal/gitrelay/http.go
4387deleted file mode 100644
4388index 55a10e3..0000000
4389--- a/internal/gitrelay/http.go
4390+++ /dev/null
4391@@ -1,393 +0,0 @@
4392-package gitrelay
4393-
4394-import (
4395- "bufio"
4396- "bytes"
4397- "compress/gzip"
4398- "context"
4399- "errors"
4400- "fmt"
4401- "html"
4402- "io"
4403- "log/slog"
4404- "net/http"
4405- "net/url"
4406- "os"
4407- "os/exec"
4408- "path/filepath"
4409- "strconv"
4410- "strings"
4411- "time"
4412-)
4413-
4414-// ServeHTTP exposes Git smart HTTP at /npub/<identifier>.git and /prs/... .
4415-// The subprocess boundary keeps pack parsing and object integrity in Git.
4416-func (g *GitRelay) ServeHTTP(w http.ResponseWriter, req *http.Request) {
4417- w.Header().Set("Access-Control-Allow-Origin", "*")
4418- w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
4419- w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Git-Protocol, Authorization")
4420- if req.Method == http.MethodOptions {
4421- w.WriteHeader(http.StatusNoContent)
4422- return
4423- }
4424- owner, id, suffix, alternative, ok := parsePathMode(req.URL.Path)
4425- if !ok {
4426- http.NotFound(w, req)
4427- return
4428- }
4429- r, err := g.lookup(owner, id)
4430- if alternative {
4431- g.mu.RLock()
4432- candidate, found := g.repos[key(owner, id)]
4433- g.mu.RUnlock()
4434- if found && candidate.Alternative {
4435- r, err = candidate, nil
4436- }
4437- }
4438- if err != nil {
4439- http.NotFound(w, req)
4440- return
4441- }
4442- if r.Private {
4443- // Private authorization may replace the body with a verified disk
4444- // spool. Close that replacement as well as the server-owned body.
4445- defer func() { _ = req.Body.Close() }()
4446- if g.authorizeHTTP == nil {
4447- http.Error(w, "private repository requires authorization", http.StatusForbidden)
4448- return
4449- }
4450- if err := g.authorizeHTTP(req.Context(), req, r); err != nil {
4451- http.Error(w, err.Error(), http.StatusForbidden)
4452- return
4453- }
4454- }
4455- if err := g.ensureRepo(r); err != nil {
4456- http.Error(w, err.Error(), 500)
4457- return
4458- }
4459- if suffix == "" && (req.Method == http.MethodGet || req.Method == http.MethodHead) {
4460- w.Header().Set("Content-Type", "text/html; charset=utf-8")
4461- _, _ = io.WriteString(w, "<html><body><code>"+html.EscapeString(r.Owner+"/"+r.Identifier)+"</code></body></html>\n")
4462- return
4463- }
4464- if err := g.cgi(req, w, r, suffix); err != nil {
4465- var streamed *cgiStreamError
4466- if errors.As(err, &streamed) {
4467- slog.ErrorContext(req.Context(), "Git response stream failed", "error", err)
4468- // Close an incomplete pack, rather than mark it complete or append
4469- // an HTTP error page to Git's binary protocol.
4470- panic(http.ErrAbortHandler)
4471- }
4472- http.Error(w, err.Error(), 500)
4473- return
4474- }
4475- if req.Method == http.MethodPost {
4476- // The CGI response has already been committed. Promotion callbacks are
4477- // retried by the next scheduler pass; never corrupt a successful Git
4478- // receive-pack response with a late HTTP error.
4479- _ = g.PromotePending(req.Context(), r)
4480- }
4481-}
4482-
4483-func parsePath(path string) (owner, id, suffix string, ok bool) {
4484- owner, id, suffix, _, ok = parsePathMode(path)
4485- return
4486-}
4487-
4488-func parsePathMode(path string) (owner, id, suffix string, alternative, ok bool) {
4489- var err error
4490- parts := strings.Split(strings.Trim(path, "/"), "/")
4491- if len(parts) < 2 {
4492- return
4493- }
4494- if parts[0] == "prs" {
4495- if len(parts) < 3 {
4496- return
4497- }
4498- parts = parts[1:]
4499- alternative = true
4500- }
4501- if len(parts) > 0 && parts[0] == "npub" {
4502- parts = parts[1:]
4503- }
4504- if len(parts) < 2 {
4505- return
4506- }
4507- if !strings.HasPrefix(parts[0], "npub1") || !strings.HasSuffix(parts[1], ".git") {
4508- return
4509- }
4510- owner, err = decodeNPub(parts[0])
4511- if err != nil {
4512- return
4513- }
4514- id, err = url.PathUnescape(strings.TrimSuffix(parts[1], ".git"))
4515- if err != nil || !validIdentifier(id) {
4516- return
4517- }
4518- if len(parts) > 2 {
4519- suffix = "/" + strings.Join(parts[2:], "/")
4520- }
4521- ok = true
4522- return
4523-}
4524-
4525-const bech32Charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
4526-
4527-func decodeNPub(s string) (string, error) {
4528- s = strings.ToLower(s)
4529- if !strings.HasPrefix(s, "npub1") {
4530- return "", errors.New("not an npub")
4531- }
4532- // Bech32 uses the first separator; the payload may itself contain the
4533- // character "1".
4534- pos := strings.IndexByte(s, '1')
4535- if pos < len("npub") || len(s)-pos < 7 {
4536- return "", errors.New("invalid npub")
4537- }
4538- data := make([]byte, 0, len(s)-pos-1)
4539- for _, c := range s[pos+1:] {
4540- i := strings.IndexRune(bech32Charset, c)
4541- if i < 0 {
4542- return "", errors.New("invalid npub character")
4543- }
4544- data = append(data, byte(i))
4545- }
4546- if bech32Polymod(append(hrpExpand(s[:pos]), data...)) != 1 {
4547- return "", errors.New("invalid npub checksum")
4548- }
4549- payload, err := convertBits(data[:len(data)-6], 5, 8, false)
4550- if err != nil || len(payload) != 32 {
4551- return "", errors.New("invalid npub payload")
4552- }
4553- return fmt.Sprintf("%x", payload), nil
4554-}
4555-
4556-func hrpExpand(s string) []byte {
4557- out := make([]byte, 0, len(s)*2+1)
4558- for _, c := range s {
4559- out = append(out, byte(c>>5))
4560- }
4561- out = append(out, 0)
4562- for _, c := range s {
4563- out = append(out, byte(c&31))
4564- }
4565- return out
4566-}
4567-func bech32Polymod(v []byte) uint64 {
4568- const g0 = 0x3b6a57b2
4569- const g1 = 0x26508e6d
4570- const g2 = 0x1ea119fa
4571- const g3 = 0x3d4233dd
4572- const g4 = 0x2a1462b3
4573- chk := uint64(1)
4574- for _, x := range v {
4575- top := chk >> 25
4576- chk = (chk&0x1ffffff)<<5 | uint64(x)
4577- if top&1 != 0 {
4578- chk ^= g0
4579- }
4580- if top&2 != 0 {
4581- chk ^= g1
4582- }
4583- if top&4 != 0 {
4584- chk ^= g2
4585- }
4586- if top&8 != 0 {
4587- chk ^= g3
4588- }
4589- if top&16 != 0 {
4590- chk ^= g4
4591- }
4592- }
4593- return chk
4594-}
4595-func convertBits(data []byte, from, to uint, pad bool) ([]byte, error) {
4596- acc := uint(0)
4597- bits := uint(0)
4598- maxv := uint((1 << to) - 1)
4599- maxacc := uint((1 << (from + to - 1)) - 1)
4600- out := make([]byte, 0)
4601- for _, v := range data {
4602- if uint(v)>>from != 0 {
4603- return nil, errors.New("invalid bech32 data")
4604- }
4605- acc = (acc<<from | uint(v)) & maxacc
4606- bits += from
4607- for bits >= to {
4608- bits -= to
4609- out = append(out, byte((acc>>bits)&maxv))
4610- }
4611- }
4612- if pad {
4613- if bits > 0 {
4614- out = append(out, byte((acc<<(to-bits))&maxv))
4615- }
4616- } else if bits >= from || byte((acc<<(to-bits))&maxv) != 0 {
4617- return nil, errors.New("invalid bech32 padding")
4618- }
4619- return out, nil
4620-}
4621-
4622-func (g *GitRelay) cgi(req *http.Request, w http.ResponseWriter, r Repository, suffix string) error {
4623- prefix := ""
4624- if r.Alternative {
4625- prefix = "/prs"
4626- }
4627- pathInfo := prefix + "/" + r.Owner + "/" + r.Identifier + ".git" + suffix
4628- ctx, cancel := context.WithCancel(req.Context())
4629- defer cancel()
4630- // Browser clients such as gitworkshop.dev fetch one commit at a time with
4631- // a filter, and refuse servers that do not advertise filter and
4632- // sha1-in-want. The -c settings reach upload-pack through the environment.
4633- cmd := exec.CommandContext(ctx, "git", "-c", "uploadpack.allowFilter=true", "-c", "uploadpack.allowAnySHA1InWant=true", "http-backend")
4634- cmd.Dir = g.root
4635- cmd.WaitDelay = 5 * time.Second
4636- stdin, contentLength, cleanup, err := cgiInput(req)
4637- if err != nil {
4638- return err
4639- }
4640- defer cleanup()
4641- cmd.Stdin = stdin
4642- var stderr cgiDiagnostic
4643- cmd.Stderr = &stderr
4644- stdout, err := cmd.StdoutPipe()
4645- if err != nil {
4646- return err
4647- }
4648- defer stdout.Close()
4649- query := req.URL.RawQuery
4650- env := os.Environ()
4651- env = append(env, "GIT_PROJECT_ROOT="+g.root, "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO="+pathInfo, "PATH_TRANSLATED="+filepath.Join(g.root, strings.TrimPrefix(pathInfo, "/")), "REQUEST_METHOD="+req.Method, "QUERY_STRING="+query, "REMOTE_ADDR="+req.RemoteAddr)
4652- if value := req.Header.Get("Content-Type"); value != "" {
4653- env = append(env, "CONTENT_TYPE="+value)
4654- }
4655- if contentLength != "" {
4656- env = append(env, "CONTENT_LENGTH="+contentLength)
4657- }
4658- if value := req.Header.Get("Git-Protocol"); value != "" {
4659- env = append(env, "HTTP_GIT_PROTOCOL="+value)
4660- }
4661- cmd.Env = env
4662- if err := cmd.Start(); err != nil {
4663- return fmt.Errorf("start git http-backend: %w", err)
4664- }
4665- controller := http.NewResponseController(w)
4666- _ = controller.EnableFullDuplex()
4667- reader := bufio.NewReader(stdout)
4668- started, streamErr := streamCGI(w, controller, reader)
4669- if streamErr != nil {
4670- cancel()
4671- _ = stdout.Close()
4672- _ = req.Body.Close()
4673- }
4674- waitErr := cmd.Wait()
4675- if waitErr != nil {
4676- streamErr = errors.Join(streamErr, fmt.Errorf("git http-backend: %w: %s", waitErr, strings.TrimSpace(stderr.String())))
4677- }
4678- if streamErr != nil && started {
4679- return &cgiStreamError{streamErr}
4680- }
4681- return streamErr
4682-}
4683-
4684-// Compressed smart-HTTP negotiation needs a decoded CONTENT_LENGTH for CGI.
4685-// Spool this uncommon request form to disk so memory stays independent of size.
4686-func cgiInput(req *http.Request) (io.Reader, string, func(), error) {
4687- length := req.Header.Get("Content-Length")
4688- if !strings.EqualFold(req.Header.Get("Content-Encoding"), "gzip") {
4689- return req.Body, length, func() {}, nil
4690- }
4691- zr, err := gzip.NewReader(req.Body)
4692- if err != nil {
4693- return nil, "", nil, fmt.Errorf("decode compressed Git request: %w", err)
4694- }
4695- defer zr.Close()
4696- file, err := os.CreateTemp("", "tiny-git-request-*")
4697- if err != nil {
4698- return nil, "", nil, err
4699- }
4700- cleanup := func() { _ = file.Close(); _ = os.Remove(file.Name()) }
4701- n, err := io.Copy(file, zr)
4702- if err == nil {
4703- _, err = file.Seek(0, io.SeekStart)
4704- }
4705- if err != nil {
4706- cleanup()
4707- return nil, "", nil, fmt.Errorf("spool compressed Git request: %w", err)
4708- }
4709- return file, strconv.FormatInt(n, 10), cleanup, nil
4710-}
4711-
4712-type cgiStreamError struct{ error }
4713-
4714-// Only diagnostic text is bounded; pack streams have no product size limit.
4715-type cgiDiagnostic struct{ bytes.Buffer }
4716-
4717-func (b *cgiDiagnostic) Write(p []byte) (int, error) {
4718- n := len(p)
4719- if remaining := 64*1024 - b.Len(); remaining > 0 {
4720- _, _ = b.Buffer.Write(p[:min(remaining, n)])
4721- }
4722- return n, nil
4723-}
4724-
4725-func streamCGI(w http.ResponseWriter, controller *http.ResponseController, reader *bufio.Reader) (bool, error) {
4726- status := http.StatusOK
4727- responseHeaders := make(http.Header)
4728- for {
4729- line, err := reader.ReadString('\n')
4730- if err != nil {
4731- return false, fmt.Errorf("read Git response headers: %w", err)
4732- }
4733- line = strings.TrimRight(line, "\r\n")
4734- if line == "" {
4735- break
4736- }
4737- k, v, ok := strings.Cut(line, ":")
4738- if !ok {
4739- return false, errors.New("malformed Git CGI response header")
4740- }
4741- if strings.EqualFold(k, "Status") {
4742- if _, err := fmt.Sscanf(v, "%d", &status); err != nil || status < 200 || status > 599 {
4743- return false, errors.New("invalid Git CGI response status")
4744- }
4745- } else {
4746- responseHeaders.Add(k, strings.TrimSpace(v))
4747- }
4748- }
4749- // Hold the response until git produces its first body byte. HTTP/1.1
4750- // proxies without full duplex discard the unread request body once a
4751- // response starts, which would truncate a pack that is still uploading.
4752- // git-http-backend writes its headers before reading the request, but
4753- // writes body bytes only after it has consumed the pack.
4754- if _, err := reader.Peek(1); err != nil && !errors.Is(err, io.EOF) {
4755- return false, fmt.Errorf("read Git response body: %w", err)
4756- }
4757- for k, values := range responseHeaders {
4758- for _, value := range values {
4759- w.Header().Add(k, value)
4760- }
4761- }
4762- w.WriteHeader(status)
4763- if err := controller.Flush(); err != nil && !errors.Is(err, http.ErrNotSupported) {
4764- return true, err
4765- }
4766- _, err := io.Copy(cgiFlushWriter{w, controller}, reader)
4767- return true, err
4768-}
4769-
4770-type cgiFlushWriter struct {
4771- writer io.Writer
4772- controller *http.ResponseController
4773-}
4774-
4775-func (w cgiFlushWriter) Write(p []byte) (int, error) {
4776- n, err := w.writer.Write(p)
4777- if err == nil {
4778- err = w.controller.Flush()
4779- if errors.Is(err, http.ErrNotSupported) {
4780- err = nil
4781- }
4782- }
4783- return n, err
4784-}
4785diff --git a/internal/gitrelay/journal.go b/internal/gitrelay/journal.go
4786deleted file mode 100644
4787index 8f9f651..0000000
4788--- a/internal/gitrelay/journal.go
4789+++ /dev/null
4790@@ -1,140 +0,0 @@
4791-package gitrelay
4792-
4793-import (
4794- "context"
4795- "crypto/sha256"
4796- "database/sql"
4797- "encoding/hex"
4798- "encoding/json"
4799- "errors"
4800- "fmt"
4801- "os"
4802- "path/filepath"
4803- "strings"
4804-)
4805-
4806-type journalRecord struct {
4807- Repository string `json:"repository"`
4808- EventID string `json:"event_id"`
4809- Kind int `json:"kind"`
4810- Refs map[string]string `json:"refs,omitempty"`
4811- Head string `json:"head,omitempty"`
4812- Committed bool `json:"committed"`
4813-}
4814-
4815-func (g *GitRelay) journalDir() string { return filepath.Join(g.root, ".tinyrelay", "journal") }
4816-
4817-func (g *GitRelay) journalPath(r journalRecord) string {
4818- h := sha256.Sum256([]byte(r.Repository + "\x00" + r.EventID))
4819- return filepath.Join(g.journalDir(), hex.EncodeToString(h[:])+".json")
4820-}
4821-
4822-func (g *GitRelay) writeJournal(repo Repository, r journalRecord) error {
4823- if err := os.MkdirAll(g.journalDir(), 0700); err != nil {
4824- return err
4825- }
4826- b, err := json.Marshal(r)
4827- if err != nil {
4828- return err
4829- }
4830- tmp := g.journalPath(r) + ".tmp"
4831- if err := os.WriteFile(tmp, b, 0600); err != nil {
4832- return err
4833- }
4834- if err := syncFile(tmp); err != nil {
4835- return err
4836- }
4837- return os.Rename(tmp, g.journalPath(r))
4838-}
4839-
4840-func (g *GitRelay) commitJournal(repo Repository, r journalRecord) error {
4841- if err := os.Remove(g.journalPath(r)); err != nil && !errors.Is(err, os.ErrNotExist) {
4842- return err
4843- }
4844- return nil
4845-}
4846-
4847-func syncFile(path string) error {
4848- f, err := os.OpenFile(path, os.O_RDONLY, 0)
4849- if err != nil {
4850- return err
4851- }
4852- defer f.Close()
4853- return f.Sync()
4854-}
4855-
4856-// recoverJournals completes state publication after a process crash. A
4857-// journal is deliberately small and repository-local; the event store remains
4858-// authoritative for event durability, while the file is the Git authority.
4859-func (g *GitRelay) recoverJournals() error {
4860- entries, err := os.ReadDir(g.journalDir())
4861- if errors.Is(err, os.ErrNotExist) {
4862- return nil
4863- }
4864- if err != nil {
4865- return err
4866- }
4867- for _, entry := range entries {
4868- if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
4869- continue
4870- }
4871- b, err := os.ReadFile(filepath.Join(g.journalDir(), entry.Name()))
4872- if err != nil {
4873- return err
4874- }
4875- var jr journalRecord
4876- if err := json.Unmarshal(b, &jr); err != nil {
4877- return fmt.Errorf("git relay journal %s: %w", entry.Name(), err)
4878- }
4879- owner, id, ok := strings.Cut(jr.Repository, "\x00")
4880- if !ok || owner == "" || id == "" {
4881- return fmt.Errorf("git relay journal %s: invalid repository", entry.Name())
4882- }
4883- r := Repository{Owner: owner, Identifier: id, Refs: jr.Refs, Head: jr.Head}
4884- if err := g.ensureRepo(r); err != nil {
4885- return err
4886- }
4887- keepJournal := false
4888- if jr.Kind == 30618 {
4889- var current string
4890- err := g.store.DB().QueryRowContext(context.Background(), `SELECT events.id FROM events JOIN tags ON tags.event_id=events.id AND tags.name='d' AND tags.value=? WHERE events.pubkey=? AND events.kind=30618 ORDER BY events.created_at DESC,events.id ASC LIMIT 1`, id, owner).Scan(&current)
4891- if errors.Is(err, sql.ErrNoRows) || current != jr.EventID {
4892- if removeErr := os.Remove(filepath.Join(g.journalDir(), entry.Name())); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
4893- return removeErr
4894- }
4895- continue
4896- }
4897- if err != nil {
4898- return err
4899- }
4900- if g.stateObjectsPresent(context.Background(), r) {
4901- err = g.writeState(r)
4902- } else {
4903- err = g.writePendingState(r)
4904- keepJournal = true
4905- }
4906- if err != nil {
4907- return err
4908- }
4909- g.mu.Lock()
4910- g.pending[jr.EventID] = struct{}{}
4911- g.mu.Unlock()
4912- } else if jr.Kind == 1617 || jr.Kind == 1618 || jr.Kind == 1619 {
4913- if !g.stateObjectsPresent(context.Background(), r) {
4914- if err := g.writePendingState(r); err != nil {
4915- return err
4916- }
4917- keepJournal = true
4918- g.mu.Lock()
4919- g.pending[jr.EventID] = struct{}{}
4920- g.mu.Unlock()
4921- }
4922- }
4923- if !keepJournal {
4924- if err := os.Remove(filepath.Join(g.journalDir(), entry.Name())); err != nil && !errors.Is(err, os.ErrNotExist) {
4925- return err
4926- }
4927- }
4928- }
4929- return nil
4930-}
4931diff --git a/internal/gitrelay/maintainers_test.go b/internal/gitrelay/maintainers_test.go
4932deleted file mode 100644
4933index e5a8eef..0000000
4934--- a/internal/gitrelay/maintainers_test.go
4935+++ /dev/null
4936@@ -1,76 +0,0 @@
4937-package gitrelay
4938-
4939-import (
4940- "context"
4941- "path/filepath"
4942- "strings"
4943- "testing"
4944-
4945- "github.com/FelineStateMachine/tinyrelay/internal/event"
4946- "github.com/FelineStateMachine/tinyrelay/internal/storage"
4947-)
4948-
4949-type maintainerFunc func(context.Context, Repository, string) bool
4950-
4951-func (f maintainerFunc) IsMaintainer(ctx context.Context, r Repository, pubkey string) bool {
4952- return f(ctx, r, pubkey)
4953-}
4954-
4955-// The host's maintainer answer is consulted after the owner and the
4956-// maintainers tag, and it decides whether a state event from another key is
4957-// accepted.
4958-func TestMaintainerSourceDecidesStateAuthority(t *testing.T) {
4959- root := t.TempDir()
4960- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
4961- if err != nil {
4962- t.Fatal(err)
4963- }
4964- defer store.Close()
4965- vouched := map[string]bool{}
4966- var asked []string
4967- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Maintainers: maintainerFunc(func(_ context.Context, r Repository, pubkey string) bool {
4968- asked = append(asked, r.Identifier+":"+pubkey)
4969- return vouched[pubkey]
4970- })})
4971- if err != nil {
4972- t.Fatal(err)
4973- }
4974- ownerSecret, agentSecret := strings.Repeat("0", 63)+"1", strings.Repeat("0", 63)+"2"
4975- owner, _ := event.PublicKey(ownerSecret)
4976- agent, _ := event.PublicKey(agentSecret)
4977- announcement := event.Event{Kind: 30617, CreatedAt: 100, Tags: [][]string{{"d", "demo"}, {"clone", "https://relay.example/" + owner + "/demo.git"}}}
4978- if err := event.Sign(&announcement, ownerSecret); err != nil {
4979- t.Fatal(err)
4980- }
4981- if err := g.Publish(context.Background(), announcement); err != nil {
4982- t.Fatal(err)
4983- }
4984- state := func(createdAt int64) event.Event {
4985- e := event.Event{Kind: 30618, CreatedAt: createdAt, Tags: [][]string{{"d", "demo"}, {"HEAD", "ref: refs/heads/main"}, {"refs/heads/main", strings.Repeat("a", 40)}}}
4986- if err := event.Sign(&e, agentSecret); err != nil {
4987- t.Fatal(err)
4988- }
4989- return e
4990- }
4991- if _, err := g.Validate(context.Background(), state(101)); err == nil || !strings.Contains(err.Error(), "not an accepted repository maintainer") {
4992- t.Fatalf("state from an unvouched key: %v", err)
4993- }
4994- if len(asked) == 0 || asked[0] != "demo:"+agent {
4995- t.Fatalf("source was not consulted for the repository: %v", asked)
4996- }
4997- vouched[agent] = true
4998- repo, err := g.Validate(context.Background(), state(102))
4999- if err != nil {
5000- t.Fatalf("state from a vouched key: %v", err)
5001- }
5002- if repo.Owner != owner || repo.Refs["refs/heads/main"] != strings.Repeat("a", 40) {
5003- t.Fatalf("state bound to the wrong repository: %+v", repo)
5004- }
5005- if !g.IsMaintainer(context.Background(), repo, owner) || g.IsMaintainer(context.Background(), repo, "") {
5006- t.Fatal("owner must count and an empty key must not")
5007- }
5008- vouched[agent] = false
5009- if _, err := g.Validate(context.Background(), state(103)); err == nil {
5010- t.Fatal("a withdrawn answer must reject the next state")
5011- }
5012-}
5013diff --git a/internal/gitrelay/pr_budget_proxy.go b/internal/gitrelay/pr_budget_proxy.go
5014deleted file mode 100644
5015index f2433d4..0000000
5016--- a/internal/gitrelay/pr_budget_proxy.go
5017+++ /dev/null
5018@@ -1,191 +0,0 @@
5019-package gitrelay
5020-
5021-import (
5022- "bufio"
5023- "context"
5024- "crypto/rand"
5025- "crypto/subtle"
5026- "encoding/base64"
5027- "encoding/hex"
5028- "errors"
5029- "io"
5030- "net"
5031- "net/http"
5032- "strings"
5033- "sync"
5034- "sync/atomic"
5035-)
5036-
5037-var errRepairByteLimit = errors.New("PR repair transfer exceeds byte limit")
5038-
5039-// A single budget covers every connection, ref and source in one repair.
5040-// Counting tunneled TLS bytes also bounds HTTPS without terminating its TLS.
5041-type byteBudget struct{ used atomic.Int64 }
5042-
5043-func (b *byteBudget) take(wanted, limit int64) int64 {
5044- for {
5045- old := b.used.Load()
5046- allowed := min(wanted, max(0, limit-old))
5047- if b.used.CompareAndSwap(old, old+allowed) {
5048- return allowed
5049- }
5050- }
5051-}
5052-
5053-type repairProxy struct {
5054- listener net.Listener
5055- // credential is the Proxy-Authorization value Git presents. The listener
5056- // is loopback only, but other local processes cannot borrow the tunnel.
5057- credential string
5058- url string
5059- ctx context.Context
5060- cancel context.CancelFunc
5061- budget *byteBudget
5062- limit int64
5063- targetIP string
5064- targetHost string
5065- targetPort string
5066- mu sync.Mutex
5067- connections map[net.Conn]struct{}
5068- closeOnce sync.Once
5069-}
5070-
5071-func newRepairProxy(ctx context.Context, targetIP, targetHost, targetPort string, budget *byteBudget, limit int64) (*repairProxy, error) {
5072- listener, err := net.Listen("tcp", "127.0.0.1:0")
5073- if err != nil {
5074- return nil, err
5075- }
5076- var secret [16]byte
5077- if _, err := rand.Read(secret[:]); err != nil {
5078- _ = listener.Close()
5079- return nil, err
5080- }
5081- proxyCtx, cancel := context.WithCancel(ctx)
5082- token := hex.EncodeToString(secret[:])
5083- credential := "Basic " + base64.StdEncoding.EncodeToString([]byte("tiny:"+token))
5084- p := &repairProxy{listener: listener, credential: credential, ctx: proxyCtx, cancel: cancel, budget: budget, limit: limit, targetIP: targetIP, targetHost: targetHost, targetPort: targetPort, connections: make(map[net.Conn]struct{})}
5085- p.url = "http://tiny:" + token + "@" + listener.Addr().String()
5086- context.AfterFunc(proxyCtx, func() { _ = p.Close() })
5087- go p.serve()
5088- return p, nil
5089-}
5090-
5091-func (p *repairProxy) URL() string { return p.url }
5092-
5093-func (p *repairProxy) Close() error {
5094- p.closeOnce.Do(func() {
5095- p.cancel()
5096- _ = p.listener.Close()
5097- p.mu.Lock()
5098- defer p.mu.Unlock()
5099- for conn := range p.connections {
5100- _ = conn.Close()
5101- }
5102- })
5103- return nil
5104-}
5105-
5106-func (p *repairProxy) track(conn net.Conn) bool {
5107- p.mu.Lock()
5108- defer p.mu.Unlock()
5109- if p.ctx.Err() != nil {
5110- _ = conn.Close()
5111- return false
5112- }
5113- p.connections[conn] = struct{}{}
5114- return true
5115-}
5116-
5117-func (p *repairProxy) release(conn net.Conn) {
5118- _ = conn.Close()
5119- p.mu.Lock()
5120- delete(p.connections, conn)
5121- p.mu.Unlock()
5122-}
5123-
5124-func (p *repairProxy) serve() {
5125- for {
5126- conn, err := p.listener.Accept()
5127- if err != nil {
5128- return
5129- }
5130- if p.track(conn) {
5131- go p.handle(conn)
5132- }
5133- }
5134-}
5135-
5136-func (p *repairProxy) handle(client net.Conn) {
5137- defer p.release(client)
5138- reader := bufio.NewReader(client)
5139- request, err := http.ReadRequest(reader)
5140- if err != nil {
5141- return
5142- }
5143- if request.Method != http.MethodConnect && request.Method != http.MethodGet && request.Method != http.MethodPost {
5144- return
5145- }
5146- if subtle.ConstantTimeCompare([]byte(request.Header.Get("Proxy-Authorization")), []byte(p.credential)) != 1 {
5147- _, _ = io.WriteString(client, "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"tiny\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
5148- return
5149- }
5150- host := request.URL.Hostname()
5151- port := request.URL.Port()
5152- if port == "" {
5153- port = "80"
5154- if request.Method == http.MethodConnect || request.URL.Scheme == "https" {
5155- port = "443"
5156- }
5157- }
5158- if !strings.EqualFold(host, p.targetHost) || port != p.targetPort {
5159- return
5160- }
5161- if p.budget.used.Load() >= p.limit {
5162- return
5163- }
5164- // Dial only the already validated address. The proxy never resolves a
5165- // host supplied by the client and never follows HTTP redirects.
5166- upstream, err := (&net.Dialer{}).DialContext(p.ctx, "tcp", net.JoinHostPort(p.targetIP, p.targetPort))
5167- if err != nil {
5168- return
5169- }
5170- if !p.track(upstream) {
5171- return
5172- }
5173- defer p.release(upstream)
5174- if request.Method == http.MethodConnect {
5175- if _, err := io.WriteString(repairBudgetWriter{p, client}, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
5176- return
5177- }
5178- done := make(chan struct{}, 2)
5179- go func() { _, _ = io.Copy(repairBudgetWriter{p, client}, upstream); done <- struct{}{} }()
5180- // Keep bytes already buffered while reading CONNECT (e.g., a TLS
5181- // handshake sent in the same write as the request).
5182- go func() { _, _ = io.Copy(repairBudgetWriter{p, upstream}, reader); done <- struct{}{} }()
5183- select {
5184- case <-done:
5185- case <-p.ctx.Done():
5186- }
5187- return
5188- }
5189- request.RequestURI = ""
5190- request.Close = true
5191- if err := request.Write(repairBudgetWriter{p, upstream}); err != nil {
5192- return
5193- }
5194- _, _ = io.Copy(repairBudgetWriter{p, client}, upstream)
5195-}
5196-
5197-type repairBudgetWriter struct {
5198- proxy *repairProxy
5199- dst io.Writer
5200-}
5201-
5202-func (w repairBudgetWriter) Write(data []byte) (int, error) {
5203- allowed := w.proxy.budget.take(int64(len(data)), w.proxy.limit)
5204- n, err := w.dst.Write(data[:allowed])
5205- if err == nil && allowed < int64(len(data)) {
5206- err = errRepairByteLimit
5207- }
5208- return n, err
5209-}
5210diff --git a/internal/gitrelay/pr_budget_proxy_test.go b/internal/gitrelay/pr_budget_proxy_test.go
5211deleted file mode 100644
5212index 4643581..0000000
5213--- a/internal/gitrelay/pr_budget_proxy_test.go
5214+++ /dev/null
5215@@ -1,155 +0,0 @@
5216-package gitrelay
5217-
5218-import (
5219- "context"
5220- "crypto/tls"
5221- "io"
5222- "net/http"
5223- "net/http/httptest"
5224- "net/url"
5225- "strings"
5226- "sync/atomic"
5227- "testing"
5228- "time"
5229-)
5230-
5231-func repairProxyClient(t *testing.T, ctx context.Context, target string, budget *byteBudget, limit int64) (*repairProxy, *http.Client) {
5232- t.Helper()
5233- u, err := url.Parse(target)
5234- if err != nil {
5235- t.Fatal(err)
5236- }
5237- proxy, err := newRepairProxy(ctx, u.Hostname(), u.Hostname(), u.Port(), budget, limit)
5238- if err != nil {
5239- t.Fatal(err)
5240- }
5241- t.Cleanup(func() { _ = proxy.Close() })
5242- proxyURL, _ := url.Parse(proxy.URL())
5243- transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // Local test server's self-signed certificate.
5244- t.Cleanup(transport.CloseIdleConnections)
5245- return proxy, &http.Client{Transport: transport, Timeout: 5 * time.Second}
5246-}
5247-
5248-func TestRepairProxyEnforcesSharedResponseBudget(t *testing.T) {
5249- var requests atomic.Int32
5250- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5251- requests.Add(1)
5252- if r.URL.Path == "/small" {
5253- _, _ = io.WriteString(w, "first")
5254- return
5255- }
5256- _, _ = io.WriteString(w, strings.Repeat("x", 8192))
5257- }))
5258- defer server.Close()
5259- budget := &byteBudget{}
5260- _, client := repairProxyClient(t, context.Background(), server.URL, budget, 2048)
5261- response, err := client.Get(server.URL + "/small")
5262- if err != nil {
5263- t.Fatal(err)
5264- }
5265- body, err := io.ReadAll(response.Body)
5266- response.Body.Close()
5267- if err != nil || string(body) != "first" {
5268- t.Fatalf("first response = %q, %v", body, err)
5269- }
5270- before := budget.used.Load()
5271- if before == 0 || before >= 2048 {
5272- t.Fatalf("first request consumed %d bytes", before)
5273- }
5274- response, err = client.Get(server.URL + "/large")
5275- if err == nil {
5276- body, err = io.ReadAll(response.Body)
5277- response.Body.Close()
5278- if len(body) >= 2048 {
5279- t.Fatalf("oversized response leaked %d bytes", len(body))
5280- }
5281- }
5282- if err == nil {
5283- t.Fatal("oversized response completed")
5284- }
5285- if budget.used.Load() != 2048 {
5286- t.Fatalf("budget = %d, want 2048", budget.used.Load())
5287- }
5288- if requests.Load() != 2 {
5289- t.Fatalf("upstream requests = %d, want two", requests.Load())
5290- }
5291- if response, err = client.Get(server.URL + "/small"); err == nil {
5292- response.Body.Close()
5293- t.Fatal("request passed after shared budget exhausted")
5294- }
5295- if requests.Load() != 2 {
5296- t.Fatal("exhausted proxy contacted upstream")
5297- }
5298-}
5299-
5300-func TestRepairProxyLimitsHTTPSTunnel(t *testing.T) {
5301- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
5302- _, _ = io.WriteString(w, strings.Repeat("x", 256<<10))
5303- }))
5304- defer server.Close()
5305- budget := &byteBudget{}
5306- _, client := repairProxyClient(t, context.Background(), server.URL, budget, 64<<10)
5307- response, err := client.Get(server.URL)
5308- if err == nil {
5309- body, bodyErr := io.ReadAll(response.Body)
5310- response.Body.Close()
5311- if len(body) >= 64<<10 {
5312- t.Fatalf("HTTPS body exceeded wire budget: %d", len(body))
5313- }
5314- err = bodyErr
5315- }
5316- if err == nil {
5317- t.Fatal("oversized HTTPS body completed")
5318- }
5319- if budget.used.Load() != 64<<10 {
5320- t.Fatalf("HTTPS budget = %d", budget.used.Load())
5321- }
5322-}
5323-
5324-func TestRepairProxyCancellationInterruptsStalledUpstream(t *testing.T) {
5325- started := make(chan struct{})
5326- server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
5327- close(started)
5328- <-r.Context().Done()
5329- }))
5330- defer server.Close()
5331- ctx, cancel := context.WithCancel(context.Background())
5332- defer cancel()
5333- _, client := repairProxyClient(t, ctx, server.URL, &byteBudget{}, 4096)
5334- done := make(chan error, 1)
5335- go func() {
5336- response, err := client.Get(server.URL)
5337- if response != nil {
5338- response.Body.Close()
5339- }
5340- done <- err
5341- }()
5342- select {
5343- case <-started:
5344- case <-time.After(5 * time.Second):
5345- t.Fatal("upstream did not receive request")
5346- }
5347- cancel()
5348- select {
5349- case err := <-done:
5350- if err == nil {
5351- t.Fatal("canceled request succeeded")
5352- }
5353- case <-time.After(time.Second):
5354- t.Fatal("cancel did not interrupt stalled request")
5355- }
5356-}
5357-
5358-func TestRepairProxyRejectsAnotherPort(t *testing.T) {
5359- var contacted atomic.Bool
5360- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { contacted.Store(true) }))
5361- defer server.Close()
5362- _, client := repairProxyClient(t, context.Background(), "http://127.0.0.1:1", &byteBudget{}, 4096)
5363- if response, err := client.Get(server.URL); err == nil {
5364- response.Body.Close()
5365- t.Fatal("proxy accepted a different port")
5366- }
5367- if contacted.Load() {
5368- t.Fatal("proxy contacted a different target")
5369- }
5370-}
5371diff --git a/internal/gitrelay/pr_repair.go b/internal/gitrelay/pr_repair.go
5372deleted file mode 100644
5373index b4e049b..0000000
5374--- a/internal/gitrelay/pr_repair.go
5375+++ /dev/null
5376@@ -1,300 +0,0 @@
5377-package gitrelay
5378-
5379-import (
5380- "bufio"
5381- "context"
5382- "errors"
5383- "fmt"
5384- "os"
5385- "os/exec"
5386- "path/filepath"
5387- "strings"
5388- "time"
5389-
5390- "github.com/FelineStateMachine/tinyrelay/internal/event"
5391-)
5392-
5393-const (
5394- // PR repair is fed by clone URLs carried in signed events. Keep its
5395- // temporary object graph finite while leaving ordinary branch repair
5396- // compatible with complete repositories.
5397- prRepairDepth = 128
5398- prRepairMaxBytes = 256 << 20
5399-)
5400-
5401-// PullRequestRepair groups a PR with its later tip updates. The event store
5402-// remains the authority for these events; this value only describes the Git
5403-// objects that GRASP-02 should fetch for them.
5404-type PullRequestRepair struct {
5405- Root event.Event
5406- Updates []event.Event
5407-}
5408-
5409-// RepairPullRequestObjects fetches the commits named by a NIP-34 PR and its
5410-// updates into the supplied hosted repository.
5411-//
5412-// Updates are accepted only from the PR author and must point to the root PR.
5413-// This prevents an unrelated event from supplying a ref to a hosted PR.
5414-func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Repository, set PullRequestRepair) error {
5415- root, err := pullRequestTarget(set.Root, event.KIND_GIT_PR)
5416- if err != nil {
5417- return err
5418- }
5419- if !hasRepositoryCoordinate(set.Root, hosted) {
5420- return errors.New("blocked: pull request targets another repository")
5421- }
5422- // The hosted repository owns its path and access policy. PR events only
5423- // contribute temporary refs and clone sources.
5424- root = hosted
5425- root.Alternative = false
5426- root.EventID = set.Root.ID
5427- sources := cloneURLs(set.Root)
5428- refs := map[string]string{"refs/nostr/" + set.Root.ID: event.Tag(set.Root, "c")}
5429- for _, update := range set.Updates {
5430- if err := validatePullRequestUpdate(set.Root, update); err != nil {
5431- return err
5432- }
5433- commit := event.Tag(update, "c")
5434- if commit == "" {
5435- continue
5436- }
5437- refs["refs/nostr/"+update.ID] = commit
5438- sources = append(sources, cloneURLs(update)...)
5439- }
5440- if len(sources) == 0 {
5441- return errors.New("GRASP-02: pull request has no clone source")
5442- }
5443- sources = uniqueStrings(sources)
5444- if err := g.stagePullRequestObjects(ctx, root, sources, refs); err != nil {
5445- return fmt.Errorf("GRASP-02 pull request repair: %w", err)
5446- }
5447- return nil
5448-}
5449-
5450-// stagePullRequestObjects admits an untrusted PR clone into a quarantine bare
5451-// repository first. Only after every expected object is present do we import
5452-// the temporary refs into the hosted repository and install the signed refs.
5453-// The quarantine is removed on return, and failed repairs never alter hosted
5454-// refs. In particular, its shallow boundary is never copied to the host.
5455-func (g *GitRelay) stagePullRequestObjects(ctx context.Context, hosted Repository, sources []string, expected map[string]string) error {
5456- if err := g.ensureRepo(hosted); err != nil {
5457- return err
5458- }
5459- // Keep the quarantine outside the hosted repository namespace. A clone URL
5460- // is signed input and can be processed concurrently with another repair.
5461- quarantineRoot, err := os.MkdirTemp("", "tinyrelay-pr-repair-")
5462- if err != nil {
5463- return fmt.Errorf("create PR repair quarantine: %w", err)
5464- }
5465- defer os.RemoveAll(quarantineRoot)
5466- stage := Repository{Owner: hosted.Owner, Identifier: "objects", Private: hosted.Private}
5467- g.mu.RLock()
5468- stageRelay := &GitRelay{
5469- root: quarantineRoot, policy: g.policy, serviceURL: g.serviceURL,
5470- allowPrivate: g.allowPrivate, privatePeers: append([]string(nil), g.privatePeers...),
5471- httpAuth: g.httpAuth, configured: make(map[string]struct{}),
5472- }
5473- g.mu.RUnlock()
5474- if err := stageRelay.ensureRepo(stage); err != nil {
5475- return fmt.Errorf("create PR repair quarantine: %w", err)
5476- }
5477- stagePath := stageRelay.repoPath(stage)
5478- // Let a PR build on objects already admitted to the hosted repository, but
5479- // never copy the quarantine's shallow boundary into that repository.
5480- alternates := filepath.Join(stagePath, "objects", "info", "alternates")
5481- hostObjects, err := filepath.Abs(filepath.Join(g.repoPath(hosted), "objects"))
5482- if err != nil {
5483- return fmt.Errorf("locate hosted PR base objects: %w", err)
5484- }
5485- if err := os.WriteFile(alternates, []byte(hostObjects+"\n"), 0600); err != nil {
5486- return fmt.Errorf("configure PR repair quarantine: %w", err)
5487- }
5488- if err := stageRelay.fetchMissing(ctx, stage, sources, expected, prRepairDepth, prRepairMaxBytes); err != nil {
5489- return err
5490- }
5491- if err := validatePRQuarantine(ctx, stagePath, nonEmptyObjectIDs(expected)); err != nil {
5492- return err
5493- }
5494-
5495- // Import into disposable refs. If a later refspec fails, clean them up and
5496- // leave the signed hosted refs untouched.
5497- cleanup := make([]string, 0, len(expected))
5498- defer func() {
5499- if len(cleanup) == 0 {
5500- return
5501- }
5502- var commands strings.Builder
5503- for _, ref := range cleanup {
5504- fmt.Fprintf(&commands, "delete %s\n", ref)
5505- }
5506- cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
5507- defer cancel()
5508- cmd := exec.CommandContext(cleanupCtx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
5509- cmd.Stdin = strings.NewReader(commands.String())
5510- _ = cmd.Run()
5511- }()
5512- for ref, oid := range expected {
5513- if oid == "" {
5514- continue
5515- }
5516- staged := "refs/tinyrelay/pr-repair/" + filepath.Base(quarantineRoot) + "/" + safeRef(ref)
5517- cleanup = append(cleanup, staged)
5518- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "fetch", "--no-tags", stagePath, "+"+oid+":"+staged)
5519- cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
5520- if out, err := cmd.CombinedOutput(); err != nil {
5521- return fmt.Errorf("import PR repair object %s: %w: %s", oid, err, strings.TrimSpace(string(out)))
5522- }
5523- }
5524- updates := make([]string, 0, len(expected))
5525- for ref, oid := range expected {
5526- if oid != "" {
5527- updates = append(updates, "update "+ref+" "+oid+"\n")
5528- }
5529- }
5530- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
5531- cmd.Stdin = strings.NewReader("start\n" + strings.Join(updates, "") + "prepare\ncommit\n")
5532- if out, err := cmd.CombinedOutput(); err != nil {
5533- return fmt.Errorf("promote PR repair refs: %w: %s", err, strings.TrimSpace(string(out)))
5534- }
5535- return nil
5536-}
5537-
5538-// validatePRQuarantine checks the complete object graph after removing the
5539-// shallow marker. rev-list reports missing commits, trees and blobs through
5540-// its --missing output; alternates make hosted base objects visible here.
5541-func validatePRQuarantine(ctx context.Context, gitDir string, oids []string) error {
5542- if len(oids) == 0 {
5543- return errors.New("PR repair has no expected objects")
5544- }
5545- if err := os.Remove(filepath.Join(gitDir, "shallow")); err != nil && !errors.Is(err, os.ErrNotExist) {
5546- return fmt.Errorf("remove PR repair shallow boundary: %w", err)
5547- }
5548- args := append([]string{"--git-dir", gitDir, "rev-list", "--objects", "--missing=print"}, oids...)
5549- cmd := exec.CommandContext(ctx, "git", args...)
5550- stdout, err := cmd.StdoutPipe()
5551- if err != nil {
5552- return fmt.Errorf("validate PR repair object graph: create pipe: %w", err)
5553- }
5554- if err := cmd.Start(); err != nil {
5555- return fmt.Errorf("validate PR repair object graph: %w", err)
5556- }
5557- scanner := bufio.NewScanner(stdout)
5558- scanner.Buffer(make([]byte, 4096), 1<<20)
5559- for scanner.Scan() {
5560- line := scanner.Text()
5561- if strings.HasPrefix(line, "?") {
5562- _ = cmd.Process.Kill()
5563- _ = cmd.Wait()
5564- return fmt.Errorf("PR repair object graph is incomplete: %s", strings.TrimSpace(line))
5565- }
5566- }
5567- if err := scanner.Err(); err != nil {
5568- _ = cmd.Process.Kill()
5569- _ = cmd.Wait()
5570- return fmt.Errorf("validate PR repair object graph: read output: %w", err)
5571- }
5572- if err := cmd.Wait(); err != nil {
5573- return fmt.Errorf("validate PR repair object graph: %w", err)
5574- }
5575- // Ensure each advertised tip is an actual commit, rather than accepting a
5576- // tree or blob object under a commit-shaped event tag.
5577- for _, oid := range oids {
5578- check := exec.CommandContext(ctx, "git", "--git-dir", gitDir, "cat-file", "-t", oid)
5579- kind, err := check.Output()
5580- if err != nil || strings.TrimSpace(string(kind)) != "commit" {
5581- return fmt.Errorf("PR repair tip %s is not a complete commit", oid)
5582- }
5583- }
5584- return nil
5585-}
5586-
5587-func nonEmptyObjectIDs(refs map[string]string) []string {
5588- ids := make([]string, 0, len(refs))
5589- for _, oid := range refs {
5590- if oid != "" {
5591- ids = append(ids, oid)
5592- }
5593- }
5594- return ids
5595-}
5596-
5597-func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {
5598- want := "30617:" + repo.Owner + ":" + repo.Identifier
5599- for _, tag := range pr.Tags {
5600- if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == want {
5601- return true
5602- }
5603- }
5604- return false
5605-}
5606-
5607-func cloneURLs(e event.Event) []string {
5608- var out []string
5609- for _, tag := range e.Tags {
5610- if len(tag) > 1 && tag[0] == "clone" {
5611- out = append(out, tag[1:]...)
5612- }
5613- }
5614- return out
5615-}
5616-
5617-func pullRequestTarget(raw event.Event, kind int) (Repository, error) {
5618- if err := event.Validate(raw); err != nil {
5619- return Repository{}, err
5620- }
5621- if raw.Kind != kind {
5622- return Repository{}, fmt.Errorf("unsupported: event kind %d", raw.Kind)
5623- }
5624- commit := event.Tag(raw, "c")
5625- if !isObjectID(commit) {
5626- return Repository{}, errors.New("invalid: pull request commit is not a SHA-1")
5627- }
5628- return Repository{
5629- Owner: raw.PubKey,
5630- Identifier: raw.ID,
5631- EventID: raw.ID,
5632- Alternative: true,
5633- Clone: cloneURLs(raw),
5634- Refs: map[string]string{"refs/nostr/" + raw.ID: commit},
5635- }, nil
5636-}
5637-
5638-func validatePullRequestUpdate(root, update event.Event) error {
5639- if err := event.Validate(update); err != nil {
5640- return err
5641- }
5642- if update.Kind != event.KIND_GIT_PR_UPDATE {
5643- return fmt.Errorf("unsupported: event kind %d is not a pull request update", update.Kind)
5644- }
5645- parent := event.Tag(update, "E")
5646- if parent == "" {
5647- parent = event.Tag(update, "e")
5648- }
5649- if parent != root.ID {
5650- return errors.New("blocked: pull request update does not reference its root")
5651- }
5652- declaredAuthor := event.Tag(update, "P")
5653- if declaredAuthor != root.PubKey {
5654- return errors.New("blocked: pull request update author tag does not match root")
5655- }
5656- if update.PubKey != root.PubKey {
5657- return errors.New("blocked: pull request update signer is not the PR author")
5658- }
5659- if !isObjectID(event.Tag(update, "c")) {
5660- return errors.New("invalid: pull request update commit is not a SHA-1")
5661- }
5662- return nil
5663-}
5664-
5665-func uniqueStrings(values []string) []string {
5666- seen := make(map[string]struct{}, len(values))
5667- out := make([]string, 0, len(values))
5668- for _, value := range values {
5669- if _, ok := seen[value]; ok {
5670- continue
5671- }
5672- seen[value] = struct{}{}
5673- out = append(out, value)
5674- }
5675- return out
5676-}
5677diff --git a/internal/gitrelay/pr_repair_test.go b/internal/gitrelay/pr_repair_test.go
5678deleted file mode 100644
5679index 65982b4..0000000
5680--- a/internal/gitrelay/pr_repair_test.go
5681+++ /dev/null
5682@@ -1,376 +0,0 @@
5683-package gitrelay
5684-
5685-import (
5686- "context"
5687- "crypto/x509"
5688- "encoding/pem"
5689- "fmt"
5690- "net/http"
5691- "net/http/httptest"
5692- "os"
5693- "os/exec"
5694- "path/filepath"
5695- "strings"
5696- "testing"
5697-
5698- "github.com/FelineStateMachine/tinyrelay/internal/event"
5699- "github.com/FelineStateMachine/tinyrelay/internal/policy"
5700- "github.com/FelineStateMachine/tinyrelay/internal/storage"
5701-)
5702-
5703-func TestPullRequestTargetUsesEventIDForStandardNIP34(t *testing.T) {
5704- secret := strings.Repeat("0", 63) + "1"
5705- root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "change", Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}
5706- if err := event.Sign(&root, secret); err != nil {
5707- t.Fatal(err)
5708- }
5709- r, err := pullRequestTarget(root, event.KIND_GIT_PR)
5710- if err != nil {
5711- t.Fatal(err)
5712- }
5713- if r.Identifier != root.ID || r.Refs["refs/nostr/"+root.ID] != strings.Repeat("a", 40) {
5714- t.Fatalf("standard PR target = %#v", r)
5715- }
5716-}
5717-
5718-func TestPullRequestUpdateRequiresRootAuthor(t *testing.T) {
5719- rootSecret := strings.Repeat("0", 63) + "1"
5720- otherSecret := strings.Repeat("0", 63) + "2"
5721- root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}, {"clone", "https://git.example/repo.git"}}}
5722- if err := event.Sign(&root, rootSecret); err != nil {
5723- t.Fatal(err)
5724- }
5725- update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", root.PubKey}, {"c", strings.Repeat("b", 40)}, {"clone", "https://git.example/repo.git"}}}
5726- if err := event.Sign(&update, otherSecret); err != nil {
5727- t.Fatal(err)
5728- }
5729- if err := validatePullRequestUpdate(root, update); err == nil {
5730- t.Fatal("accepted update signed by another author")
5731- }
5732-}
5733-
5734-func TestPullRequestRepairRejectsMissingCloneBeforeGit(t *testing.T) {
5735- secret := strings.Repeat("0", 63) + "1"
5736- root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:owner:repo"}, {"c", strings.Repeat("a", 40)}}}
5737- owner, err := event.PublicKey(secret)
5738- if err != nil {
5739- t.Fatal(err)
5740- }
5741- root.Tags[0][1] = "30617:" + owner + ":repo"
5742- if err := event.Sign(&root, secret); err != nil {
5743- t.Fatal(err)
5744- }
5745- g := &GitRelay{}
5746- hosted := Repository{Owner: root.PubKey, Identifier: "repo", Refs: map[string]string{}}
5747- err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})
5748- if err == nil || !strings.Contains(err.Error(), "no clone source") {
5749- t.Fatalf("missing clone error = %v", err)
5750- }
5751-}
5752-
5753-func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {
5754- rootDir := t.TempDir()
5755- remote := filepath.Join(rootDir, "remote", "repo.git")
5756- work := filepath.Join(rootDir, "work")
5757- prGitRun(t, "init", "--bare", remote)
5758- prGitRun(t, "init", work)
5759- prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")
5760- prGitRun(t, "-C", work, "config", "user.name", "PR")
5761- if err := os.WriteFile(filepath.Join(work, "README"), []byte("one\n"), 0600); err != nil {
5762- t.Fatal(err)
5763- }
5764- prGitRun(t, "-C", work, "add", "README")
5765- prGitRun(t, "-C", work, "commit", "-m", "one")
5766- first := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))
5767- prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")
5768- if err := os.WriteFile(filepath.Join(work, "NEXT"), []byte("two\n"), 0600); err != nil {
5769- t.Fatal(err)
5770- }
5771- prGitRun(t, "-C", work, "add", "NEXT")
5772- prGitRun(t, "-C", work, "commit", "-m", "two")
5773- second := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))
5774- prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/pr")
5775-
5776- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, filepath.Dir(remote)) }))
5777- t.Cleanup(server.Close)
5778- cert, err := x509.ParseCertificate(server.Certificate().Raw)
5779- if err != nil {
5780- t.Fatal(err)
5781- }
5782- ca := filepath.Join(rootDir, "ca.pem")
5783- if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {
5784- t.Fatal(err)
5785- }
5786- t.Setenv("GIT_SSL_CAINFO", ca)
5787- store, err := storage.Open(context.Background(), filepath.Join(rootDir, "relay.db"))
5788- if err != nil {
5789- t.Fatal(err)
5790- }
5791- t.Cleanup(func() { _ = store.Close() })
5792- p := policy.Defaults("")
5793- g, err := New(Config{Store: store, Root: filepath.Join(rootDir, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
5794- if err != nil {
5795- t.Fatal(err)
5796- }
5797- secret := strings.Repeat("0", 63) + "1"
5798- owner, err := event.PublicKey(secret)
5799- if err != nil {
5800- t.Fatal(err)
5801- }
5802- clone := server.URL + "/repo.git"
5803- root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Content: "PR", Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", first}, {"clone", clone}}}
5804- if err := event.Sign(&root, secret); err != nil {
5805- t.Fatal(err)
5806- }
5807- update := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 2, Tags: [][]string{{"E", root.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}
5808- if err := event.Sign(&update, secret); err != nil {
5809- t.Fatal(err)
5810- }
5811- hosted := Repository{Owner: owner, Identifier: "repo", Clone: []string{clone}, Refs: map[string]string{"refs/heads/main": first}}
5812- if err := g.FetchMissing(context.Background(), hosted, hosted.Clone, hosted.Refs); err != nil {
5813- t.Fatal(err)
5814- }
5815- if g.objectsPresent(context.Background(), hosted, []string{second}) {
5816- t.Fatal("PR update was already present before repair; test must exercise a real fetch")
5817- }
5818- if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root, Updates: []event.Event{update}}); err != nil {
5819- t.Fatal(err)
5820- }
5821- gitDir := filepath.Join(rootDir, "git", owner, "repo.git")
5822- for ref, want := range map[string]string{"refs/heads/main": first, "refs/nostr/" + root.ID: first, "refs/nostr/" + update.ID: second} {
5823- got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", ref))
5824- if got != want {
5825- t.Fatalf("%s = %s, want %s", ref, got, want)
5826- }
5827- }
5828- if _, err := os.Stat(filepath.Join(gitDir, "tinyrelay.refs")); !os.IsNotExist(err) {
5829- t.Fatalf("PR repair wrote signed state file: %v", err)
5830- }
5831- quarantines, err := filepath.Glob(filepath.Join(rootDir, "git", "prs", owner, "repo.repair-"+root.ID+"-*.git"))
5832- if err != nil {
5833- t.Fatal(err)
5834- }
5835- if len(quarantines) != 0 {
5836- t.Fatalf("PR repair quarantine was not removed: %v", quarantines)
5837- }
5838- blockedRoot := root
5839- blockedRoot.CreatedAt = 3
5840- if err := event.Sign(&blockedRoot, secret); err != nil {
5841- t.Fatal(err)
5842- }
5843- blockedUpdate := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 4, Tags: [][]string{{"E", blockedRoot.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}
5844- if err := event.Sign(&blockedUpdate, secret); err != nil {
5845- t.Fatal(err)
5846- }
5847- // A ref-directory collision makes one update fail. The other signed ref
5848- // must not appear, regardless of map iteration or transaction order.
5849- blocker := "refs/nostr/" + blockedRoot.ID + "/child"
5850- prGitRun(t, "--git-dir", gitDir, "update-ref", blocker, first)
5851- if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: blockedRoot, Updates: []event.Event{blockedUpdate}}); err == nil {
5852- t.Fatal("repair ignored conflicting ref")
5853- }
5854- if err := exec.Command("git", "--git-dir", gitDir, "show-ref", "--verify", "--quiet", "refs/nostr/"+blockedUpdate.ID).Run(); err == nil {
5855- t.Fatal("failed repair partially promoted its signed refs")
5856- }
5857- if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", blocker)); got != first {
5858- t.Fatalf("repair changed blocking ref: %s", got)
5859- }
5860- if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/tinyrelay/pr-repair/"); strings.TrimSpace(out) != "" {
5861- t.Fatalf("failed repair left temporary refs: %s", out)
5862- }
5863-}
5864-
5865-func TestPullRequestRepairFailureLeavesHostedRefsUnchanged(t *testing.T) {
5866- secret := strings.Repeat("0", 63) + "1"
5867- owner, err := event.PublicKey(secret)
5868- if err != nil {
5869- t.Fatal(err)
5870- }
5871- rootDir := t.TempDir()
5872- hosted := Repository{Owner: owner, Identifier: "repo", Refs: map[string]string{
5873- "refs/heads/main": strings.Repeat("a", 40),
5874- }}
5875- g := &GitRelay{root: rootDir, allowPrivate: true, configured: make(map[string]struct{})}
5876- root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, PubKey: owner,
5877- Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", strings.Repeat("b", 40)}, {"clone", "http://127.0.0.1:1/repo.git"}}}
5878- if err := event.Sign(&root, secret); err != nil {
5879- t.Fatal(err)
5880- }
5881- err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})
5882- if err == nil {
5883- t.Fatal("repair unexpectedly succeeded from unavailable source")
5884- }
5885- if _, err := os.Stat(g.repoPath(hosted)); !os.IsNotExist(err) {
5886- // ensureRepo is allowed to create the host, but no signed ref may be
5887- // installed after a failed quarantine admission.
5888- got, _ := exec.Command("git", "--git-dir", g.repoPath(hosted), "show-ref", "refs/nostr/"+root.ID).CombinedOutput()
5889- if len(got) != 0 {
5890- t.Fatalf("failed repair installed hosted PR ref: %s", got)
5891- }
5892- }
5893-}
5894-
5895-func TestPRQuarantineRejectsHistoryBeyondDepth(t *testing.T) {
5896- root := t.TempDir()
5897- work := filepath.Join(root, "work")
5898- remote := filepath.Join(root, "remote.git")
5899- prGitRun(t, "init", work)
5900- prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")
5901- prGitRun(t, "-C", work, "config", "user.name", "PR")
5902- for i := 0; i < prRepairDepth+1; i++ {
5903- if err := os.WriteFile(filepath.Join(work, "history"), []byte(fmt.Sprintf("%d\n", i)), 0600); err != nil {
5904- t.Fatal(err)
5905- }
5906- prGitRun(t, "-C", work, "add", "history")
5907- prGitRun(t, "-C", work, "commit", "-m", fmt.Sprintf("commit %d", i))
5908- }
5909- tip := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))
5910- base := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD~1"))
5911- prGitRun(t, "init", "--bare", remote)
5912- prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")
5913- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, root) }))
5914- t.Cleanup(server.Close)
5915- ca := filepath.Join(root, "ca.pem")
5916- if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
5917- t.Fatal(err)
5918- }
5919- t.Setenv("GIT_SSL_CAINFO", ca)
5920- secret := strings.Repeat("0", 63) + "1"
5921- owner, err := event.PublicKey(secret)
5922- if err != nil {
5923- t.Fatal(err)
5924- }
5925- hosted := Repository{Owner: owner, Identifier: "repo"}
5926- g := &GitRelay{root: filepath.Join(root, "hosted"), allowPrivate: true, configured: make(map[string]struct{})}
5927- pr := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", tip}, {"clone", server.URL + "/remote.git"}}}
5928- if err := event.Sign(&pr, secret); err != nil {
5929- t.Fatal(err)
5930- }
5931- if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err == nil || !strings.Contains(err.Error(), "object graph") {
5932- t.Fatalf("expected incomplete object graph rejection, got %v", err)
5933- }
5934- if g.objectsPresent(context.Background(), hosted, []string{tip}) {
5935- t.Fatal("rejected objects reached the hosted repository")
5936- }
5937- gitDir := g.repoPath(hosted)
5938- if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {
5939- t.Fatalf("hosted shallow boundary changed: %v", err)
5940- }
5941- if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/nostr/"); strings.TrimSpace(out) != "" {
5942- t.Fatalf("rejected PR installed refs: %s", out)
5943- }
5944- // Once the existing base is hosted, the exact same PR must succeed even
5945- // though its complete ancestry extends beyond the remote depth window.
5946- prGitRun(t, "--git-dir", gitDir, "fetch", work, base+":refs/heads/main")
5947- if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err != nil {
5948- t.Fatalf("PR with hosted base failed: %v", err)
5949- }
5950- if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", "refs/nostr/"+pr.ID)); got != tip {
5951- t.Fatalf("PR tip = %s, want %s", got, tip)
5952- }
5953- prGitRun(t, "--git-dir", gitDir, "fsck", "--full", "--no-dangling")
5954- if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {
5955- t.Fatalf("successful repair made host shallow: %v", err)
5956- }
5957-}
5958-
5959-func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {
5960- var requests int
5961- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
5962- requests++
5963- w.WriteHeader(http.StatusInternalServerError)
5964- }))
5965- t.Cleanup(server.Close)
5966- root := t.TempDir()
5967- cert, err := x509.ParseCertificate(server.Certificate().Raw)
5968- if err != nil {
5969- t.Fatal(err)
5970- }
5971- ca := filepath.Join(root, "ca.pem")
5972- if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {
5973- t.Fatal(err)
5974- }
5975- t.Setenv("GIT_SSL_CAINFO", ca)
5976- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
5977- if err != nil {
5978- t.Fatal(err)
5979- }
5980- t.Cleanup(func() { _ = store.Close() })
5981- p := policy.Defaults("")
5982- p.Reads = "members"
5983- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
5984- if err != nil {
5985- t.Fatal(err)
5986- }
5987- repo := Repository{Owner: strings.Repeat("a", 64), Identifier: "private", Clone: []string{server.URL + "/repo.git"}, Refs: map[string]string{"refs/heads/main": strings.Repeat("b", 40)}}
5988- err = g.FetchMissing(context.Background(), repo, repo.Clone, repo.Refs)
5989- if err == nil {
5990- t.Fatal("private-policy repair unexpectedly succeeded")
5991- }
5992- if requests != 0 {
5993- t.Fatalf("private-policy repair contacted unconfigured source %d time(s)", requests)
5994- }
5995-}
5996-
5997-func TestGRASPTickResumesAfterCanceledRepository(t *testing.T) {
5998- p := policy.Policy{Features: policy.Features{Grasp: true, Grasp02: true}}
5999- owner := strings.Repeat("a", 64)
6000- first := Repository{Owner: owner, Identifier: "a", Refs: map[string]string{}}
6001- second := Repository{Owner: owner, Identifier: "b", Refs: map[string]string{}}
6002- var calls []string
6003- var cancel context.CancelFunc
6004- g := &GitRelay{
6005- root: t.TempDir(),
6006- policy: func() policy.Policy { return p },
6007- repos: map[string]Repository{key(first.Owner, first.Identifier): first, key(second.Owner, second.Identifier): second},
6008- gitSync: func(ctx context.Context, repo Repository) error {
6009- calls = append(calls, repo.Identifier)
6010- if len(calls) == 1 {
6011- cancel()
6012- return ctx.Err()
6013- }
6014- return nil
6015- },
6016- }
6017- ctx, done := context.WithCancel(context.Background())
6018- cancel = done
6019- if err := g.GRASPService().Tick(ctx); err == nil {
6020- t.Fatal("canceled GRASP tick unexpectedly succeeded")
6021- }
6022- status, err := g.GRASPService().SyncStatus(context.Background())
6023- if err != nil {
6024- t.Fatal(err)
6025- }
6026- if _, ok := status.Repositories[key(second.Owner, second.Identifier)]; ok {
6027- t.Fatalf("unattempted repository received a status: %#v", status.Repositories)
6028- }
6029- if err := g.GRASPService().Tick(context.Background()); err != nil {
6030- t.Fatal(err)
6031- }
6032- status, err = g.GRASPService().SyncStatus(context.Background())
6033- if err != nil {
6034- t.Fatal(err)
6035- }
6036- if status.Repositories[key(second.Owner, second.Identifier)].SucceededAt == 0 {
6037- t.Fatalf("resumed repository was not attempted: %#v", status.Repositories)
6038- }
6039- if len(calls) != 2 || calls[1] != "b" {
6040- t.Fatalf("resume order = %v, want [a b]", calls)
6041- }
6042-}
6043-
6044-func prGitRun(t *testing.T, args ...string) {
6045- t.Helper()
6046- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
6047- t.Fatalf("git %v: %v: %s", args, err, out)
6048- }
6049-}
6050-
6051-func prGitOutput(t *testing.T, args ...string) string {
6052- t.Helper()
6053- out, err := exec.Command("git", args...).Output()
6054- if err != nil {
6055- t.Fatal(err)
6056- }
6057- return string(out)
6058-}
6059diff --git a/internal/gitrelay/private_repair_acceptance_test.go b/internal/gitrelay/private_repair_acceptance_test.go
6060deleted file mode 100644
6061index 15be204..0000000
6062--- a/internal/gitrelay/private_repair_acceptance_test.go
6063+++ /dev/null
6064@@ -1,122 +0,0 @@
6065-package gitrelay
6066-
6067-import (
6068- "context"
6069- "crypto/x509"
6070- "encoding/pem"
6071- "io"
6072- "net/http"
6073- "net/http/httptest"
6074- "os"
6075- "os/exec"
6076- "path/filepath"
6077- "strconv"
6078- "strings"
6079- "sync/atomic"
6080- "testing"
6081-
6082- "github.com/FelineStateMachine/tinyrelay/internal/policy"
6083- "github.com/FelineStateMachine/tinyrelay/internal/storage"
6084-)
6085-
6086-func TestFetchMissingHTTPSRepairAndNoop(t *testing.T) {
6087- root := t.TempDir()
6088- remoteRoot := filepath.Join(root, "remote")
6089- work := filepath.Join(root, "work")
6090- gitRepairRun(t, "init", "--bare", filepath.Join(remoteRoot, "repo.git"))
6091- gitRepairRun(t, "init", work)
6092- gitRepairRun(t, "-C", work, "config", "user.email", "repair@example.test")
6093- gitRepairRun(t, "-C", work, "config", "user.name", "Repair")
6094- if err := os.WriteFile(filepath.Join(work, "README"), []byte("repair\n"), 0600); err != nil {
6095- t.Fatal(err)
6096- }
6097- gitRepairRun(t, "-C", work, "add", "README")
6098- gitRepairRun(t, "-C", work, "commit", "-m", "repair")
6099- commit := strings.TrimSpace(gitRepairOutput(t, "-C", work, "rev-parse", "HEAD"))
6100- gitRepairRun(t, "-C", work, "push", filepath.Join(remoteRoot, "repo.git"), "HEAD:refs/heads/main")
6101-
6102- var requests atomic.Int32
6103- server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6104- requests.Add(1)
6105- serveGitBackend(t, w, r, remoteRoot)
6106- }))
6107- t.Cleanup(server.Close)
6108- caFile := filepath.Join(root, "ca.pem")
6109- cert, err := x509.ParseCertificate(server.Certificate().Raw)
6110- if err != nil {
6111- t.Fatal(err)
6112- }
6113- if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}), 0600); err != nil {
6114- t.Fatal(err)
6115- }
6116- t.Setenv("GIT_SSL_CAINFO", caFile)
6117-
6118- store, err := storage.Open(context.Background(), filepath.Join(root, "relay.db"))
6119- if err != nil {
6120- t.Fatal(err)
6121- }
6122- t.Cleanup(func() { _ = store.Close() })
6123- p := policy.Defaults("")
6124- g, err := New(Config{Store: store, Root: filepath.Join(root, "git"), Policy: func() policy.Policy { return p }, AllowPrivateRelays: true})
6125- if err != nil {
6126- t.Fatal(err)
6127- }
6128- repo := Repository{Owner: strings.Repeat("a", 64), Identifier: "repo", Clone: []string{server.URL + "/repo.git"}, Refs: map[string]string{"refs/heads/main": commit}}
6129- if err := g.FetchMissing(context.Background(), repo, repo.Clone, repo.Refs); err != nil {
6130- t.Fatal(err)
6131- }
6132- if requests.Load() == 0 || !gitRepairObject(t, filepath.Join(root, "git", repo.Owner, "repo.git"), commit) {
6133- t.Fatalf("repair did not fetch commit: requests=%d", requests.Load())
6134- }
6135- firstRequests := requests.Load()
6136- if err := g.FetchMissing(context.Background(), repo, repo.Clone, repo.Refs); err != nil {
6137- t.Fatal(err)
6138- }
6139- if requests.Load() != firstRequests {
6140- t.Fatalf("noop repair made network requests: before=%d after=%d", firstRequests, requests.Load())
6141- }
6142-}
6143-
6144-func serveGitBackend(t *testing.T, w http.ResponseWriter, r *http.Request, root string) {
6145- t.Helper()
6146- env := append(os.Environ(), "GIT_PROJECT_ROOT="+root, "GIT_HTTP_EXPORT_ALL=1", "PATH_INFO="+r.URL.Path, "REQUEST_METHOD="+r.Method, "QUERY_STRING="+r.URL.RawQuery, "CONTENT_TYPE="+r.Header.Get("Content-Type"), "CONTENT_LENGTH="+strconv.FormatInt(r.ContentLength, 10))
6147- cmd := exec.Command("git", "http-backend")
6148- cmd.Env = env
6149- cmd.Stdin = r.Body
6150- out, err := cmd.Output()
6151- if err != nil {
6152- http.Error(w, err.Error(), http.StatusBadGateway)
6153- return
6154- }
6155- parts := strings.SplitN(string(out), "\r\n\r\n", 2)
6156- if len(parts) != 2 {
6157- http.Error(w, "invalid git backend response", http.StatusBadGateway)
6158- return
6159- }
6160- for _, line := range strings.Split(parts[0], "\r\n") {
6161- fields := strings.SplitN(line, ": ", 2)
6162- if len(fields) == 2 {
6163- w.Header().Set(fields[0], fields[1])
6164- }
6165- }
6166- _, _ = io.WriteString(w, parts[1])
6167-}
6168-
6169-func gitRepairRun(t *testing.T, args ...string) {
6170- t.Helper()
6171- if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
6172- t.Fatalf("git %v: %v: %s", args, err, out)
6173- }
6174-}
6175-func gitRepairOutput(t *testing.T, args ...string) string {
6176- t.Helper()
6177- out, err := exec.Command("git", args...).Output()
6178- if err != nil {
6179- t.Fatal(err)
6180- }
6181- return string(out)
6182-}
6183-func gitRepairObject(t *testing.T, dir, object string) bool {
6184- t.Helper()
6185- return exec.Command("git", "--git-dir", dir, "cat-file", "-e", object).Run() == nil
6186-}
6187diff --git a/internal/gitrelay/private_transport.go b/internal/gitrelay/private_transport.go
6188deleted file mode 100644
6189index 21a5a93..0000000
6190--- a/internal/gitrelay/private_transport.go
6191+++ /dev/null
6192@@ -1,256 +0,0 @@
6193-package gitrelay
6194-
6195-import (
6196- "context"
6197- "crypto/rand"
6198- "crypto/sha256"
6199- "encoding/hex"
6200- "encoding/json"
6201- "errors"
6202- "fmt"
6203- "io"
6204- "net"
6205- "net/http"
6206- "net/url"
6207- "os"
6208- "strings"
6209-
6210- "github.com/FelineStateMachine/tinyrelay/internal/auth"
6211- "github.com/FelineStateMachine/tinyrelay/internal/policy"
6212-)
6213-
6214-// HTTPAuthSigner returns the authorization header for a private peer request.
6215-// GRASP-08 callers receive the repository root, GET and an empty payload hash;
6216-// it is deliberately a callback so GitRelay never owns private keys.
6217-type HTTPAuthSigner func(context.Context, string, string, string) (string, error)
6218-
6219-// ConfigurePrivatePeers refreshes operator-controlled private transport after
6220-// a tenant policy update without reopening the Git store.
6221-func (g *GitRelay) ConfigurePrivatePeers(peers []string, signer HTTPAuthSigner) {
6222- g.mu.Lock()
6223- defer g.mu.Unlock()
6224- g.privatePeers = append([]string(nil), peers...)
6225- g.httpAuth = signer
6226-}
6227-
6228-type privateProxy struct {
6229- server *http.Server
6230- listener net.Listener
6231- done chan struct{}
6232- base *url.URL
6233- token string
6234- sign HTTPAuthSigner
6235- addr string
6236- client *http.Client
6237-}
6238-
6239-func newPrivateProxy(ctx context.Context, target string, sign HTTPAuthSigner, allowPrivate ...bool) (*privateProxy, error) {
6240- base, err := url.Parse(target)
6241- if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
6242- return nil, errors.New("Git private peer: invalid target")
6243- }
6244- if sign == nil {
6245- return nil, errors.New("Git private peer: missing signer")
6246- }
6247- raw := make([]byte, 16)
6248- if _, err := rand.Read(raw); err != nil {
6249- return nil, fmt.Errorf("Git private peer token: %w", err)
6250- }
6251- private := len(allowPrivate) > 0 && allowPrivate[0]
6252- p := &privateProxy{base: base, token: hex.EncodeToString(raw), sign: sign, done: make(chan struct{}), client: privateHTTPClient(private)}
6253- p.server = &http.Server{Handler: http.HandlerFunc(p.serve)}
6254- listener, err := net.Listen("tcp", "127.0.0.1:0")
6255- if err != nil {
6256- return nil, fmt.Errorf("Git private peer listener: %w", err)
6257- }
6258- p.listener = listener
6259- p.addr = listener.Addr().String()
6260- go func() { defer close(p.done); _ = p.server.Serve(listener) }()
6261- return p, nil
6262-}
6263-
6264-func (p *privateProxy) URL() string {
6265- return "http://" + p.addr + "/tiny/" + p.token
6266-}
6267-
6268-func (p *privateProxy) Close(ctx context.Context) error {
6269- if ctx == nil || ctx.Err() != nil {
6270- ctx = context.Background()
6271- }
6272- err := p.server.Shutdown(ctx)
6273- if err != nil && p.listener != nil {
6274- _ = p.listener.Close()
6275- }
6276- select {
6277- case <-p.done:
6278- case <-ctx.Done():
6279- if p.listener != nil {
6280- _ = p.listener.Close()
6281- }
6282- <-p.done
6283- }
6284- return err
6285-}
6286-
6287-func (p *privateProxy) serve(w http.ResponseWriter, r *http.Request) {
6288- prefix := "/tiny/" + p.token
6289- if r.URL.Path != prefix && !strings.HasPrefix(r.URL.Path, prefix+"/") {
6290- http.NotFound(w, r)
6291- return
6292- }
6293- u := *p.base
6294- suffix := strings.TrimPrefix(r.URL.Path, prefix)
6295- if strings.Contains(suffix, "..") {
6296- http.Error(w, "invalid private peer path", http.StatusBadRequest)
6297- return
6298- }
6299- u.Path = strings.TrimSuffix(p.base.Path, "/") + "/" + strings.TrimPrefix(suffix, "/")
6300- u.RawQuery = r.URL.RawQuery
6301- file, _, size, err := spoolRequest(r.Body)
6302- if err != nil {
6303- http.Error(w, err.Error(), http.StatusBadGateway)
6304- return
6305- }
6306- if file != nil {
6307- defer func() { _ = os.Remove(file.Name()); _ = file.Close() }()
6308- }
6309- signURL, ok := auth.GRASP08RepositoryRoot(u.String())
6310- if !ok {
6311- http.Error(w, "private peer: invalid Git repository URL", http.StatusBadGateway)
6312- return
6313- }
6314- // GRASP-08 uses one reusable GET proof for the repository root. The
6315- // concrete Smart HTTP path and request body are deliberately out of scope.
6316- authorization, err := p.sign(r.Context(), http.MethodGet, signURL, "")
6317- if err != nil {
6318- http.Error(w, err.Error(), http.StatusBadGateway)
6319- return
6320- }
6321- var body io.Reader
6322- if file != nil {
6323- if _, err := file.Seek(0, io.SeekStart); err != nil {
6324- http.Error(w, err.Error(), http.StatusBadGateway)
6325- return
6326- }
6327- body = file
6328- }
6329- request, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), body)
6330- if err != nil {
6331- http.Error(w, err.Error(), http.StatusBadGateway)
6332- return
6333- }
6334- copyHeaders(request.Header, r.Header)
6335- request.Header.Set("Authorization", authorization)
6336- request.ContentLength = size
6337- response, err := p.client.Do(request)
6338- if err != nil {
6339- http.Error(w, err.Error(), http.StatusBadGateway)
6340- return
6341- }
6342- defer response.Body.Close()
6343- copyHeaders(w.Header(), response.Header)
6344- w.WriteHeader(response.StatusCode)
6345- _, _ = io.Copy(w, response.Body)
6346-}
6347-
6348-func spoolRequest(body io.ReadCloser) (*os.File, [32]byte, int64, error) {
6349- var digest [32]byte
6350- if body == nil || body == http.NoBody {
6351- return nil, digest, 0, nil
6352- }
6353- file, err := os.CreateTemp("", "tiny-private-git-")
6354- if err != nil {
6355- return nil, digest, 0, err
6356- }
6357- hash := sha256.New()
6358- size, err := io.Copy(io.MultiWriter(file, hash), body)
6359- if err != nil {
6360- _ = file.Close()
6361- _ = os.Remove(file.Name())
6362- return nil, digest, 0, err
6363- }
6364- copy(digest[:], hash.Sum(nil))
6365- return file, digest, size, nil
6366-}
6367-
6368-func copyHeaders(dst http.Header, src http.Header) {
6369- for key, values := range src {
6370- if strings.EqualFold(key, "Authorization") || strings.EqualFold(key, "Host") || strings.EqualFold(key, "Content-Length") || strings.EqualFold(key, "Transfer-Encoding") {
6371- continue
6372- }
6373- for _, value := range values {
6374- dst.Add(key, value)
6375- }
6376- }
6377-}
6378-
6379-func privateHTTPClient(allowPrivate bool) *http.Client {
6380- transport := http.DefaultTransport.(*http.Transport).Clone()
6381- transport.Proxy = nil
6382- transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
6383- host, port, err := net.SplitHostPort(address)
6384- if err != nil {
6385- return nil, err
6386- }
6387- ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
6388- if err != nil {
6389- return nil, err
6390- }
6391- for _, ip := range ips {
6392- if !allowPrivate && privateIP(ip) {
6393- continue
6394- }
6395- conn, dialErr := (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
6396- if dialErr == nil {
6397- return conn, nil
6398- }
6399- }
6400- return nil, fmt.Errorf("private peer: no usable address for %s", host)
6401- }
6402- return &http.Client{Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
6403-}
6404-
6405-func (g *GitRelay) privatePeer(source string) bool {
6406- return g.privatePeerBase(source) != ""
6407-}
6408-
6409-func (g *GitRelay) privatePeerBase(source string) string {
6410- g.mu.RLock()
6411- peers := append([]string(nil), g.privatePeers...)
6412- g.mu.RUnlock()
6413- return policy.PrivatePeerBase(source, peers)
6414-}
6415-
6416-func probePrivatePeer(ctx context.Context, source string, allowPrivate ...bool) error {
6417- u, err := url.Parse(source)
6418- if err != nil {
6419- return err
6420- }
6421- req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
6422- if err != nil {
6423- return err
6424- }
6425- req.Header.Set("Accept", "application/nostr+json")
6426- private := len(allowPrivate) > 0 && allowPrivate[0]
6427- res, err := privateHTTPClient(private).Do(req)
6428- if err != nil {
6429- return err
6430- }
6431- defer res.Body.Close()
6432- if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
6433- return fmt.Errorf("private peer NIP-11 status %d", res.StatusCode)
6434- }
6435- var info struct {
6436- SupportedGRASPs []string `json:"supported_grasps"`
6437- PrivateService bool `json:"private_service"`
6438- }
6439- if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&info); err != nil {
6440- return err
6441- }
6442- for _, profile := range info.SupportedGRASPs {
6443- if strings.EqualFold(profile, "GRASP-08") {
6444- return nil
6445- }
6446- }
6447- return errors.New("private peer does not advertise GRASP-08")
6448-}
6449diff --git a/internal/gitrelay/private_transport_test.go b/internal/gitrelay/private_transport_test.go
6450deleted file mode 100644
6451index 1d83917..0000000
6452--- a/internal/gitrelay/private_transport_test.go
6453+++ /dev/null
6454@@ -1,78 +0,0 @@
6455-package gitrelay
6456-
6457-import (
6458- "context"
6459- "io"
6460- "net/http"
6461- "net/http/httptest"
6462- "strings"
6463- "testing"
6464-)
6465-
6466-func TestPrivateProxySignsAndStreamsGitRequest(t *testing.T) {
6467- var authHeader string
6468- var body string
6469- remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6470- authHeader = r.Header.Get("Authorization")
6471- data, _ := io.ReadAll(r.Body)
6472- body = string(data)
6473- w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
6474- _, _ = w.Write([]byte("pack-response"))
6475- }))
6476- t.Cleanup(remote.Close)
6477- proxy, err := newPrivateProxy(context.Background(), remote.URL+"/r/owner/repo.git", func(_ context.Context, method, target, payload string) (string, error) {
6478- if method != http.MethodGet || target != remote.URL+"/r/owner/repo.git" || payload != "" {
6479- return "", &testError{"unexpected signed request"}
6480- }
6481- return "Nostr signed:" + payload, nil
6482- }, true)
6483- if err != nil {
6484- t.Fatal(err)
6485- }
6486- t.Cleanup(func() { _ = proxy.Close(context.Background()) })
6487- req, err := http.NewRequest(http.MethodPost, proxy.URL()+"/info/refs", strings.NewReader("git-request"))
6488- if err != nil {
6489- t.Fatal(err)
6490- }
6491- res, err := http.DefaultClient.Do(req)
6492- if err != nil {
6493- t.Fatal(err)
6494- }
6495- defer res.Body.Close()
6496- if res.StatusCode != http.StatusOK {
6497- t.Fatalf("status=%d", res.StatusCode)
6498- }
6499- if got, _ := io.ReadAll(res.Body); string(got) != "pack-response" {
6500- t.Fatalf("response=%q", got)
6501- }
6502- if body != "git-request" || !strings.HasPrefix(authHeader, "Nostr signed:") {
6503- t.Fatalf("forwarded body/auth=%q/%q", body, authHeader)
6504- }
6505-}
6506-
6507-func TestProbePrivatePeerRequiresGRASP08(t *testing.T) {
6508- for _, tc := range []struct {
6509- name string
6510- body string
6511- want bool
6512- }{
6513- {name: "private", body: `{"supported_grasps":["GRASP-08"]}`, want: true},
6514- {name: "public", body: `{"supported_grasps":["GRASP-01"]}`, want: false},
6515- } {
6516- t.Run(tc.name, func(t *testing.T) {
6517- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6518- w.Header().Set("Content-Type", "application/nostr+json")
6519- _, _ = w.Write([]byte(tc.body))
6520- }))
6521- t.Cleanup(server.Close)
6522- err := probePrivatePeer(context.Background(), server.URL, true)
6523- if (err == nil) != tc.want {
6524- t.Fatalf("probe error=%v", err)
6525- }
6526- })
6527- }
6528-}
6529-
6530-type testError struct{ text string }
6531-
6532-func (e *testError) Error() string { return e.text }
6533diff --git a/internal/gitrelay/recovery_refs.go b/internal/gitrelay/recovery_refs.go
6534deleted file mode 100644
6535index 790cfb9..0000000
6536--- a/internal/gitrelay/recovery_refs.go
6537+++ /dev/null
6538@@ -1,28 +0,0 @@
6539-package gitrelay
6540-
6541-import (
6542- "errors"
6543- "os"
6544-)
6545-
6546-// Native refs can include receive-pack staging work. The signed authority
6547-// file identifies which exact ref/object pairs have actually been published.
6548-func (g *GitRelay) publishedNativeRefs(r Repository) (map[string]string, error) {
6549- published, err := readRefsFile(g.statePath(r))
6550- if errors.Is(err, os.ErrNotExist) {
6551- return map[string]string{}, nil
6552- }
6553- if err != nil {
6554- return nil, err
6555- }
6556- native, err := g.nativeRefs(r)
6557- if err != nil {
6558- return nil, err
6559- }
6560- for name, oid := range published {
6561- if native[name] != oid || internalRef(name) {
6562- delete(published, name)
6563- }
6564- }
6565- return published, nil
6566-}
6567diff --git a/internal/gitrelay/repair.go b/internal/gitrelay/repair.go
6568deleted file mode 100644
6569index e5a2fcf..0000000
6570--- a/internal/gitrelay/repair.go
6571+++ /dev/null
6572@@ -1,365 +0,0 @@
6573-package gitrelay
6574-
6575-import (
6576- "context"
6577- "errors"
6578- "fmt"
6579- "net"
6580- "net/url"
6581- "os"
6582- "os/exec"
6583- "path/filepath"
6584- "strconv"
6585- "strings"
6586-)
6587-
6588-// AdmitSource enforces the source restrictions from GRASP's Git sync model.
6589-// Only HTTPS sources are accepted; localhost, private IPs, credentials,
6590-// query/fragment data, and this relay itself are rejected.
6591-func (g *GitRelay) AdmitSource(raw string) (string, error) {
6592- return g.admitSource(context.Background(), raw, false)
6593-}
6594-
6595-func (g *GitRelay) admitSource(ctx context.Context, raw string, resolve bool) (string, error) {
6596- u, err := url.Parse(raw)
6597- privatePeer := g.privatePeer(raw)
6598- if err != nil || (u.Scheme != "https" && !(privatePeer && u.Scheme == "http")) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
6599- return "", errors.New("invalid: Git source must be a credential-free HTTPS URL")
6600- }
6601- host := strings.ToLower(u.Hostname())
6602- if host == "localhost" || strings.HasSuffix(host, ".localhost") || host == "metadata.google.internal" {
6603- return "", errors.New("blocked: Git source host is local or address-like")
6604- }
6605- if ip := net.ParseIP(host); ip != nil && !g.allowPrivate {
6606- return "", errors.New("blocked: Git source host is an IP literal")
6607- }
6608- if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {
6609- return "", errors.New("blocked: Git source host is private")
6610- }
6611- if resolve {
6612- ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
6613- if err != nil {
6614- return "", fmt.Errorf("Git source DNS lookup: %w", err)
6615- }
6616- for _, ip := range ips {
6617- if privateIP(ip) && !g.allowPrivate {
6618- return "", errors.New("blocked: Git source resolves to a private address")
6619- }
6620- }
6621- }
6622- if g.serviceURL != "" && strings.TrimRight(raw, "/") == g.serviceURL {
6623- return "", errors.New("blocked: Git source points to this relay")
6624- }
6625- return strings.TrimRight(raw, "/"), nil
6626-}
6627-
6628-func (g *GitRelay) resolvedSource(ctx context.Context, raw string) (string, string, error) {
6629- source, err := g.admitSource(ctx, raw, false)
6630- if err != nil {
6631- return "", "", err
6632- }
6633- u, err := url.Parse(source)
6634- if err != nil {
6635- return "", "", err
6636- }
6637- ips, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())
6638- if err != nil {
6639- return "", "", fmt.Errorf("Git source DNS lookup: %w", err)
6640- }
6641- for _, ip := range ips {
6642- if !privateIP(ip) || g.allowPrivate {
6643- return source, ip.String(), nil
6644- }
6645- }
6646- return "", "", errors.New("blocked: Git source resolves to a private address")
6647-}
6648-
6649-func privateIP(ip net.IP) bool {
6650- if ip == nil {
6651- return true
6652- }
6653- if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
6654- return true
6655- }
6656- return ip.IsUnspecified()
6657-}
6658-
6659-// FetchMissing downloads only the expected refs from an admitted source. Git
6660-// verifies pack checksums and object connectivity; refs are installed only
6661-// after every expected object exists locally.
6662-func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string) error {
6663- return g.fetchMissing(ctx, r, sources, expected, 0, 0)
6664-}
6665-
6666-// fetchMissing is shared by ordinary branch repair and the more restrictive
6667-// PR repair path. A non-zero depth keeps an untrusted PR clone from making the
6668-// hosted repository walk arbitrary history. maxBytes bounds transfer traffic
6669-// across all sources and the isolated destination after each fetch. These
6670-// limits apply only to isolated repositories, never the hosted branch repo.
6671-func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string, depth int, maxBytes int64) error {
6672- if err := g.ensureRepo(r); err != nil {
6673- return err
6674- }
6675- local := r
6676- local.Refs = expected
6677- if g.stateObjectsPresent(ctx, local) {
6678- return g.installFetchedRefs(r, expected)
6679- }
6680- missing := make(map[string]struct{}, len(expected))
6681- var budget *byteBudget
6682- if maxBytes > 0 {
6683- budget = &byteBudget{}
6684- }
6685- var lastErr error
6686- for ref, oid := range expected {
6687- if oid != "" {
6688- missing[ref] = struct{}{}
6689- }
6690- }
6691- for _, raw := range sources {
6692- if err := ctx.Err(); err != nil {
6693- return err
6694- }
6695- if budget != nil && len(missing) == 0 {
6696- break
6697- }
6698- if budget != nil && budget.used.Load() >= maxBytes {
6699- return errRepairByteLimit
6700- }
6701- // Private object IDs must never be requested from a public source.
6702- private := r.Private || (g.policy != nil && g.policy().Reads == "members")
6703- if private && !g.privatePeer(raw) {
6704- lastErr = errors.New("blocked: private Git source is not a configured peer")
6705- continue
6706- }
6707- source, ip, err := g.resolvedSource(ctx, raw)
6708- if err != nil {
6709- lastErr = err
6710- continue
6711- }
6712- var proxy *privateProxy
6713- if peerBase := g.privatePeerBase(source); peerBase != "" {
6714- if err := probePrivatePeer(ctx, peerBase, g.allowPrivate); err != nil {
6715- continue
6716- }
6717- g.mu.RLock()
6718- signer := g.httpAuth
6719- g.mu.RUnlock()
6720- proxy, err = newPrivateProxy(ctx, source, signer, g.allowPrivate)
6721- if err != nil {
6722- continue
6723- }
6724- defer proxy.Close(context.Background())
6725- }
6726- fetchSource := source
6727- if proxy != nil {
6728- fetchSource = proxy.URL()
6729- }
6730- var budgetProxy *repairProxy
6731- if budget != nil {
6732- targetIP := ip
6733- if proxy != nil {
6734- if parsed, parseErr := url.Parse(proxy.URL()); parseErr == nil {
6735- targetIP = parsed.Hostname()
6736- }
6737- }
6738- parsedSource, parseErr := url.Parse(fetchSource)
6739- if parseErr != nil {
6740- lastErr = parseErr
6741- continue
6742- }
6743- targetHost := parsedSource.Hostname()
6744- targetPort := parsedSource.Port()
6745- if targetPort == "" {
6746- targetPort = "443"
6747- if parsedSource.Scheme == "http" {
6748- targetPort = "80"
6749- }
6750- }
6751- budgetProxy, err = newRepairProxy(ctx, targetIP, targetHost, targetPort, budget, maxBytes)
6752- if err != nil {
6753- lastErr = err
6754- continue
6755- }
6756- defer budgetProxy.Close()
6757- }
6758- for ref, oid := range expected {
6759- if err := ctx.Err(); err != nil {
6760- return err
6761- }
6762- if budget != nil && budget.used.Load() >= maxBytes {
6763- return errRepairByteLimit
6764- }
6765- if _, needed := missing[ref]; budget != nil && !needed {
6766- continue
6767- }
6768- if oid == "" {
6769- delete(missing, ref)
6770- continue
6771- }
6772- port := uPort(source)
6773- resolve := []string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}
6774- if proxy != nil {
6775- fetchSource = proxy.URL()
6776- resolve = nil
6777- }
6778- if budgetProxy != nil {
6779- resolve = nil
6780- }
6781- fetchRef := ref
6782- if strings.HasPrefix(ref, "refs/nostr/") {
6783- // NIP-34 clone URLs may point to ordinary Git hosts. The
6784- // signed commit is the source; refs/nostr is our local name.
6785- if !isObjectID(oid) {
6786- return errors.New("invalid: PR commit ID")
6787- }
6788- fetchRef = oid
6789- }
6790- proxySetting := ""
6791- if budgetProxy != nil {
6792- proxySetting = budgetProxy.URL()
6793- }
6794- args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=" + proxySetting, "--git-dir", g.repoPath(r), "fetch", "--no-tags"}
6795- if depth > 0 {
6796- args = append(args, "--depth", strconv.Itoa(depth))
6797- }
6798- args = append(args, fetchSource, "+"+fetchRef+":"+"refs/tinyrelay/source/"+safeRef(ref))
6799- // Keep the resolve option before the subcommand; Git accepts config
6800- // options only before the command name.
6801- if len(resolve) > 0 {
6802- args = append([]string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}, args...)
6803- }
6804- cmd := exec.CommandContext(ctx, "git", args...)
6805- if depth > 0 && maxBytes > 0 {
6806- // Git receives pack data in a child process and does not expose a
6807- // portable receive-byte limit. Apply the shell's file-size limit
6808- // to the isolated fetch process so an oversized pack is stopped
6809- // while it is being written. The unit is 512 bytes in dash and
6810- // 1024 in bash, so this is a coarse guard; the proxy budget and
6811- // the cumulative size check below enforce the exact bound.
6812- limitBlocks := (maxBytes + 511) / 512
6813- limited := []string{"ulimit -f \"$1\"; shift; exec \"$@\"", "gitfetch", strconv.FormatInt(limitBlocks, 10), "git"}
6814- limited = append(limited, args...)
6815- cmd = exec.CommandContext(ctx, "sh", append([]string{"-c"}, limited...)...)
6816- }
6817- cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "http_proxy=", "https_proxy=", "all_proxy=", "NO_PROXY=", "no_proxy=", "GIT_CONFIG_NOSYSTEM=1")
6818- if budget != nil {
6819- // A URL-specific proxy or rewrite in the user's Git config must
6820- // not bypass the bounded transport selected for this repair.
6821- cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull)
6822- }
6823- if out, err := cmd.CombinedOutput(); err != nil {
6824- lastErr = fmt.Errorf("fetch %s: %w: %s", fetchSource, err, strings.TrimSpace(string(out)))
6825- continue
6826- }
6827- if maxBytes > 0 {
6828- size, err := gitDirSize(g.repoPath(r))
6829- if err != nil {
6830- lastErr = fmt.Errorf("inspect isolated Git repository: %w", err)
6831- continue
6832- }
6833- if size > maxBytes {
6834- return fmt.Errorf("Git source exceeded isolated repair budget: %d bytes", size)
6835- }
6836- }
6837- if !g.objectsPresent(ctx, r, []string{oid}) {
6838- continue
6839- }
6840- delete(missing, ref)
6841- }
6842- }
6843- if len(missing) != 0 {
6844- if lastErr != nil {
6845- return fmt.Errorf("Git sources did not provide every expected object: %w", lastErr)
6846- }
6847- return errors.New("Git sources did not provide every expected object")
6848- }
6849- return g.installFetchedRefs(r, expected)
6850-}
6851-
6852-func (g *GitRelay) installFetchedRefs(r Repository, expected map[string]string) error {
6853- native, err := g.nativeRefs(r)
6854- if err != nil {
6855- return err
6856- }
6857- for ref, oid := range expected {
6858- if oid != "" && native[ref] != oid {
6859- if err := g.updateRef(r, ref, oid); err != nil {
6860- return err
6861- }
6862- }
6863- }
6864- return nil
6865-}
6866-
6867-func urlHost(raw string) string {
6868- u, _ := url.Parse(raw)
6869- h := u.Hostname()
6870- if strings.Contains(h, ":") {
6871- return "[" + h + "]"
6872- }
6873- return h
6874-}
6875-func uPort(raw string) string {
6876- u, _ := url.Parse(raw)
6877- if p := u.Port(); p != "" {
6878- return p
6879- }
6880- return "443"
6881-}
6882-
6883-func safeRef(ref string) string {
6884- return strings.NewReplacer("/", "_", "\\", "_").Replace(strings.TrimPrefix(ref, "refs/"))
6885-}
6886-
6887-func gitDirSize(root string) (int64, error) {
6888- var total int64
6889- err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
6890- if err != nil {
6891- if os.IsNotExist(err) {
6892- return nil
6893- }
6894- return err
6895- }
6896- if entry.IsDir() {
6897- return nil
6898- }
6899- info, err := entry.Info()
6900- if err != nil {
6901- if os.IsNotExist(err) {
6902- return nil
6903- }
6904- return err
6905- }
6906- total += info.Size()
6907- return nil
6908- })
6909- return total, err
6910-}
6911-
6912-func (g *GitRelay) updateRef(r Repository, ref, oid string) error {
6913- cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", ref, oid)
6914- if out, err := cmd.CombinedOutput(); err != nil {
6915- return fmt.Errorf("update ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))
6916- }
6917- return nil
6918-}
6919-func isObjectID(s string) bool {
6920- if len(s) != 40 {
6921- return false
6922- }
6923- for _, c := range s {
6924- if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
6925- return false
6926- }
6927- }
6928- return true
6929-}
6930-func contains(values []string, want string) bool {
6931- for _, value := range values {
6932- if value == want {
6933- return true
6934- }
6935- }
6936- return false
6937-}
6938diff --git a/internal/gitrelay/repository.go b/internal/gitrelay/repository.go
6939deleted file mode 100644
6940index 331a3a3..0000000
6941--- a/internal/gitrelay/repository.go
6942+++ /dev/null
6943@@ -1,352 +0,0 @@
6944-package gitrelay
6945-
6946-import (
6947- "bytes"
6948- "context"
6949- "fmt"
6950- "os"
6951- "os/exec"
6952- "path/filepath"
6953- "sort"
6954- "strings"
6955- "time"
6956-
6957- "github.com/FelineStateMachine/tinyrelay/internal/event"
6958- "github.com/FelineStateMachine/tinyrelay/internal/storage"
6959-)
6960-
6961-func (g *GitRelay) repoPath(r Repository) string {
6962- if r.Alternative {
6963- return filepath.Join(g.root, "prs", r.Owner, r.Identifier+".git")
6964- }
6965- return filepath.Join(g.root, r.Owner, r.Identifier+".git")
6966-}
6967-func (g *GitRelay) statePath(r Repository) string {
6968- return filepath.Join(g.repoPath(r), "tinyrelay.refs")
6969-}
6970-func (g *GitRelay) pendingStatePath(r Repository) string {
6971- return filepath.Join(g.repoPath(r), "tinyrelay.pending")
6972-}
6973-
6974-func (g *GitRelay) pendingHeadPath(r Repository) string {
6975- return filepath.Join(g.repoPath(r), "tinyrelay.pending.head")
6976-}
6977-
6978-func (g *GitRelay) stateObjectsPresent(ctx context.Context, r Repository) bool {
6979- oids := make([]string, 0, len(r.Refs))
6980- for _, oid := range r.Refs {
6981- if oid != "" {
6982- oids = append(oids, oid)
6983- }
6984- }
6985- return g.objectsPresent(ctx, r, oids)
6986-}
6987-
6988-// objectsPresent checks all object IDs through one long-lived cat-file
6989-// process. Repository state can contain hundreds of refs; starting one Git
6990-// process per ref makes promotion latency grow linearly with process startup.
6991-func (g *GitRelay) objectsPresent(ctx context.Context, r Repository, oids []string) bool {
6992- if len(oids) == 0 {
6993- return true
6994- }
6995- cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(r), "cat-file", "--batch-check")
6996- var out bytes.Buffer
6997- cmd.Stdin = strings.NewReader(strings.Join(oids, "\n") + "\n")
6998- cmd.Stdout = &out
6999- if err := cmd.Run(); err != nil {
7000- return false
7001- }
7002- lines := strings.Split(strings.TrimSpace(out.String()), "\n")
7003- if len(lines) != len(oids) {
7004- return false
7005- }
7006- for _, line := range lines {
7007- fields := strings.Fields(line)
7008- if len(fields) < 2 || fields[1] == "missing" {
7009- return false
7010- }
7011- }
7012- return true
7013-}
7014-func (g *GitRelay) ensureR
Diff preview truncated (256 KiB or 10,000 lines).

About

ownerc01503e8035f
visibilitypublic
head9d68e8778c34

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs