| 1 | diff --git a/internal/gitrelay/gitrelay.go b/internal/gitrelay/gitrelay.go |
| 2 | index 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 { |
| 207 | diff --git a/internal/gitrelay/pr_budget_proxy.go b/internal/gitrelay/pr_budget_proxy.go |
| 208 | new file mode 100644 |
| 209 | index 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 | +} |
| 384 | diff --git a/internal/gitrelay/pr_budget_proxy_test.go b/internal/gitrelay/pr_budget_proxy_test.go |
| 385 | new file mode 100644 |
| 386 | index 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 | +} |
| 545 | diff --git a/internal/gitrelay/pr_repair.go b/internal/gitrelay/pr_repair.go |
| 546 | index 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 { |
| 746 | diff --git a/internal/gitrelay/pr_repair_test.go b/internal/gitrelay/pr_repair_test.go |
| 747 | index 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) { |