- commit
- 3a631053c493e479e8b93530165e1c0835f3bbe3
- parent
- ee42dfd3d47999d1d66cc9fa1776b1cb5458ceea
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 132 | +++++++++++++++++++++++- |
| A | internal/ | 171 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 155 | +++++++++++++++++++++++++++ |
| M | internal/ | 164 | +++++++++++++++++++++++++++++- |
| M | internal/ | 127 | ++++++++++++++++++++++ |
@@ -1233,6 +1233,15 @@ func privateIP(ip net.IP) bool {
12331233 // verifies pack checksums and object connectivity; refs are installed only12341234 // after every expected object exists locally.12351235 func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string) error {1236+ return g.fetchMissing(ctx, r, sources, expected, 0, 0)1237+}1238+1239+// fetchMissing is shared by ordinary branch repair and the more restrictive1240+// PR repair path. A non-zero depth keeps an untrusted PR clone from making the1241+// hosted repository walk arbitrary history. maxBytes bounds transfer traffic1242+// across all sources and the isolated destination after each fetch. These1243+// limits apply only to isolated repositories, never the hosted branch repo.1244+func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string, depth int, maxBytes int64) error {12361245 if err := g.ensureRepo(r); err != nil {12371246 return err12381247 }@@ -1242,6 +1251,10 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12421251 return g.installFetchedRefs(r, expected)12431252 }12441253 missing := make(map[string]struct{}, len(expected))1254+ var budget *byteBudget1255+ if maxBytes > 0 {1256+ budget = &byteBudget{}1257+ }12451258 var lastErr error12461259 for ref, oid := range expected {12471260 if oid != "" {@@ -1249,6 +1262,15 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12491262 }12501263 }12511264 for _, raw := range sources {1265+ if err := ctx.Err(); err != nil {1266+ return err1267+ }1268+ if budget != nil && len(missing) == 0 {1269+ break1270+ }1271+ if budget != nil && budget.used.Load() >= maxBytes {1272+ return errRepairByteLimit1273+ }12521274 // Private object IDs must never be requested from a public source.12531275 private := r.Private || (g.policy != nil && g.policy().Reads == "members")12541276 if private && !g.privatePeer(raw) {@@ -1257,6 +1279,7 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12571279 }12581280 source, ip, err := g.resolvedSource(ctx, raw)12591281 if err != nil {1282+ lastErr = err12601283 continue12611284 }12621285 var proxy *privateProxy@@ -1273,18 +1296,61 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12731296 }12741297 defer proxy.Close(context.Background())12751298 }1299+ fetchSource := source1300+ if proxy != nil {1301+ fetchSource = proxy.URL()1302+ }1303+ var budgetProxy *repairProxy1304+ if budget != nil {1305+ targetIP := ip1306+ if proxy != nil {1307+ if parsed, parseErr := url.Parse(proxy.URL()); parseErr == nil {1308+ targetIP = parsed.Hostname()1309+ }1310+ }1311+ parsedSource, parseErr := url.Parse(fetchSource)1312+ if parseErr != nil {1313+ lastErr = parseErr1314+ continue1315+ }1316+ targetHost := parsedSource.Hostname()1317+ targetPort := parsedSource.Port()1318+ if targetPort == "" {1319+ targetPort = "443"1320+ if parsedSource.Scheme == "http" {1321+ targetPort = "80"1322+ }1323+ }1324+ budgetProxy, err = newRepairProxy(ctx, targetIP, targetHost, targetPort, budget, maxBytes)1325+ if err != nil {1326+ lastErr = err1327+ continue1328+ }1329+ defer budgetProxy.Close()1330+ }12761331 for ref, oid := range expected {1332+ if err := ctx.Err(); err != nil {1333+ return err1334+ }1335+ if budget != nil && budget.used.Load() >= maxBytes {1336+ return errRepairByteLimit1337+ }1338+ if _, needed := missing[ref]; budget != nil && !needed {1339+ continue1340+ }12771341 if oid == "" {12781342 delete(missing, ref)12791343 continue12801344 }12811345 port := uPort(source)1282- fetchSource := source12831346 resolve := []string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}12841347 if proxy != nil {12851348 fetchSource = proxy.URL()12861349 resolve = nil12871350 }1351+ if budgetProxy != nil {1352+ resolve = nil1353+ }12881354 fetchRef := ref12891355 if strings.HasPrefix(ref, "refs/nostr/") {12901356 // NIP-34 clone URLs may point to ordinary Git hosts. The@@ -1294,18 +1360,53 @@ func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []str
12941360 }12951361 fetchRef = oid12961362 }1297- 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)}1363+ proxySetting := ""1364+ if budgetProxy != nil {1365+ proxySetting = budgetProxy.URL()1366+ }1367+ args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=" + proxySetting, "--git-dir", g.repoPath(r), "fetch", "--no-tags"}1368+ if depth > 0 {1369+ args = append(args, "--depth", strconv.Itoa(depth))1370+ }1371+ args = append(args, fetchSource, "+"+fetchRef+":"+"refs/tinyrelay/source/"+safeRef(ref))12981372 // Keep the resolve option before the subcommand; Git accepts config12991373 // options only before the command name.13001374 if len(resolve) > 0 {13011375 args = append([]string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}, args...)13021376 }13031377 cmd := exec.CommandContext(ctx, "git", args...)1304- cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "http_proxy=", "https_proxy=", "all_proxy=", "GIT_CONFIG_NOSYSTEM=1")1378+ if depth > 0 && maxBytes > 0 {1379+ // Git receives pack data in a child process and does not expose a1380+ // portable receive-byte limit. Apply the shell's file-size limit1381+ // to the isolated fetch process so an oversized pack is stopped1382+ // while it is being written. The unit is 512 bytes in dash and1383+ // 1024 in bash, so this is a coarse guard; the proxy budget and1384+ // the cumulative size check below enforce the exact bound.1385+ limitBlocks := (maxBytes + 511) / 5121386+ limited := []string{"ulimit -f \"$1\"; shift; exec \"$@\"", "gitfetch", strconv.FormatInt(limitBlocks, 10), "git"}1387+ limited = append(limited, args...)1388+ cmd = exec.CommandContext(ctx, "sh", append([]string{"-c"}, limited...)...)1389+ }1390+ 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")1391+ if budget != nil {1392+ // A URL-specific proxy or rewrite in the user's Git config must1393+ // not bypass the bounded transport selected for this repair.1394+ cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull)1395+ }13051396 if out, err := cmd.CombinedOutput(); err != nil {13061397 lastErr = fmt.Errorf("fetch %s: %w: %s", fetchSource, err, strings.TrimSpace(string(out)))13071398 continue13081399 }1400+ if maxBytes > 0 {1401+ size, err := gitDirSize(g.repoPath(r))1402+ if err != nil {1403+ lastErr = fmt.Errorf("inspect isolated Git repository: %w", err)1404+ continue1405+ }1406+ if size > maxBytes {1407+ return fmt.Errorf("Git source exceeded isolated repair budget: %d bytes", size)1408+ }1409+ }13091410 if !g.objectsPresent(ctx, r, []string{oid}) {13101411 continue13111412 }@@ -1356,6 +1457,31 @@ func safeRef(ref string) string {
13561457 return strings.NewReplacer("/", "_", "\\", "_").Replace(strings.TrimPrefix(ref, "refs/"))13571458 }13581459 1460+func gitDirSize(root string) (int64, error) {1461+ var total int641462+ err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {1463+ if err != nil {1464+ if os.IsNotExist(err) {1465+ return nil1466+ }1467+ return err1468+ }1469+ if entry.IsDir() {1470+ return nil1471+ }1472+ info, err := entry.Info()1473+ if err != nil {1474+ if os.IsNotExist(err) {1475+ return nil1476+ }1477+ return err1478+ }1479+ total += info.Size()1480+ return nil1481+ })1482+ return total, err1483+}1484+13591485 func (g *GitRelay) updateRef(r Repository, ref, oid string) error {13601486 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", ref, oid)13611487 if out, err := cmd.CombinedOutput(); err != nil {@@ -0,0 +1,171 @@
1+package gitrelay2+3+import (4+ "bufio"5+ "context"6+ "errors"7+ "io"8+ "net"9+ "net/http"10+ "strings"11+ "sync"12+ "sync/atomic"13+)14+15+var errRepairByteLimit = errors.New("PR repair transfer exceeds byte limit")16+17+// A single budget covers every connection, ref and source in one repair.18+// Counting tunneled TLS bytes also bounds HTTPS without terminating its TLS.19+type byteBudget struct{ used atomic.Int64 }20+21+func (b *byteBudget) take(wanted, limit int64) int64 {22+ for {23+ old := b.used.Load()24+ allowed := min(wanted, max(0, limit-old))25+ if b.used.CompareAndSwap(old, old+allowed) {26+ return allowed27+ }28+ }29+}30+31+type repairProxy struct {32+ listener net.Listener33+ ctx context.Context34+ cancel context.CancelFunc35+ budget *byteBudget36+ limit int6437+ targetIP string38+ targetHost string39+ targetPort string40+ mu sync.Mutex41+ connections map[net.Conn]struct{}42+ closeOnce sync.Once43+}44+45+func newRepairProxy(ctx context.Context, targetIP, targetHost, targetPort string, budget *byteBudget, limit int64) (*repairProxy, error) {46+ listener, err := net.Listen("tcp", "127.0.0.1:0")47+ if err != nil {48+ return nil, err49+ }50+ proxyCtx, cancel := context.WithCancel(ctx)51+ p := &repairProxy{listener: listener, ctx: proxyCtx, cancel: cancel, budget: budget, limit: limit, targetIP: targetIP, targetHost: targetHost, targetPort: targetPort, connections: make(map[net.Conn]struct{})}52+ context.AfterFunc(proxyCtx, func() { _ = p.Close() })53+ go p.serve()54+ return p, nil55+}56+57+func (p *repairProxy) URL() string { return "http://" + p.listener.Addr().String() }58+59+func (p *repairProxy) Close() error {60+ p.closeOnce.Do(func() {61+ p.cancel()62+ _ = p.listener.Close()63+ p.mu.Lock()64+ defer p.mu.Unlock()65+ for conn := range p.connections {66+ _ = conn.Close()67+ }68+ })69+ return nil70+}71+72+func (p *repairProxy) track(conn net.Conn) bool {73+ p.mu.Lock()74+ defer p.mu.Unlock()75+ if p.ctx.Err() != nil {76+ _ = conn.Close()77+ return false78+ }79+ p.connections[conn] = struct{}{}80+ return true81+}82+83+func (p *repairProxy) release(conn net.Conn) {84+ _ = conn.Close()85+ p.mu.Lock()86+ delete(p.connections, conn)87+ p.mu.Unlock()88+}89+90+func (p *repairProxy) serve() {91+ for {92+ conn, err := p.listener.Accept()93+ if err != nil {94+ return95+ }96+ if p.track(conn) {97+ go p.handle(conn)98+ }99+ }100+}101+102+func (p *repairProxy) handle(client net.Conn) {103+ defer p.release(client)104+ reader := bufio.NewReader(client)105+ request, err := http.ReadRequest(reader)106+ if err != nil {107+ return108+ }109+ if request.Method != http.MethodConnect && request.Method != http.MethodGet && request.Method != http.MethodPost {110+ return111+ }112+ host := request.URL.Hostname()113+ port := request.URL.Port()114+ if port == "" {115+ port = "80"116+ if request.Method == http.MethodConnect || request.URL.Scheme == "https" {117+ port = "443"118+ }119+ }120+ if !strings.EqualFold(host, p.targetHost) || port != p.targetPort {121+ return122+ }123+ if p.budget.used.Load() >= p.limit {124+ return125+ }126+ // Dial only the already validated address. The proxy never resolves a127+ // host supplied by the client and never follows HTTP redirects.128+ upstream, err := (&net.Dialer{}).DialContext(p.ctx, "tcp", net.JoinHostPort(p.targetIP, p.targetPort))129+ if err != nil {130+ return131+ }132+ if !p.track(upstream) {133+ return134+ }135+ defer p.release(upstream)136+ if request.Method == http.MethodConnect {137+ if _, err := io.WriteString(repairBudgetWriter{p, client}, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {138+ return139+ }140+ done := make(chan struct{}, 2)141+ go func() { _, _ = io.Copy(repairBudgetWriter{p, client}, upstream); done <- struct{}{} }()142+ // Keep bytes already buffered while reading CONNECT (e.g., a TLS143+ // handshake sent in the same write as the request).144+ go func() { _, _ = io.Copy(repairBudgetWriter{p, upstream}, reader); done <- struct{}{} }()145+ select {146+ case <-done:147+ case <-p.ctx.Done():148+ }149+ return150+ }151+ request.RequestURI = ""152+ request.Close = true153+ if err := request.Write(repairBudgetWriter{p, upstream}); err != nil {154+ return155+ }156+ _, _ = io.Copy(repairBudgetWriter{p, client}, upstream)157+}158+159+type repairBudgetWriter struct {160+ proxy *repairProxy161+ dst io.Writer162+}163+164+func (w repairBudgetWriter) Write(data []byte) (int, error) {165+ allowed := w.proxy.budget.take(int64(len(data)), w.proxy.limit)166+ n, err := w.dst.Write(data[:allowed])167+ if err == nil && allowed < int64(len(data)) {168+ err = errRepairByteLimit169+ }170+ return n, err171+}@@ -0,0 +1,155 @@
1+package gitrelay2+3+import (4+ "context"5+ "crypto/tls"6+ "io"7+ "net/http"8+ "net/http/httptest"9+ "net/url"10+ "strings"11+ "sync/atomic"12+ "testing"13+ "time"14+)15+16+func repairProxyClient(t *testing.T, ctx context.Context, target string, budget *byteBudget, limit int64) (*repairProxy, *http.Client) {17+ t.Helper()18+ u, err := url.Parse(target)19+ if err != nil {20+ t.Fatal(err)21+ }22+ proxy, err := newRepairProxy(ctx, u.Hostname(), u.Hostname(), u.Port(), budget, limit)23+ if err != nil {24+ t.Fatal(err)25+ }26+ t.Cleanup(func() { _ = proxy.Close() })27+ proxyURL, _ := url.Parse(proxy.URL())28+ transport := &http.Transport{Proxy: http.ProxyURL(proxyURL), TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // Local test server's self-signed certificate.29+ t.Cleanup(transport.CloseIdleConnections)30+ return proxy, &http.Client{Transport: transport, Timeout: 5 * time.Second}31+}32+33+func TestRepairProxyEnforcesSharedResponseBudget(t *testing.T) {34+ var requests atomic.Int3235+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {36+ requests.Add(1)37+ if r.URL.Path == "/small" {38+ _, _ = io.WriteString(w, "first")39+ return40+ }41+ _, _ = io.WriteString(w, strings.Repeat("x", 8192))42+ }))43+ defer server.Close()44+ budget := &byteBudget{}45+ _, client := repairProxyClient(t, context.Background(), server.URL, budget, 2048)46+ response, err := client.Get(server.URL + "/small")47+ if err != nil {48+ t.Fatal(err)49+ }50+ body, err := io.ReadAll(response.Body)51+ response.Body.Close()52+ if err != nil || string(body) != "first" {53+ t.Fatalf("first response = %q, %v", body, err)54+ }55+ before := budget.used.Load()56+ if before == 0 || before >= 2048 {57+ t.Fatalf("first request consumed %d bytes", before)58+ }59+ response, err = client.Get(server.URL + "/large")60+ if err == nil {61+ body, err = io.ReadAll(response.Body)62+ response.Body.Close()63+ if len(body) >= 2048 {64+ t.Fatalf("oversized response leaked %d bytes", len(body))65+ }66+ }67+ if err == nil {68+ t.Fatal("oversized response completed")69+ }70+ if budget.used.Load() != 2048 {71+ t.Fatalf("budget = %d, want 2048", budget.used.Load())72+ }73+ if requests.Load() != 2 {74+ t.Fatalf("upstream requests = %d, want two", requests.Load())75+ }76+ if response, err = client.Get(server.URL + "/small"); err == nil {77+ response.Body.Close()78+ t.Fatal("request passed after shared budget exhausted")79+ }80+ if requests.Load() != 2 {81+ t.Fatal("exhausted proxy contacted upstream")82+ }83+}84+85+func TestRepairProxyLimitsHTTPSTunnel(t *testing.T) {86+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {87+ _, _ = io.WriteString(w, strings.Repeat("x", 256<<10))88+ }))89+ defer server.Close()90+ budget := &byteBudget{}91+ _, client := repairProxyClient(t, context.Background(), server.URL, budget, 64<<10)92+ response, err := client.Get(server.URL)93+ if err == nil {94+ body, bodyErr := io.ReadAll(response.Body)95+ response.Body.Close()96+ if len(body) >= 64<<10 {97+ t.Fatalf("HTTPS body exceeded wire budget: %d", len(body))98+ }99+ err = bodyErr100+ }101+ if err == nil {102+ t.Fatal("oversized HTTPS body completed")103+ }104+ if budget.used.Load() != 64<<10 {105+ t.Fatalf("HTTPS budget = %d", budget.used.Load())106+ }107+}108+109+func TestRepairProxyCancellationInterruptsStalledUpstream(t *testing.T) {110+ started := make(chan struct{})111+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {112+ close(started)113+ <-r.Context().Done()114+ }))115+ defer server.Close()116+ ctx, cancel := context.WithCancel(context.Background())117+ defer cancel()118+ _, client := repairProxyClient(t, ctx, server.URL, &byteBudget{}, 4096)119+ done := make(chan error, 1)120+ go func() {121+ response, err := client.Get(server.URL)122+ if response != nil {123+ response.Body.Close()124+ }125+ done <- err126+ }()127+ select {128+ case <-started:129+ case <-time.After(5 * time.Second):130+ t.Fatal("upstream did not receive request")131+ }132+ cancel()133+ select {134+ case err := <-done:135+ if err == nil {136+ t.Fatal("canceled request succeeded")137+ }138+ case <-time.After(time.Second):139+ t.Fatal("cancel did not interrupt stalled request")140+ }141+}142+143+func TestRepairProxyRejectsAnotherPort(t *testing.T) {144+ var contacted atomic.Bool145+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { contacted.Store(true) }))146+ defer server.Close()147+ _, client := repairProxyClient(t, context.Background(), "http://127.0.0.1:1", &byteBudget{}, 4096)148+ if response, err := client.Get(server.URL); err == nil {149+ response.Body.Close()150+ t.Fatal("proxy accepted a different port")151+ }152+ if contacted.Load() {153+ t.Fatal("proxy contacted a different target")154+ }155+}@@ -1,13 +1,27 @@
11 package gitrelay22 33 import (4+ "bufio"45 "context"56 "errors"67 "fmt"8+ "os"9+ "os/exec"10+ "path/filepath"11+ "strings"12+ "time"713 814 "github.com/FelineStateMachine/tinyrelay/internal/event"915 )1016 17+const (18+ // PR repair is fed by clone URLs carried in signed events. Keep its19+ // temporary object graph finite while leaving ordinary branch repair20+ // compatible with complete repositories.21+ prRepairDepth = 12822+ prRepairMaxBytes = 256 << 2023+)24+1125 // PullRequestRepair groups a PR with its later tip updates. The event store1226 // remains the authority for these events; this value only describes the Git1327 // objects that GRASP-02 should fetch for them.@@ -33,6 +47,7 @@ func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Reposito
3347 // contribute temporary refs and clone sources.3448 root = hosted3549 root.Alternative = false50+ root.EventID = set.Root.ID3651 sources := cloneURLs(set.Root)3752 refs := map[string]string{"refs/nostr/" + set.Root.ID: event.Tag(set.Root, "c")}3853 for _, update := range set.Updates {@@ -50,12 +65,159 @@ func (g *GitRelay) RepairPullRequestObjects(ctx context.Context, hosted Reposito
5065 return errors.New("GRASP-02: pull request has no clone source")5166 }5267 sources = uniqueStrings(sources)53- if err := g.FetchMissing(ctx, root, sources, refs); err != nil {68+ if err := g.stagePullRequestObjects(ctx, root, sources, refs); err != nil {5469 return fmt.Errorf("GRASP-02 pull request repair: %w", err)5570 }5671 return nil5772 }5873 74+// stagePullRequestObjects admits an untrusted PR clone into a quarantine bare75+// repository first. Only after every expected object is present do we import76+// the temporary refs into the hosted repository and install the signed refs.77+// The quarantine is removed on return, and failed repairs never alter hosted78+// refs. In particular, its shallow boundary is never copied to the host.79+func (g *GitRelay) stagePullRequestObjects(ctx context.Context, hosted Repository, sources []string, expected map[string]string) error {80+ if err := g.ensureRepo(hosted); err != nil {81+ return err82+ }83+ // Keep the quarantine outside the hosted repository namespace. A clone URL84+ // is signed input and can be processed concurrently with another repair.85+ quarantineRoot, err := os.MkdirTemp("", "tinyrelay-pr-repair-")86+ if err != nil {87+ return fmt.Errorf("create PR repair quarantine: %w", err)88+ }89+ defer os.RemoveAll(quarantineRoot)90+ stage := Repository{Owner: hosted.Owner, Identifier: "objects", Private: hosted.Private}91+ g.mu.RLock()92+ stageRelay := &GitRelay{93+ root: quarantineRoot, policy: g.policy, serviceURL: g.serviceURL,94+ allowPrivate: g.allowPrivate, privatePeers: append([]string(nil), g.privatePeers...),95+ httpAuth: g.httpAuth, configured: make(map[string]struct{}),96+ }97+ g.mu.RUnlock()98+ if err := stageRelay.ensureRepo(stage); err != nil {99+ return fmt.Errorf("create PR repair quarantine: %w", err)100+ }101+ stagePath := stageRelay.repoPath(stage)102+ // Let a PR build on objects already admitted to the hosted repository, but103+ // never copy the quarantine's shallow boundary into that repository.104+ alternates := filepath.Join(stagePath, "objects", "info", "alternates")105+ hostObjects, err := filepath.Abs(filepath.Join(g.repoPath(hosted), "objects"))106+ if err != nil {107+ return fmt.Errorf("locate hosted PR base objects: %w", err)108+ }109+ if err := os.WriteFile(alternates, []byte(hostObjects+"\n"), 0600); err != nil {110+ return fmt.Errorf("configure PR repair quarantine: %w", err)111+ }112+ if err := stageRelay.fetchMissing(ctx, stage, sources, expected, prRepairDepth, prRepairMaxBytes); err != nil {113+ return err114+ }115+ if err := validatePRQuarantine(ctx, stagePath, nonEmptyObjectIDs(expected)); err != nil {116+ return err117+ }118+119+ // Import into disposable refs. If a later refspec fails, clean them up and120+ // leave the signed hosted refs untouched.121+ cleanup := make([]string, 0, len(expected))122+ defer func() {123+ if len(cleanup) == 0 {124+ return125+ }126+ var commands strings.Builder127+ for _, ref := range cleanup {128+ fmt.Fprintf(&commands, "delete %s\n", ref)129+ }130+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)131+ defer cancel()132+ cmd := exec.CommandContext(cleanupCtx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")133+ cmd.Stdin = strings.NewReader(commands.String())134+ _ = cmd.Run()135+ }()136+ for ref, oid := range expected {137+ if oid == "" {138+ continue139+ }140+ staged := "refs/tinyrelay/pr-repair/" + filepath.Base(quarantineRoot) + "/" + safeRef(ref)141+ cleanup = append(cleanup, staged)142+ cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "fetch", "--no-tags", stagePath, "+"+oid+":"+staged)143+ cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")144+ if out, err := cmd.CombinedOutput(); err != nil {145+ return fmt.Errorf("import PR repair object %s: %w: %s", oid, err, strings.TrimSpace(string(out)))146+ }147+ }148+ updates := make([]string, 0, len(expected))149+ for ref, oid := range expected {150+ if oid != "" {151+ updates = append(updates, "update "+ref+" "+oid+"\n")152+ }153+ }154+ cmd := exec.CommandContext(ctx, "git", "--git-dir", g.repoPath(hosted), "update-ref", "--stdin")155+ cmd.Stdin = strings.NewReader("start\n" + strings.Join(updates, "") + "prepare\ncommit\n")156+ if out, err := cmd.CombinedOutput(); err != nil {157+ return fmt.Errorf("promote PR repair refs: %w: %s", err, strings.TrimSpace(string(out)))158+ }159+ return nil160+}161+162+// validatePRQuarantine checks the complete object graph after removing the163+// shallow marker. rev-list reports missing commits, trees and blobs through164+// its --missing output; alternates make hosted base objects visible here.165+func validatePRQuarantine(ctx context.Context, gitDir string, oids []string) error {166+ if len(oids) == 0 {167+ return errors.New("PR repair has no expected objects")168+ }169+ if err := os.Remove(filepath.Join(gitDir, "shallow")); err != nil && !errors.Is(err, os.ErrNotExist) {170+ return fmt.Errorf("remove PR repair shallow boundary: %w", err)171+ }172+ args := append([]string{"--git-dir", gitDir, "rev-list", "--objects", "--missing=print"}, oids...)173+ cmd := exec.CommandContext(ctx, "git", args...)174+ stdout, err := cmd.StdoutPipe()175+ if err != nil {176+ return fmt.Errorf("validate PR repair object graph: create pipe: %w", err)177+ }178+ if err := cmd.Start(); err != nil {179+ return fmt.Errorf("validate PR repair object graph: %w", err)180+ }181+ scanner := bufio.NewScanner(stdout)182+ scanner.Buffer(make([]byte, 4096), 1<<20)183+ for scanner.Scan() {184+ line := scanner.Text()185+ if strings.HasPrefix(line, "?") {186+ _ = cmd.Process.Kill()187+ _ = cmd.Wait()188+ return fmt.Errorf("PR repair object graph is incomplete: %s", strings.TrimSpace(line))189+ }190+ }191+ if err := scanner.Err(); err != nil {192+ _ = cmd.Process.Kill()193+ _ = cmd.Wait()194+ return fmt.Errorf("validate PR repair object graph: read output: %w", err)195+ }196+ if err := cmd.Wait(); err != nil {197+ return fmt.Errorf("validate PR repair object graph: %w", err)198+ }199+ // Ensure each advertised tip is an actual commit, rather than accepting a200+ // tree or blob object under a commit-shaped event tag.201+ for _, oid := range oids {202+ check := exec.CommandContext(ctx, "git", "--git-dir", gitDir, "cat-file", "-t", oid)203+ kind, err := check.Output()204+ if err != nil || strings.TrimSpace(string(kind)) != "commit" {205+ return fmt.Errorf("PR repair tip %s is not a complete commit", oid)206+ }207+ }208+ return nil209+}210+211+func nonEmptyObjectIDs(refs map[string]string) []string {212+ ids := make([]string, 0, len(refs))213+ for _, oid := range refs {214+ if oid != "" {215+ ids = append(ids, oid)216+ }217+ }218+ return ids219+}220+59221 func hasRepositoryCoordinate(pr event.Event, repo Repository) bool {60222 want := "30617:" + repo.Owner + ":" + repo.Identifier61223 for _, tag := range pr.Tags {@@ -4,6 +4,7 @@ import (
44 "context"55 "crypto/x509"66 "encoding/pem"7+ "fmt"78 "net/http"89 "net/http/httptest"910 "os"@@ -145,6 +146,132 @@ func TestPullRequestRepairUsesHostedRepositoryRefs(t *testing.T) {
145146 if _, err := os.Stat(filepath.Join(gitDir, "tinyrelay.refs")); !os.IsNotExist(err) {146147 t.Fatalf("PR repair wrote signed state file: %v", err)147148 }149+ quarantines, err := filepath.Glob(filepath.Join(rootDir, "git", "prs", owner, "repo.repair-"+root.ID+"-*.git"))150+ if err != nil {151+ t.Fatal(err)152+ }153+ if len(quarantines) != 0 {154+ t.Fatalf("PR repair quarantine was not removed: %v", quarantines)155+ }156+ blockedRoot := root157+ blockedRoot.CreatedAt = 3158+ if err := event.Sign(&blockedRoot, secret); err != nil {159+ t.Fatal(err)160+ }161+ blockedUpdate := event.Event{Kind: event.KIND_GIT_PR_UPDATE, CreatedAt: 4, Tags: [][]string{{"E", blockedRoot.ID}, {"P", owner}, {"c", second}, {"clone", clone}}}162+ if err := event.Sign(&blockedUpdate, secret); err != nil {163+ t.Fatal(err)164+ }165+ // A ref-directory collision makes one update fail. The other signed ref166+ // must not appear, regardless of map iteration or transaction order.167+ blocker := "refs/nostr/" + blockedRoot.ID + "/child"168+ prGitRun(t, "--git-dir", gitDir, "update-ref", blocker, first)169+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: blockedRoot, Updates: []event.Event{blockedUpdate}}); err == nil {170+ t.Fatal("repair ignored conflicting ref")171+ }172+ if err := exec.Command("git", "--git-dir", gitDir, "show-ref", "--verify", "--quiet", "refs/nostr/"+blockedUpdate.ID).Run(); err == nil {173+ t.Fatal("failed repair partially promoted its signed refs")174+ }175+ if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", blocker)); got != first {176+ t.Fatalf("repair changed blocking ref: %s", got)177+ }178+ if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/tinyrelay/pr-repair/"); strings.TrimSpace(out) != "" {179+ t.Fatalf("failed repair left temporary refs: %s", out)180+ }181+}182+183+func TestPullRequestRepairFailureLeavesHostedRefsUnchanged(t *testing.T) {184+ secret := strings.Repeat("0", 63) + "1"185+ owner, err := event.PublicKey(secret)186+ if err != nil {187+ t.Fatal(err)188+ }189+ rootDir := t.TempDir()190+ hosted := Repository{Owner: owner, Identifier: "repo", Refs: map[string]string{191+ "refs/heads/main": strings.Repeat("a", 40),192+ }}193+ g := &GitRelay{root: rootDir, allowPrivate: true, configured: make(map[string]struct{})}194+ root := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, PubKey: owner,195+ Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", strings.Repeat("b", 40)}, {"clone", "http://127.0.0.1:1/repo.git"}}}196+ if err := event.Sign(&root, secret); err != nil {197+ t.Fatal(err)198+ }199+ err = g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: root})200+ if err == nil {201+ t.Fatal("repair unexpectedly succeeded from unavailable source")202+ }203+ if _, err := os.Stat(g.repoPath(hosted)); !os.IsNotExist(err) {204+ // ensureRepo is allowed to create the host, but no signed ref may be205+ // installed after a failed quarantine admission.206+ got, _ := exec.Command("git", "--git-dir", g.repoPath(hosted), "show-ref", "refs/nostr/"+root.ID).CombinedOutput()207+ if len(got) != 0 {208+ t.Fatalf("failed repair installed hosted PR ref: %s", got)209+ }210+ }211+}212+213+func TestPRQuarantineRejectsHistoryBeyondDepth(t *testing.T) {214+ root := t.TempDir()215+ work := filepath.Join(root, "work")216+ remote := filepath.Join(root, "remote.git")217+ prGitRun(t, "init", work)218+ prGitRun(t, "-C", work, "config", "user.email", "pr@example.test")219+ prGitRun(t, "-C", work, "config", "user.name", "PR")220+ for i := 0; i < prRepairDepth+1; i++ {221+ if err := os.WriteFile(filepath.Join(work, "history"), []byte(fmt.Sprintf("%d\n", i)), 0600); err != nil {222+ t.Fatal(err)223+ }224+ prGitRun(t, "-C", work, "add", "history")225+ prGitRun(t, "-C", work, "commit", "-m", fmt.Sprintf("commit %d", i))226+ }227+ tip := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD"))228+ base := strings.TrimSpace(prGitOutput(t, "-C", work, "rev-parse", "HEAD~1"))229+ prGitRun(t, "init", "--bare", remote)230+ prGitRun(t, "-C", work, "push", remote, "HEAD:refs/heads/main")231+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { serveGitBackend(t, w, r, root) }))232+ t.Cleanup(server.Close)233+ ca := filepath.Join(root, "ca.pem")234+ if err := os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {235+ t.Fatal(err)236+ }237+ t.Setenv("GIT_SSL_CAINFO", ca)238+ secret := strings.Repeat("0", 63) + "1"239+ owner, err := event.PublicKey(secret)240+ if err != nil {241+ t.Fatal(err)242+ }243+ hosted := Repository{Owner: owner, Identifier: "repo"}244+ g := &GitRelay{root: filepath.Join(root, "hosted"), allowPrivate: true, configured: make(map[string]struct{})}245+ pr := event.Event{Kind: event.KIND_GIT_PR, CreatedAt: 1, Tags: [][]string{{"a", "30617:" + owner + ":repo"}, {"c", tip}, {"clone", server.URL + "/remote.git"}}}246+ if err := event.Sign(&pr, secret); err != nil {247+ t.Fatal(err)248+ }249+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err == nil || !strings.Contains(err.Error(), "object graph") {250+ t.Fatalf("expected incomplete object graph rejection, got %v", err)251+ }252+ if g.objectsPresent(context.Background(), hosted, []string{tip}) {253+ t.Fatal("rejected objects reached the hosted repository")254+ }255+ gitDir := g.repoPath(hosted)256+ if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {257+ t.Fatalf("hosted shallow boundary changed: %v", err)258+ }259+ if out := prGitOutput(t, "--git-dir", gitDir, "for-each-ref", "refs/nostr/"); strings.TrimSpace(out) != "" {260+ t.Fatalf("rejected PR installed refs: %s", out)261+ }262+ // Once the existing base is hosted, the exact same PR must succeed even263+ // though its complete ancestry extends beyond the remote depth window.264+ prGitRun(t, "--git-dir", gitDir, "fetch", work, base+":refs/heads/main")265+ if err := g.RepairPullRequestObjects(context.Background(), hosted, PullRequestRepair{Root: pr}); err != nil {266+ t.Fatalf("PR with hosted base failed: %v", err)267+ }268+ if got := strings.TrimSpace(prGitOutput(t, "--git-dir", gitDir, "rev-parse", "refs/nostr/"+pr.ID)); got != tip {269+ t.Fatalf("PR tip = %s, want %s", got, tip)270+ }271+ prGitRun(t, "--git-dir", gitDir, "fsck", "--full", "--no-dangling")272+ if _, err := os.Stat(filepath.Join(gitDir, "shallow")); !os.IsNotExist(err) {273+ t.Fatalf("successful repair made host shallow: %v", err)274+ }148275 }149276 150277 func TestPrivatePolicyDoesNotContactUnconfiguredRepairSource(t *testing.T) {