| 1 | diff --git a/AGENTS.md b/AGENTS.md |
| 2 | index 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. |
| 11 | diff --git a/Dockerfile b/Dockerfile |
| 12 | index 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"] |
| 24 | diff --git a/Makefile b/Makefile |
| 25 | index 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 | |
| 48 | diff --git a/README.md b/README.md |
| 49 | index 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: |
| 73 | diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES |
| 74 | index 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 | ----------------- |
| 103 | diff --git a/cmd/tinyclient/main.go b/cmd/tinyclient/main.go |
| 104 | new file mode 100644 |
| 105 | index 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 | +} |
| 184 | diff --git a/cmd/tinyclient/main_test.go b/cmd/tinyclient/main_test.go |
| 185 | new file mode 100644 |
| 186 | index 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 | +} |
| 238 | diff --git a/cmd/tinygit/main.go b/cmd/tinygit/main.go |
| 239 | new file mode 100644 |
| 240 | index 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 | +} |
| 332 | diff --git a/cmd/tinygit/main_integration_test.go b/cmd/tinygit/main_integration_test.go |
| 333 | new file mode 100644 |
| 334 | index 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 | +} |
| 529 | diff --git a/docs/architecture.md b/docs/architecture.md |
| 530 | index 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 | |
| 562 | diff --git a/docs/extensions/README.md b/docs/extensions/README.md |
| 563 | new file mode 100644 |
| 564 | index 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. |
| 577 | diff --git a/docs/extensions/tinyclient-read-adapter.md b/docs/extensions/tinyclient-read-adapter.md |
| 578 | new file mode 100644 |
| 579 | index 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). |
| 666 | diff --git a/docs/fixi.md b/docs/fixi.md |
| 667 | index 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 | |
| 678 | diff --git a/docs/module-contracts.md b/docs/module-contracts.md |
| 679 | new file mode 100644 |
| 680 | index 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. |
| 765 | diff --git a/docs/testing.md b/docs/testing.md |
| 766 | index 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. |
| 798 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 799 | index 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 | ) |
| 811 | diff --git a/internal/daemon/client_browse_http.go b/internal/daemon/client_browse_http.go |
| 812 | index 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 { |
| 824 | diff --git a/internal/daemon/client_files_test.go b/internal/daemon/client_files_test.go |
| 825 | index 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 | ) |
| 837 | diff --git a/internal/daemon/collaboration_approvals.go b/internal/daemon/collaboration_approvals.go |
| 838 | index 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 | |
| 850 | diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go |
| 851 | index 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 | ) |
| 863 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 864 | index 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" |
| 876 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 877 | index 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" |
| 889 | diff --git a/internal/daemon/event_commit.go b/internal/daemon/event_commit.go |
| 890 | index 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" |
| 902 | diff --git a/internal/daemon/git_agent_maintainer_test.go b/internal/daemon/git_agent_maintainer_test.go |
| 903 | index 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" |
| 915 | diff --git a/internal/daemon/git_live.go b/internal/daemon/git_live.go |
| 916 | index 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 | ) |
| 928 | diff --git a/internal/daemon/git_live_test.go b/internal/daemon/git_live_test.go |
| 929 | index 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) { |
| 941 | diff --git a/internal/daemon/git_outbox_sync.go b/internal/daemon/git_outbox_sync.go |
| 942 | index 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 | ) |
| 954 | diff --git a/internal/daemon/git_payload_test.go b/internal/daemon/git_payload_test.go |
| 955 | index 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{} |
| 967 | diff --git a/internal/daemon/git_services.go b/internal/daemon/git_services.go |
| 968 | index 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" |
| 980 | diff --git a/internal/daemon/git_services_test.go b/internal/daemon/git_services_test.go |
| 981 | index 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) { |
| 993 | diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go |
| 994 | index 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" |
| 1006 | diff --git a/internal/daemon/git_sync_cursor_test.go b/internal/daemon/git_sync_cursor_test.go |
| 1007 | index 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 | ) |
| 1019 | diff --git a/internal/daemon/git_sync_integration_test.go b/internal/daemon/git_sync_integration_test.go |
| 1020 | index 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 | ) |
| 1032 | diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go |
| 1033 | index 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 |
| 1045 | diff --git a/internal/daemon/private_peer_acceptance_test.go b/internal/daemon/private_peer_acceptance_test.go |
| 1046 | index 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 | ) |
| 1058 | diff --git a/internal/daemon/replication_services.go b/internal/daemon/replication_services.go |
| 1059 | index 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" |
| 1071 | diff --git a/internal/daemon/repository_maintainers.go b/internal/daemon/repository_maintainers.go |
| 1072 | index 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 |
| 1084 | diff --git a/internal/daemon/rooms_browse.go b/internal/daemon/rooms_browse.go |
| 1085 | index 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 ( |
| 1097 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 1098 | index 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 | |
| 1119 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 1120 | index 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 { |
| 1141 | diff --git a/internal/daemon/tinyclient_integration_test.go b/internal/daemon/tinyclient_integration_test.go |
| 1142 | new file mode 100644 |
| 1143 | index 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 | +} |
| 1256 | diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go |
| 1257 | index 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) |
| 1269 | diff --git a/internal/gitrelay/admission.go b/internal/gitrelay/admission.go |
| 1270 | deleted file mode 100644 |
| 1271 | index 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 = © |
| 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(¤t); 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 | -} |
| 1949 | diff --git a/internal/gitrelay/archive.go b/internal/gitrelay/archive.go |
| 1950 | deleted file mode 100644 |
| 1951 | index 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 | -} |
| 2034 | diff --git a/internal/gitrelay/archive_test.go b/internal/gitrelay/archive_test.go |
| 2035 | deleted file mode 100644 |
| 2036 | index 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 | -} |
| 2126 | diff --git a/internal/gitrelay/backup_import.go b/internal/gitrelay/backup_import.go |
| 2127 | deleted file mode 100644 |
| 2128 | index 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 | -} |
| 2341 | diff --git a/internal/gitrelay/browse.go b/internal/gitrelay/browse.go |
| 2342 | deleted file mode 100644 |
| 2343 | index 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 | -} |
| 2762 | diff --git a/internal/gitrelay/browse_test.go b/internal/gitrelay/browse_test.go |
| 2763 | deleted file mode 100644 |
| 2764 | index 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 | -} |
| 2958 | diff --git a/internal/gitrelay/capabilities_test.go b/internal/gitrelay/capabilities_test.go |
| 2959 | deleted file mode 100644 |
| 2960 | index 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 | -} |
| 2998 | diff --git a/internal/gitrelay/cgi_test.go b/internal/gitrelay/cgi_test.go |
| 2999 | deleted file mode 100644 |
| 3000 | index 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 | -} |
| 3120 | diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go |
| 3121 | deleted file mode 100644 |
| 3122 | index 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 | -} |
| 3244 | diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go |
| 3245 | deleted file mode 100644 |
| 3246 | index 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 | -} |
| 3362 | diff --git a/internal/gitrelay/doc.go b/internal/gitrelay/doc.go |
| 3363 | deleted file mode 100644 |
| 3364 | index 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 |
| 3392 | diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go |
| 3393 | deleted file mode 100644 |
| 3394 | index 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 | -} |
| 3560 | diff --git a/internal/gitrelay/gitrelay_test.go b/internal/gitrelay/gitrelay_test.go |
| 3561 | deleted file mode 100644 |
| 3562 | index 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 | -} |
| 4386 | diff --git a/internal/gitrelay/http.go b/internal/gitrelay/http.go |
| 4387 | deleted file mode 100644 |
| 4388 | index 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 | -} |
| 4785 | diff --git a/internal/gitrelay/journal.go b/internal/gitrelay/journal.go |
| 4786 | deleted file mode 100644 |
| 4787 | index 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(¤t) |
| 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 | -} |
| 4931 | diff --git a/internal/gitrelay/maintainers_test.go b/internal/gitrelay/maintainers_test.go |
| 4932 | deleted file mode 100644 |
| 4933 | index 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 | -} |
| 5013 | diff --git a/internal/gitrelay/pr_budget_proxy.go b/internal/gitrelay/pr_budget_proxy.go |
| 5014 | deleted file mode 100644 |
| 5015 | index 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 | -} |
| 5210 | diff --git a/internal/gitrelay/pr_budget_proxy_test.go b/internal/gitrelay/pr_budget_proxy_test.go |
| 5211 | deleted file mode 100644 |
| 5212 | index 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 | -} |
| 5371 | diff --git a/internal/gitrelay/pr_repair.go b/internal/gitrelay/pr_repair.go |
| 5372 | deleted file mode 100644 |
| 5373 | index 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 | -} |
| 5677 | diff --git a/internal/gitrelay/pr_repair_test.go b/internal/gitrelay/pr_repair_test.go |
| 5678 | deleted file mode 100644 |
| 5679 | index 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 | -} |
| 6059 | diff --git a/internal/gitrelay/private_repair_acceptance_test.go b/internal/gitrelay/private_repair_acceptance_test.go |
| 6060 | deleted file mode 100644 |
| 6061 | index 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 | -} |
| 6187 | diff --git a/internal/gitrelay/private_transport.go b/internal/gitrelay/private_transport.go |
| 6188 | deleted file mode 100644 |
| 6189 | index 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 | -} |
| 6449 | diff --git a/internal/gitrelay/private_transport_test.go b/internal/gitrelay/private_transport_test.go |
| 6450 | deleted file mode 100644 |
| 6451 | index 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 } |
| 6533 | diff --git a/internal/gitrelay/recovery_refs.go b/internal/gitrelay/recovery_refs.go |
| 6534 | deleted file mode 100644 |
| 6535 | index 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 | -} |
| 6567 | diff --git a/internal/gitrelay/repair.go b/internal/gitrelay/repair.go |
| 6568 | deleted file mode 100644 |
| 6569 | index 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 | -} |
| 6938 | diff --git a/internal/gitrelay/repository.go b/internal/gitrelay/repository.go |
| 6939 | deleted file mode 100644 |
| 6940 | index 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 |