tinyrelaycommit

Commit 3a631053c493

browse files at this commit | history

1diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go
2index 60ef1f6..5a01864 100644
3--- a/internal/gitrelay/gitrelay.go
4+++ b/internal/gitrelay/gitrelay.go
5@@ -1233,6 +1233,15 @@ func privateIP(ip net.IP) bool {
6 // verifies pack checksums and object connectivity; refs are installed only
7 // after every expected object exists locally.
8 func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string) error {
9+ return g.fetchMissing(ctx, r, sources, expected, 0, 0)
10+}
11+
12+// fetchMissing is shared by ordinary branch repair and the more restrictive
13+// PR repair path. A non-zero depth keeps an untrusted PR clone from making the
14+// hosted repository walk arbitrary history. maxBytes bounds transfer traffic
15+// across all sources and the isolated destination after each fetch. These
16+// limits apply only to isolated repositories, never the hosted branch repo.
17+func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string, depth int, maxBytes int64) error {
18 if err := g.ensureRepo(r); err != nil {
19 return err
20 }
21@@ -1242,6 +1251,10 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
22 return g.installFetchedRefs(r, expected)
23 }
24 missing := make(map[string]struct{}, len(expected))
25+ var budget *byteBudget
26+ if maxBytes > 0 {
27+ budget = &byteBudget{}
28+ }
29 var lastErr error
30 for ref, oid := range expected {
31 if oid != "" {
32@@ -1249,6 +1262,15 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
33 }
34 }
35 for _, raw := range sources {
36+ if err := ctx.Err(); err != nil {
37+ return err
38+ }
39+ if budget != nil && len(missing) == 0 {
40+ break
41+ }
42+ if budget != nil && budget.used.Load() >= maxBytes {
43+ return errRepairByteLimit
44+ }
45 // Private object IDs must never be requested from a public source.
46 private := r.Private || (g.policy != nil && g.policy().Reads == "members")
47 if private && !g.privatePeer(raw) {
48@@ -1257,6 +1279,7 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
49 }
50 source, ip, err := g.resolvedSource(ctx, raw)
51 if err != nil {
52+ lastErr = err
53 continue
54 }
55 var proxy *privateProxy
56@@ -1273,18 +1296,61 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
57 }
58 defer proxy.Close(context.Background())
59 }
60+ fetchSource := source
61+ if proxy != nil {
62+ fetchSource = proxy.URL()
63+ }
64+ var budgetProxy *repairProxy
65+ if budget != nil {
66+ targetIP := ip
67+ if proxy != nil {
68+ if parsed, parseErr := url.Parse(proxy.URL()); parseErr == nil {
69+ targetIP = parsed.Hostname()
70+ }
71+ }
72+ parsedSource, parseErr := url.Parse(fetchSource)
73+ if parseErr != nil {
74+ lastErr = parseErr
75+ continue
76+ }
77+ targetHost := parsedSource.Hostname()
78+ targetPort := parsedSource.Port()
79+ if targetPort == "" {
80+ targetPort = "443"
81+ if parsedSource.Scheme == "http" {
82+ targetPort = "80"
83+ }
84+ }
85+ budgetProxy, err = newRepairProxy(ctx, targetIP, targetHost, targetPort, budget, maxBytes)
86+ if err != nil {
87+ lastErr = err
88+ continue
89+ }
90+ defer budgetProxy.Close()
91+ }
92 for ref, oid := range expected {
93+ if err := ctx.Err(); err != nil {
94+ return err
95+ }
96+ if budget != nil && budget.used.Load() >= maxBytes {
97+ return errRepairByteLimit
98+ }
99+ if _, needed := missing[ref]; budget != nil && !needed {
100+ continue
101+ }
102 if oid == "" {
103 delete(missing, ref)
104 continue
105 }
106 port := uPort(source)
107- fetchSource := source
108 resolve := []string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}
109 if proxy != nil {
110 fetchSource = proxy.URL()
111 resolve = nil
112 }
113+ if budgetProxy != nil {
114+ resolve = nil
115+ }
116 fetchRef := ref
117 if strings.HasPrefix(ref, "refs/nostr/") {
118 // NIP-34 clone URLs may point to ordinary Git hosts. The
119@@ -1294,18 +1360,53 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
120 }
121 fetchRef = oid
122 }
123- args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=", "--git-dir", g.repoPath(r), "fetch", "--no-tags", fetchSource, "+" + fetchRef + ":" + "refs/tinyrelay/source/" + safeRef(ref)}
124+ proxySetting := ""
125+ if budgetProxy != nil {
126+ proxySetting = budgetProxy.URL()
127+ }
128+ args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=" + proxySetting, "--git-dir", g.repoPath(r), "fetch", "--no-tags"}
129+ if depth > 0 {
130+ args = append(args, "--depth", strconv.Itoa(depth))
131+ }
132+ args = append(args, fetchSource, "+"+fetchRef+":"+"refs/tinyrelay/source/"+safeRef(ref))
133 // Keep the resolve option before the subcommand; Git accepts config
134 // options only before the command name.
135 if len(resolve) > 0 {
136 args = append([]string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}, args...)
137 }
138 cmd := exec.CommandContext(ctx, "git", args...)
139- cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "http_proxy=", "https_proxy=", "all_proxy=", "GIT_CONFIG_NOSYSTEM=1")
140+ if depth > 0 && maxBytes > 0 {
141+ // Git receives pack data in a child process and does not expose a
142+ // portable receive-byte limit. Apply the shell's file-size limit
143+ // to the isolated fetch process so an oversized pack is stopped
144+ // while it is being written. The unit is 512 bytes in dash and
145+ // 1024 in bash, so this is a coarse guard; the proxy budget and
146+ // the cumulative size check below enforce the exact bound.
147+ limitBlocks := (maxBytes + 511) / 512
148+ limited := []string{"ulimit -f \"$1\"; shift; exec \"$@\"", "gitfetch", strconv.FormatInt(limitBlocks, 10), "git"}
149+ limited = append(limited, args...)
150+ cmd = exec.CommandContext(ctx, "sh", append([]string{"-c"}, limited...)...)
151+ }
152+ 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")
153+ if budget != nil {
154+ // A URL-specific proxy or rewrite in the user's Git config must
155+ // not bypass the bounded transport selected for this repair.
156+ cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull)
157+ }
158 if out, err := cmd.CombinedOutput(); err != nil {
159 lastErr = fmt.Errorf("fetch %s: %w: %s", fetchSource, err, strings.TrimSpace(string(out)))
160 continue
161 }
162+ if maxBytes > 0 {
163+ size, err := gitDirSize(g.repoPath(r))
164+ if err != nil {
165+ lastErr = fmt.Errorf("inspect isolated Git repository: %w", err)
166+ continue
167+ }
168+ if size > maxBytes {
169+ return fmt.Errorf("Git source exceeded isolated repair budget: %d bytes", size)
170+ }
171+ }
172 if !g.objectsPresent(ctx, r, []string{oid}) {
173 continue
174 }
175@@ -1356,6 +1457,31 @@ func safeRef(ref string) string {
176 return strings.NewReplacer("/", "_", "\\", "_").Replace(strings.TrimPrefix(ref, "refs/"))
177 }
178
179+func gitDirSize(root string) (int64, error) {
180+ var total int64
181+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
182+ if err != nil {
183+ if os.IsNotExist(err) {
184+ return nil
185+ }
186+ return err
187+ }
188+ if entry.IsDir() {
189+ return nil
190+ }
191+ info, err := entry.Info()
192+ if err != nil {
193+ if os.IsNotExist(err) {
194+ return nil
195+ }
196+ return err
197+ }
198+ total += info.Size()
199+ return nil
200+ })
201+ return total, err
202+}
203+
204 func (g *GitRelay) updateRef(r Repository, ref, oid string) error {
205 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", ref, oid)
206 if out, err := cmd.CombinedOutput(); err != nil {
207diff --git a/internal/gitrelay/pr_budget_proxy.go b/internal/gitrelay/pr_budget_proxy.go
208new file mode 100644
209index 0000000..9da3093
210--- /dev/null
211+++ b/internal/gitrelay/pr_budget_proxy.go
212@@ -0,0 +1,171 @@
213+package gitrelay
214+
215+import (
216+ "bufio"
217+ "context"
218+ "errors"
219+ "io"
220+ "net"
221+ "net/http"
222+ "strings"
223+ "sync"
224+ "sync/atomic"
225+)
226+
227+var errRepairByteLimit = errors.New("PR repair transfer exceeds byte limit")
228+
229+// A single budget covers every connection, ref and source in one repair.
230+// Counting tunneled TLS bytes also bounds HTTPS without terminating its TLS.
231+type byteBudget struct{ used atomic.Int64 }
232+
233+func (b *byteBudget) take(wanted, limit int64) int64 {
234+ for {
235+ old := b.used.Load()
236+ allowed := min(wanted, max(0, limit-old))
237+ if b.used.CompareAndSwap(old, old+allowed) {
238+ return allowed
239+ }
240+ }
241+}
242+
243+type repairProxy struct {
244+ listener net.Listener
245+ ctx context.Context
246+ cancel context.CancelFunc
247+ budget *byteBudget
248+ limit int64
249+ targetIP string
250+ targetHost string
251+ targetPort string
252+ mu sync.Mutex
253+ connections map[net.Conn]struct{}
254+ closeOnce sync.Once
255+}
256+
257+func newRepairProxy(ctx context.Context, targetIP, targetHost, targetPort string, budget *byteBudget, limit int64) (*repairProxy, error) {
258+ listener, err := net.Listen("tcp", "127.0.0.1:0")
259+ if err != nil {
260+ return nil, err
261+ }
262+ proxyCtx, cancel := context.WithCancel(ctx)
263+ p := &repairProxy{listener: listener, ctx: proxyCtx, cancel: cancel, budget: budget, limit: limit, targetIP: targetIP, targetHost: targetHost, targetPort: targetPort, connections: make(map[net.Conn]struct{})}
264+ context.AfterFunc(proxyCtx, func() { _ = p.Close() })
265+ go p.serve()
266+ return p, nil
267+}
268+
269+func (p *repairProxy) URL() string { return "http://" + p.listener.Addr().String() }
270+
271+func (p *repairProxy) Close() error {
272+ p.closeOnce.Do(func() {
273+ p.cancel()
274+ _ = p.listener.Close()
275+ p.mu.Lock()
276+ defer p.mu.Unlock()
277+ for conn := range p.connections {
278+ _ = conn.Close()
279+ }
280+ })
281+ return nil
282+}
283+
284+func (p *repairProxy) track(conn net.Conn) bool {
285+ p.mu.Lock()
286+ defer p.mu.Unlock()
287+ if p.ctx.Err() != nil {
288+ _ = conn.Close()
289+ return false
290+ }
291+ p.connections[conn] = struct{}{}
292+ return true
293+}
294+
295+func (p *repairProxy) release(conn net.Conn) {
296+ _ = conn.Close()
297+ p.mu.Lock()
298+ delete(p.connections, conn)
299+ p.mu.Unlock()
300+}
301+
302+func (p *repairProxy) serve() {
303+ for {
304+ conn, err := p.listener.Accept()
305+ if err != nil {
306+ return
307+ }
308+ if p.track(conn) {
309+ go p.handle(conn)
310+ }
311+ }
312+}
313+
314+func (p *repairProxy) handle(client net.Conn) {
315+ defer p.release(client)
316+ reader := bufio.NewReader(client)
317+ request, err := http.ReadRequest(reader)
318+ if err != nil {
319+ return
320+ }
321+ if request.Method != http.MethodConnect && request.Method != http.MethodGet && request.Method != http.MethodPost {
322+ return
323+ }
324+ host := request.URL.Hostname()
325+ port := request.URL.Port()
326+ if port == "" {
327+ port = "80"
328+ if request.Method == http.MethodConnect || request.URL.Scheme == "https" {
329+ port = "443"
330+ }
331+ }
332+ if !strings.EqualFold(host, p.targetHost) || port != p.targetPort {
333+ return
334+ }
335+ if p.budget.used.Load() >= p.limit {
336+ return
337+ }
338+ // Dial only the already validated address. The proxy never resolves a
339+ // host supplied by the client and never follows HTTP redirects.
340+ upstream, err := (&net.Dialer{}).DialContext(p.ctx, "tcp", net.JoinHostPort(p.targetIP, p.targetPort))
341+ if err != nil {
342+ return
343+ }
344+ if !p.track(upstream) {
345+ return
346+ }
347+ defer p.release(upstream)
348+ if request.Method == http.MethodConnect {
349+ if _, err := io.WriteString(repairBudgetWriter{p, client}, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
350+ return
351+ }
352+ done := make(chan struct{}, 2)
353+ go func() { _, _ = io.Copy(repairBudgetWriter{p, client}, upstream); done <- struct{}{} }()
354+ // Keep bytes already buffered while reading CONNECT (e.g., a TLS
355+ // handshake sent in the same write as the request).
356+ go func() { _, _ = io.Copy(repairBudgetWriter{p, upstream}, reader); done <- struct{}{} }()
357+ select {
358+ case <-done:
359+ case <-p.ctx.Done():
360+ }
361+ return
362+ }
363+ request.RequestURI = ""
364+ request.Close = true
365+ if err := request.Write(repairBudgetWriter{p, upstream}); err != nil {
366+ return
367+ }
368+ _, _ = io.Copy(repairBudgetWriter{p, client}, upstream)
369+}
370+
371+type repairBudgetWriter struct {
372+ proxy *repairProxy
373+ dst io.Writer
374+}
375+
376+func (w repairBudgetWriter) Write(data []byte) (int, error) {
377+ allowed := w.proxy.budget.take(int64(len(data)), w.proxy.limit)
378+ n, err := w.dst.Write(data[:allowed])
379+ if err == nil && allowed < int64(len(data)) {
380+ err = errRepairByteLimit
381+ }
382+ return n, err
383+}
384diff --git a/internal/gitrelay/pr_budget_proxy_test.go b/internal/gitrelay/pr_budget_proxy_test.go
385new file mode 100644
386index 0000000..4643581
387--- /dev/null
388+++ b/internal/gitrelay/pr_budget_proxy_test.go
389@@ -0,0 +1,155 @@
390+package gitrelay
391+
392+import (
393+ "context"
394+ "crypto/tls"
395+ "io"
396+ "net/http"
397+ "net/http/httptest"
398+ "net/url"
399+ "strings"
400+ "sync/atomic"
401+ "testing"
402+ "time"
403+)
404+
405+func repairProxyClient(t *testing.T, ctx context.Context, target string, budget *byteBudget, limit int64) (*repairProxy, *http.Client) {
406+ t.Helper()
407+ u, err := url.Parse(target)
408+ if err != nil {
409+ t.Fatal(err)
410+ }
411+ proxy, err := newRepairProxy(ctx, u.Hostname(), u.Hostname(), u.Port(), budget, limit)
412+ if err != nil {
413+ t.Fatal(err)
414+ }
415+ t.Cleanup(func() { _ = proxy.Close() })
416+ proxyURL, _ := url.Parse(proxy.URL())
417+ transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // Local test server's self-signed certificate.
418+ t.Cleanup(transport.CloseIdleConnections)
419+ return proxy, &http.Client{Transport: transport, Timeout: 5 * time.Second}
420+}
421+
422+func TestRepairProxyEnforcesSharedResponseBudget(t *testing.T) {
423+ var requests atomic.Int32
424+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
425+ requests.Add(1)
426+ if r.URL.Path == "/small" {
427+ _, _ = io.WriteString(w, "first")
428+ return
429+ }
430+ _, _ = io.WriteString(w, strings.Repeat("x", 8192))
431+ }))
432+ defer server.Close()
433+ budget := &byteBudget{}
434+ _, client := repairProxyClient(t, context.Background(), server.URL, budget, 2048)
435+ response, err := client.Get(server.URL + "/small")
436+ if err != nil {
437+ t.Fatal(err)
438+ }
439+ body, err := io.ReadAll(response.Body)
440+ response.Body.Close()
441+ if err != nil || string(body) != "first" {
442+ t.Fatalf("first response = %q, %v", body, err)
443+ }
444+ before := budget.used.Load()
445+ if before == 0 || before >= 2048 {
446+ t.Fatalf("first request consumed %d bytes", before)
447+ }
448+ response, err = client.Get(server.URL + "/large")
449+ if err == nil {
450+ body, err = io.ReadAll(response.Body)
451+ response.Body.Close()
452+ if len(body) >= 2048 {
453+ t.Fatalf("oversized response leaked %d bytes", len(body))
454+ }
455+ }
456+ if err == nil {
457+ t.Fatal("oversized response completed")
458+ }
459+ if budget.used.Load() != 2048 {
460+ t.Fatalf("budget = %d, want 2048", budget.used.Load())
461+ }
462+ if requests.Load() != 2 {
463+ t.Fatalf("upstream requests = %d, want two", requests.Load())
464+ }
465+ if response, err = client.Get(server.URL + "/small"); err == nil {
466+ response.Body.Close()
467+ t.Fatal("request passed after shared budget exhausted")
468+ }
469+ if requests.Load() != 2 {
470+ t.Fatal("exhausted proxy contacted upstream")
471+ }
472+}
473+
474+func TestRepairProxyLimitsHTTPSTunnel(t *testing.T) {
475+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
476+ _, _ = io.WriteString(w, strings.Repeat("x", 256<<10))
477+ }))
478+ defer server.Close()
479+ budget := &byteBudget{}
480+ _, client := repairProxyClient(t, context.Background(), server.URL, budget, 64<<10)
481+ response, err := client.Get(server.URL)
482+ if err == nil {
483+ body, bodyErr := io.ReadAll(response.Body)
484+ response.Body.Close()
485+ if len(body) >= 64<<10 {
486+ t.Fatalf("HTTPS body exceeded wire budget: %d", len(body))
487+ }
488+ err = bodyErr
489+ }
490+ if err == nil {
491+ t.Fatal("oversized HTTPS body completed")
492+ }
493+ if budget.used.Load() != 64<<10 {
494+ t.Fatalf("HTTPS budget = %d", budget.used.Load())
495+ }
496+}
497+
498+func TestRepairProxyCancellationInterruptsStalledUpstream(t *testing.T) {
499+ started := make(chan struct{})
500+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
501+ close(started)
502+ <-r.Context().Done()
503+ }))
504+ defer server.Close()
505+ ctx, cancel := context.WithCancel(context.Background())
506+ defer cancel()
507+ _, client := repairProxyClient(t, ctx, server.URL, &byteBudget{}, 4096)
508+ done := make(chan error, 1)
509+ go func() {
510+ response, err := client.Get(server.URL)
511+ if response != nil {
512+ response.Body.Close()
513+ }
514+ done <- err
515+ }()
516+ select {
517+ case <-started:
518+ case <-time.After(5 * time.Second):
519+ t.Fatal("upstream did not receive request")
520+ }
521+ cancel()
522+ select {
523+ case err := <-done:
524+ if err == nil {
525+ t.Fatal("canceled request succeeded")
526+ }
527+ case <-time.After(time.Second):
528+ t.Fatal("cancel did not interrupt stalled request")
529+ }
530+}
531+
532+func TestRepairProxyRejectsAnotherPort(t *testing.T) {
533+ var contacted atomic.Bool
534+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { contacted.Store(true) }))
535+ defer server.Close()
536+ _, client := repairProxyClient(t, context.Background(), "http://127.0.0.1:1", &byteBudget{}, 4096)
537+ if response, err := client.Get(server.URL); err == nil {
538+ response.Body.Close()
539+ t.Fatal("proxy accepted a different port")
540+ }
541+ if contacted.Load() {
542+ t.Fatal("proxy contacted a different target")
543+ }
544+}
545diff --git a/internal/gitrelay/pr_repair.go b/internal/gitrelay/pr_repair.go
546index 7403d68..b4e049b 100644
547--- a/internal/gitrelay/pr_repair.go
548+++ b/internal/gitrelay/pr_repair.go
549@@ -1,13 +1,27 @@
550 package gitrelay
551
552 import (
553+ "bufio"
554 "context"
555 "errors"
556 "fmt"
557+ "os"
558+ "os/exec"
559+ "path/filepath"
560+ "strings"
561+ "time"
562
563 "github.com/FelineStateMachine/tinyrelay/internal/event"
564 )
565
566+const (
567+ // PR repair is fed by clone URLs carried in signed events. Keep its
568+ // temporary object graph finite while leaving ordinary branch repair
569+ // compatible with complete repositories.
570+ prRepairDepth = 128
571+ prRepairMaxBytes = 256 << 20
572+)
573+
574 // PullRequestRepair groups a PR with its later tip updates. The event store
575 // remains the authority for these events; this value only describes the Git
576 // objects that GRASP-02 should fetch for them.
577@@ -33,6 +47,7 @@ func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Reposito
578 // contribute temporary refs and clone sources.
579 root = hosted
580 root.Alternative = false
581+ root.EventID = set.Root.ID
582 sources := cloneURLs(set.Root)
583 refs := map[string]string{"refs/nostr/" + set.Root.ID: event.Tag(set.Root, "c")}
584 for _, update := range set.Updates {
585@@ -50,12 +65,159 @@ func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Reposito
586 return errors.New("GRASP-02: pull request has no clone source")
587 }
588 sources = uniqueStrings(sources)
589- if err := g.FetchMissing(ctx, root, sources, refs); err != nil {
590+ if err := g.stagePullRequestObjects(ctx, root, sources, refs); err != nil {
591 return fmt.Errorf("GRASP-02 pull request repair: %w", err)
592 }
593 return nil
594 }
595
596+// stagePullRequestObjects admits an untrusted PR clone into a quarantine bare
597+// repository first. Only after every expected object is present do we import
598+// the temporary refs into the hosted repository and install the signed refs.
599+// The quarantine is removed on return, and failed repairs never alter hosted
600+// refs. In particular, its shallow boundary is never copied to the host.
601+func (g *GitRelay) stagePullRequestObjects(ctx context.Context, hosted Repository, sources []string, expected map[string]string) error {
602+ if err := g.ensureRepo(hosted); err != nil {
603+ return err
604+ }
605+ // Keep the quarantine outside the hosted repository namespace. A clone URL
606+ // is signed input and can be processed concurrently with another repair.
607+ quarantineRoot, err := os.MkdirTemp("", "tinyrelay-pr-repair-")
608+ if err != nil {
609+ return fmt.Errorf("create PR repair quarantine: %w", err)
610+ }
611+ defer os.RemoveAll(quarantineRoot)
612+ stage := Repository{Owner: hosted.Owner, Identifier: "objects", Private: hosted.Private}
613+ g.mu.RLock()
614+ stageRelay := &GitRelay{
615+ root: quarantineRoot, policy: g.policy, serviceURL: g.serviceURL,
616+ allowPrivate: g.allowPrivate, privatePeers: append([]string(nil), g.privatePeers...),
617+ httpAuth: g.httpAuth, configured: make(map[string]struct{}),
618+ }
619+ g.mu.RUnlock()
620+ if err := stageRelay.ensureRepo(stage); err != nil {
621+ return fmt.Errorf("create PR repair quarantine: %w", err)
622+ }
623+ stagePath := stageRelay.repoPath(stage)
624+ // Let a PR build on objects already admitted to the hosted repository, but
625+ // never copy the quarantine's shallow boundary into that repository.
626+ alternates := filepath.Join(stagePath, "objects", "info", "alternates")
627+ hostObjects, err := filepath.Abs(filepath.Join(g.repoPath(hosted), "objects"))
628+ if err != nil {
629+ return fmt.Errorf("locate hosted PR base objects: %w", err)
630+ }
631+ if err := os.WriteFile(alternates, []byte(hostObjects+"\n"), 0600); err != nil {
632+ return fmt.Errorf("configure PR repair quarantine: %w", err)
633+ }
634+ if err := stageRelay.fetchMissing(ctx, stage, sources, expected, prRepairDepth, prRepairMaxBytes); err != nil {
635+ return err
636+ }
637+ if err := validatePRQuarantine(ctx, stagePath, nonEmptyObjectIDs(expected)); err != nil {
638+ return err
639+ }
640+
641+ // Import into disposable refs. If a later refspec fails, clean them up and
642+ // leave the signed hosted refs untouched.
643+ cleanup := make([]string, 0, len(expected))
644+ defer func() {
645+ if len(cleanup) == 0 {
646+ return
647+ }
648+ var commands strings.Builder
649+ for _, ref := range cleanup {
650+ fmt.Fprintf(&commands, "delete %s\n", ref)
651+ }
652+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
653+ defer cancel()
654+ cmd := exec.CommandContext(cleanupCtx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
655+ cmd.Stdin = strings.NewReader(commands.String())
656+ _ = cmd.Run()
657+ }()
658+ for ref, oid := range expected {
659+ if oid == "" {
660+ continue
661+ }
662+ staged := "refs/tinyrelay/pr-repair/" + filepath.Base(quarantineRoot) + "/" + safeRef(ref)
663+ cleanup = append(cleanup, staged)
664+ cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "fetch", "--no-tags", stagePath, "+"+oid+":"+staged)
665+ cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
666+ if out, err := cmd.CombinedOutput(); err != nil {
667+ return fmt.Errorf("import PR repair object %s: %w: %s", oid, err, strings.TrimSpace(string(out)))
668+ }
669+ }
670+ updates := make([]string, 0, len(expected))
671+ for ref, oid := range expected {
672+ if oid != "" {
673+ updates = append(updates, "update "+ref+" "+oid+"\n")
674+ }
675+ }
676+ cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")
677+ cmd.Stdin = strings.NewReader("start\n" + strings.Join(updates, "") + "prepare\ncommit\n")
678+ if out, err := cmd.CombinedOutput(); err != nil {
679+ return fmt.Errorf("promote PR repair refs: %w: %s", err, strings.TrimSpace(string(out)))
680+ }
681+ return nil
682+}
683+
684+// validatePRQuarantine checks the complete object graph after removing the
685+// shallow marker. rev-list reports missing commits, trees and blobs through
686+// its --missing output; alternates make hosted base objects visible here.
687+func validatePRQuarantine(ctx context.Context, gitDir string, oids []string) error {
688+ if len(oids) == 0 {
689+ return errors.New("PR repair has no expected objects")
690+ }
691+ if err := os.Remove(filepath.Join(gitDir, "shallow")); err != nil && !errors.Is(err, os.ErrNotExist) {
692+ return fmt.Errorf("remove PR repair shallow boundary: %w", err)
693+ }
694+ args := append([]string{"--git-dir", gitDir, "rev-list", "--objects", "--missing=print"}, oids...)
695+ cmd := exec.CommandContext(ctx, "git", args...)
696+ stdout, err := cmd.StdoutPipe()
697+ if err != nil {
698+ return fmt.Errorf("validate PR repair object graph: create pipe: %w", err)
699+ }
700+ if err := cmd.Start(); err != nil {
701+ return fmt.Errorf("validate PR repair object graph: %w", err)
702+ }
703+ scanner := bufio.NewScanner(stdout)
704+ scanner.Buffer(make([]byte, 4096), 1<<20)
705+ for scanner.Scan() {
706+ line := scanner.Text()
707+ if strings.HasPrefix(line, "?") {
708+ _ = cmd.Process.Kill()
709+ _ = cmd.Wait()
710+ return fmt.Errorf("PR repair object graph is incomplete: %s", strings.TrimSpace(line))
711+ }
712+ }
713+ if err := scanner.Err(); err != nil {
714+ _ = cmd.Process.Kill()
715+ _ = cmd.Wait()
716+ return fmt.Errorf("validate PR repair object graph: read output: %w", err)
717+ }
718+ if err := cmd.Wait(); err != nil {
719+ return fmt.Errorf("validate PR repair object graph: %w", err)
720+ }
721+ // Ensure each advertised tip is an actual commit, rather than accepting a
722+ // tree or blob object under a commit-shaped event tag.
723+ for _, oid := range oids {
724+ check := exec.CommandContext(ctx, "git", "--git-dir", gitDir, "cat-file", "-t", oid)
725+ kind, err := check.Output()
726+ if err != nil || strings.TrimSpace(string(kind)) != "commit" {
727+ return fmt.Errorf("PR repair tip %s is not a complete commit", oid)
728+ }
729+ }
730+ return nil
731+}
732+
733+func nonEmptyObjectIDs(refs map[string]string) []string {
734+ ids := make([]string, 0, len(refs))
735+ for _, oid := range refs {
736+ if oid != "" {
737+ ids = append(ids, oid)
738+ }
739+ }
740+ return ids
741+}
742+
743 func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {
744 want := "30617:" + repo.Owner + ":" + repo.Identifier
745 for _, tag := range pr.Tags {
746diff --git a/internal/gitrelay/pr_repair_test.go b/internal/gitrelay/pr_repair_test.go
747index 4e05e04..65982b4 100644
748--- a/internal/gitrelay/pr_repair_test.go
749+++ b/internal/gitrelay/pr_repair_test.go
750@@ -4,6 +4,7 @@ import (
751 "context"
752 "crypto/x509"
753 "encoding/pem"
754+ "fmt"
755 "net/http"
756 "net/http/httptest"
757 "os"
758@@ -145,6 +146,132 @@ func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {
759 if _, err := os.Stat(filepath.Join(gitDir, "tinyrelay.refs")); !os.IsNotExist(err) {
760 t.Fatalf("PR repair wrote signed state file: %v", err)
761 }
762+ quarantines, err := filepath.Glob(filepath.Join(rootDir, "git", "prs", owner, "repo.repair-"+root.ID+"-*.git"))
763+ if err != nil {
764+ t.Fatal(err)
765+ }
766+ if len(quarantines) != 0 {
767+ t.Fatalf("PR repair quarantine was not removed: %v", quarantines)
768+ }
769+ blockedRoot := root
770+ blockedRoot.CreatedAt = 3
771+ if err := event.Sign(&blockedRoot, secret); err != nil {
772+ t.Fatal(err)
773+ }
774+ blockedUpdate := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 4, Tags: [][]string{{"E", blockedRoot.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}
775+ if err := event.Sign(&blockedUpdate, secret); err != nil {
776+ t.Fatal(err)
777+ }
778+ // A ref-directory collision makes one update fail. The other signed ref
779+ // must not appear, regardless of map iteration or transaction order.
780+ blocker := "refs/nostr/" + blockedRoot.ID + "/child"
781+ prGitRun(t, "--git-dir", gitDir, "update-ref", blocker, first)
782+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: blockedRoot, Updates: []event.Event{blockedUpdate}}); err == nil {
783+ t.Fatal("repair ignored conflicting ref")
784+ }
785+ if err := exec.Command("git", "--git-dir", gitDir, "show-ref", "--verify", "--quiet", "refs/nostr/"+blockedUpdate.ID).Run(); err == nil {
786+ t.Fatal("failed repair partially promoted its signed refs")
787+ }
788+ if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", blocker)); got != first {
789+ t.Fatalf("repair changed blocking ref: %s", got)
790+ }
791+ if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/tinyrelay/pr-repair/"); strings.TrimSpace(out) != "" {
792+ t.Fatalf("failed repair left temporary refs: %s", out)
793+ }
794+}
795+
796+func TestPullRequestRepairFailureLeavesHostedRefsUnchanged(t *testing.T) {
797+ secret := strings.Repeat("0", 63) + "1"
798+ owner, err := event.PublicKey(secret)
799+ if err != nil {
800+ t.Fatal(err)
801+ }
802+ rootDir := t.TempDir()
803+ hosted := Repository{Owner: owner, Identifier: "repo", Refs: map[string]string{
804+ "refs/heads/main": strings.Repeat("a", 40),
805+ }}
806+ g := &GitRelay{root: rootDir, allowPrivate: true, configured: make(map[string]struct{})}
807+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, PubKey: owner,
808+ Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", strings.Repeat("b", 40)}, {"clone", "http://127.0.0.1:1/repo.git"}}}
809+ if err := event.Sign(&root, secret); err != nil {
810+ t.Fatal(err)
811+ }
812+ err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})
813+ if err == nil {
814+ t.Fatal("repair unexpectedly succeeded from unavailable source")
815+ }
816+ if _, err := os.Stat(g.repoPath(hosted)); !os.IsNotExist(err) {
817+ // ensureRepo is allowed to create the host, but no signed ref may be
818+ // installed after a failed quarantine admission.
819+ got, _ := exec.Command("git", "--git-dir", g.repoPath(hosted), "show-ref", "refs/nostr/"+root.ID).CombinedOutput()
820+ if len(got) != 0 {
821+ t.Fatalf("failed repair installed hosted PR ref: %s", got)
822+ }
823+ }
824+}
825+
826+func TestPRQuarantineRejectsHistoryBeyondDepth(t *testing.T) {
827+ root := t.TempDir()
828+ work := filepath.Join(root, "work")
829+ remote := filepath.Join(root, "remote.git")
830+ prGitRun(t, "init", work)
831+ prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")
832+ prGitRun(t, "-C", work, "config", "user.name", "PR")
833+ for i := 0; i < prRepairDepth+1; i++ {
834+ if err := os.WriteFile(filepath.Join(work, "history"), []byte(fmt.Sprintf("%d\n", i)), 0600); err != nil {
835+ t.Fatal(err)
836+ }
837+ prGitRun(t, "-C", work, "add", "history")
838+ prGitRun(t, "-C", work, "commit", "-m", fmt.Sprintf("commit %d", i))
839+ }
840+ tip := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))
841+ base := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD~1"))
842+ prGitRun(t, "init", "--bare", remote)
843+ prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")
844+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, root) }))
845+ t.Cleanup(server.Close)
846+ ca := filepath.Join(root, "ca.pem")
847+ if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
848+ t.Fatal(err)
849+ }
850+ t.Setenv("GIT_SSL_CAINFO", ca)
851+ secret := strings.Repeat("0", 63) + "1"
852+ owner, err := event.PublicKey(secret)
853+ if err != nil {
854+ t.Fatal(err)
855+ }
856+ hosted := Repository{Owner: owner, Identifier: "repo"}
857+ g := &GitRelay{root: filepath.Join(root, "hosted"), allowPrivate: true, configured: make(map[string]struct{})}
858+ pr := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", tip}, {"clone", server.URL + "/remote.git"}}}
859+ if err := event.Sign(&pr, secret); err != nil {
860+ t.Fatal(err)
861+ }
862+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err == nil || !strings.Contains(err.Error(), "object graph") {
863+ t.Fatalf("expected incomplete object graph rejection, got %v", err)
864+ }
865+ if g.objectsPresent(context.Background(), hosted, []string{tip}) {
866+ t.Fatal("rejected objects reached the hosted repository")
867+ }
868+ gitDir := g.repoPath(hosted)
869+ if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {
870+ t.Fatalf("hosted shallow boundary changed: %v", err)
871+ }
872+ if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/nostr/"); strings.TrimSpace(out) != "" {
873+ t.Fatalf("rejected PR installed refs: %s", out)
874+ }
875+ // Once the existing base is hosted, the exact same PR must succeed even
876+ // though its complete ancestry extends beyond the remote depth window.
877+ prGitRun(t, "--git-dir", gitDir, "fetch", work, base+":refs/heads/main")
878+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err != nil {
879+ t.Fatalf("PR with hosted base failed: %v", err)
880+ }
881+ if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", "refs/nostr/"+pr.ID)); got != tip {
882+ t.Fatalf("PR tip = %s, want %s", got, tip)
883+ }
884+ prGitRun(t, "--git-dir", gitDir, "fsck", "--full", "--no-dangling")
885+ if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {
886+ t.Fatalf("successful repair made host shallow: %v", err)
887+ }
888 }
889
890 func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {

About

ownerc01503e8035f
visibilitypublic
head3a631053c493

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs