Skip to main content

codetopo_server/
ingest.rs

1// Copyright (C) 2026 Nrupal Akolkar
2// SPDX-License-Identifier: AGPL-3.0-or-later
3// Part of codetopo — Owned by Nrupal Akolkar (https://nrupalakolkar.com) · Built with Muse by Meta (https://muse.ai/join) — join Muse, then redeem code 101EEK in Settings within 48 hours of joining and we both earn tokens.
4
5//! Hosted ingest: getting a stranger's repository onto the box
6//! (paid-path plan S2, gap G1).
7//!
8//! Until now `POST /v1/index` accepted only a path on the server host,
9//! which is a fine contract for the operator's own keys and a useless
10//! one for a paying tenant: there was no way in. This module is the way
11//! in. Both hosted modes land the tree in a server-owned workdir —
12//! `<data_dir>/ingest/<index_id>/` — never at a caller-chosen host path:
13//!
14//! * **Clone.** A public `https://` git URL, shallow-cloned with the
15//!   system `git`. The URL is validated before `git` ever runs: HTTPS
16//!   only, no embedded credentials, port 443 only, and the host must
17//!   resolve exclusively to public addresses (see
18//!   [`validate_repo_url`]). The clone itself is hardened: argument
19//!   array (never a shell), `GIT_TERMINAL_PROMPT=0` so a private repo
20//!   fails instead of prompting, redirects refused so a checked URL
21//!   cannot bounce the clone somewhere unchecked, submodules not
22//!   recursed, LFS smudge skipped, a wall-clock timeout, and a cap on
23//!   the cloned tree's size.
24//! * **Archive upload.** A `.zip` or `.tar.gz` body, extracted entry by
25//!   entry with every path proven to stay inside the workdir before it
26//!   is written: traversal entries (`../…`, absolute paths) are
27//!   refused, and so are link entries — a symlink planted by one entry
28//!   and written through by the next is the classic extraction escape,
29//!   so archives carrying links do not ingest at all. Extracted bytes
30//!   are counted as they are written, not trusted from headers, so a
31//!   lying header cannot smuggle an expansion bomb past the cap.
32//!
33//! The caps below are the hosted plan's values, named so the
34//! abuse-envelope step (plan S3) could turn them into per-plan
35//! policy without hunting literals — which it did: they are now the
36//! `HOSTED` row of [`IngestCaps`], resolved per key by
37//! [`crate::limits`], and every function here takes its caps as a
38//! parameter. A failed ingest removes its workdir: a partial tree is
39//! never left behind for a later sweep to mistake for a live index.
40
41use std::io::{Cursor, Read, Write};
42use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
43use std::path::{Component, Path, PathBuf};
44use std::process::{Command, Stdio};
45use std::time::{Duration, Instant};
46
47use url::{Host, Url};
48
49use crate::error::ApiError;
50
51/// Largest archive body `POST /v1/index` accepts, in bytes.
52pub const MAX_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
53
54/// Largest tree an ingest may produce, in bytes — extracted bytes for
55/// an archive, on-disk bytes for a clone. Counted as written/measured,
56/// never trusted from a header.
57pub const MAX_TREE_BYTES: u64 = 512 * 1024 * 1024;
58
59/// Most entries one archive may carry. Bounds the work a single upload
60/// can make the extractor do before any byte is counted.
61pub const MAX_ARCHIVE_ENTRIES: usize = 100_000;
62
63/// Wall-clock budget for one `git clone`. A clone that outlives it is
64/// killed, not waited on: ingest is synchronous from the client's side
65/// of the request, and an unbounded clone is an unbounded request.
66pub const CLONE_TIMEOUT_SECS: u64 = 120;
67
68/// The ingest caps one plan resolves to, passed into every function
69/// here so no function reads a global: a hosted key's clone, upload,
70/// and extraction are bounded by these numbers, and the operator's
71/// self-hosted keys resolve to [`IngestCaps::UNLIMITED`] instead —
72/// trusting the operator with their own box is the self-hosted
73/// plan's whole definition (see [`crate::limits`]).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct IngestCaps {
76    /// Largest archive body accepted, in bytes.
77    pub max_upload_bytes: usize,
78    /// Largest tree an ingest may produce, in bytes.
79    pub max_tree_bytes: u64,
80    /// Most entries one archive may carry.
81    pub max_archive_entries: usize,
82    /// Wall-clock budget for one `git clone`.
83    pub clone_timeout: Duration,
84}
85
86impl IngestCaps {
87    /// The hosted plan's row: the named constants above.
88    pub const HOSTED: Self = IngestCaps {
89        max_upload_bytes: MAX_UPLOAD_BYTES,
90        max_tree_bytes: MAX_TREE_BYTES,
91        max_archive_entries: MAX_ARCHIVE_ENTRIES,
92        clone_timeout: Duration::from_secs(CLONE_TIMEOUT_SECS),
93    };
94
95    /// The self-hosted row: no cap binds. `Duration::MAX` is safe in
96    /// the clone loop, which compares elapsed time against the
97    /// budget and therefore never trips.
98    pub const UNLIMITED: Self = IngestCaps {
99        max_upload_bytes: usize::MAX,
100        max_tree_bytes: u64::MAX,
101        max_archive_entries: usize::MAX,
102        clone_timeout: Duration::MAX,
103    };
104}
105
106// ---------------------------------------------------------------------------
107// Repository URLs: validation, then clone
108// ---------------------------------------------------------------------------
109
110/// Parses and statically validates a `repo_url`.
111///
112/// The checks that need no network: the string parses, the scheme is
113/// exactly `https`, no credentials are embedded (`user:pass@` in a URL
114/// is both a secret leak into logs and an ambiguity about whose
115/// repository this is), a host is present, and the port is 443 or the
116/// HTTPS default. Address-class checks need resolution and live in
117/// [`validate_repo_url`].
118pub(crate) fn parse_repo_url(raw: &str) -> Result<Url, ApiError> {
119    let url = Url::parse(raw.trim())
120        .map_err(|_| ApiError::invalid_repo_url("not a valid URL"))?;
121    if url.scheme() != "https" {
122        return Err(ApiError::invalid_repo_url(
123            "only https:// URLs are accepted",
124        ));
125    }
126    if !url.username().is_empty() || url.password().is_some() {
127        return Err(ApiError::invalid_repo_url(
128            "URLs with embedded credentials are not accepted",
129        ));
130    }
131    if url.host().is_none() {
132        return Err(ApiError::invalid_repo_url("the URL has no host"));
133    }
134    if let Some(port) = url.port() {
135        if port != 443 {
136            return Err(ApiError::invalid_repo_url(
137                "only port 443 (the HTTPS default) is accepted",
138            ));
139        }
140    }
141    Ok(url)
142}
143
144/// One validated DNS answer the clone is pinned to: `git` will be
145/// told to connect the URL's host to exactly this address (see
146/// [`validate_repo_url`]).
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ResolvePin {
149    /// The host as it appears in the URL.
150    pub host: String,
151    /// One address validation approved for that host.
152    pub address: IpAddr,
153}
154
155/// A `repo_url` that passed validation, with the DNS answers it was
156/// approved against. The clone consumes this, never the raw string,
157/// so the checked URL and the connected addresses cannot drift
158/// apart between the two steps.
159#[derive(Debug, Clone)]
160pub struct ValidatedRepoUrl {
161    url: Url,
162    pins: Vec<ResolvePin>,
163}
164
165impl ValidatedRepoUrl {
166    /// The validated URL.
167    pub fn url(&self) -> &Url {
168        &self.url
169    }
170
171    /// The URL as a string, for the clone's argument array.
172    pub fn as_str(&self) -> &str {
173        self.url.as_str()
174    }
175
176    /// The resolution pins for the clone: one per validated address
177    /// of a domain host. Empty for an IP-literal host — the URL
178    /// already names the exact address, so there is no resolution
179    /// to pin.
180    pub fn pins(&self) -> &[ResolvePin] {
181        &self.pins
182    }
183
184    /// The default package name for this URL's repository.
185    pub fn package_name(&self) -> String {
186        package_name_for_url(&self.url)
187    }
188}
189
190/// Fully validates a `repo_url`, including where its host points.
191///
192/// The SSRF guard: every address the host resolves to must be a public
193/// address. A name that resolves to even one loopback, private,
194/// link-local, or otherwise non-public address is refused whole —
195/// accepting the public answers and letting `git` pick would race the
196/// refusal. IP-literal hosts are checked directly, with no lookup.
197///
198/// **The rebinding window, and what S3 did about it.** Validation
199/// resolves the name; a clone that resolved it *again* could be
200/// handed different addresses the second time (DNS rebinding). The
201/// clone therefore does not resolve again: every validated address
202/// is passed to `git` as an `http.curloptResolve` pin (see
203/// [`run_git_clone`]), so libcurl connects the checked host to a
204/// checked address on port 443, with TLS still verifying the URL's
205/// hostname. Residuals, stated plainly: the pin is a `git` config
206/// key, and a `git` build old enough not to know it ignores it
207/// silently — on such a build the check-then-clone window stands
208/// (the option is present in git 2.43; the deployment should not run
209/// older). And the pin set is the answer at validation time: the
210/// clone runs seconds later in the same request, so staleness is
211/// bounded by that gap, and every address in the set was public when
212/// checked.
213pub async fn validate_repo_url(raw: &str) -> Result<ValidatedRepoUrl, ApiError> {
214    let url = parse_repo_url(raw)?;
215    let pins = match url.host() {
216        Some(Host::Ipv4(ip)) => {
217            require_public(IpAddr::V4(ip), url.host_str().unwrap_or(""))?;
218            Vec::new()
219        }
220        Some(Host::Ipv6(ip)) => {
221            require_public(IpAddr::V6(ip), url.host_str().unwrap_or(""))?;
222            Vec::new()
223        }
224        Some(Host::Domain(domain)) => {
225            let domain = domain.strip_suffix('.').unwrap_or(domain);
226            if domain.eq_ignore_ascii_case("localhost") {
227                return Err(non_public_host(domain));
228            }
229            let addresses = tokio::net::lookup_host((domain, 443))
230                .await
231                .map_err(|_| ApiError::invalid_repo_url("the host does not resolve"))?;
232            // The pin's host is the URL's own host string, exactly
233            // as libcurl will extract it from the URL — a pin keyed
234            // on any other spelling (this lookup strips a trailing
235            // dot, for instance) would silently not match.
236            let url_host = url.host_str().unwrap_or(domain).to_string();
237            let mut pins: Vec<ResolvePin> = Vec::new();
238            for address in addresses {
239                let ip = address.ip();
240                require_public(ip, domain)?;
241                if !pins.iter().any(|pin| pin.address == ip) {
242                    pins.push(ResolvePin {
243                        host: url_host.clone(),
244                        address: ip,
245                    });
246                }
247            }
248            if pins.is_empty() {
249                return Err(ApiError::invalid_repo_url("the host does not resolve"));
250            }
251            pins
252        }
253        None => return Err(ApiError::invalid_repo_url("the URL has no host")),
254    };
255    Ok(ValidatedRepoUrl { url, pins })
256}
257
258fn require_public(ip: IpAddr, host: &str) -> Result<(), ApiError> {
259    if is_public_ip(ip) {
260        Ok(())
261    } else {
262        Err(non_public_host(host))
263    }
264}
265
266fn non_public_host(host: &str) -> ApiError {
267    ApiError::invalid_repo_url(&format!(
268        "host '{host}' resolves to a non-public address; only public repositories are ingested"
269    ))
270}
271
272/// True for addresses a public repository host may legitimately have:
273/// globally routable, and none of the loopback / private / link-local /
274/// shared / benchmarking / documentation / reserved classes an SSRF
275/// guard exists to keep a server from dialing on a caller's behalf.
276pub(crate) fn is_public_ip(ip: IpAddr) -> bool {
277    match ip {
278        IpAddr::V4(ip) => is_public_ipv4(ip),
279        IpAddr::V6(ip) => is_public_ipv6(ip),
280    }
281}
282
283fn is_public_ipv4(ip: Ipv4Addr) -> bool {
284    let octets = ip.octets();
285    !(ip.is_loopback()
286        || ip.is_private()
287        || ip.is_link_local()
288        || ip.is_multicast()
289        || ip.is_unspecified()
290        || ip.is_broadcast()
291        || ip.is_documentation()
292        || octets[0] == 0 // 0.0.0.0/8, "this network"
293        || (octets[0] == 100 && (octets[1] & 0xC0) == 0x40) // 100.64.0.0/10, shared/CGNAT
294        || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) // 192.0.0.0/24, protocol assignments
295        || (octets[0] == 198 && (octets[1] & 0xFE) == 18) // 198.18.0.0/15, benchmarking
296        || octets[0] >= 240) // 240.0.0.0/4, reserved
297}
298
299fn is_public_ipv6(ip: Ipv6Addr) -> bool {
300    // An IPv4-mapped IPv6 address is judged by the IPv4 rules: the
301    // mapped form must not be a laundering route around them.
302    if let Some(mapped) = ip.to_ipv4_mapped() {
303        return is_public_ipv4(mapped);
304    }
305    let segments = ip.segments();
306    !(ip.is_loopback()
307        || ip.is_multicast()
308        || ip.is_unspecified()
309        || (segments[0] & 0xFE00) == 0xFC00 // fc00::/7, unique local
310        || (segments[0] & 0xFFC0) == 0xFE80 // fe80::/10, link local
311        || (segments[0] == 0x2001 && segments[1] == 0x0DB8)) // 2001:db8::/32, documentation
312}
313
314/// The default package name for a cloned repository: the URL's last
315/// path segment, minus a `.git` suffix (`…/acme/api.git` → `api`).
316pub(crate) fn package_name_for_url(url: &Url) -> String {
317    let segment = url
318        .path_segments()
319        .and_then(|mut segments| segments.rfind(|s| !s.is_empty()))
320        .unwrap_or("");
321    let name = segment.strip_suffix(".git").unwrap_or(segment);
322    if name.is_empty() {
323        "repo".to_string()
324    } else {
325        name.to_string()
326    }
327}
328
329/// Clones a validated URL into `workdir` (created here) and checks
330/// the tree against the plan's byte cap. On any failure the workdir
331/// is removed: a partial clone is not an index and must not linger
332/// as one.
333pub fn clone_into(
334    validated: &ValidatedRepoUrl,
335    workdir: &Path,
336    caps: IngestCaps,
337) -> Result<(), ApiError> {
338    let result = clone_into_inner(validated, workdir, caps);
339    if result.is_err() {
340        let _ = std::fs::remove_dir_all(workdir);
341    }
342    result
343}
344
345fn clone_into_inner(
346    validated: &ValidatedRepoUrl,
347    workdir: &Path,
348    caps: IngestCaps,
349) -> Result<(), ApiError> {
350    std::fs::create_dir_all(workdir).map_err(|err| {
351        ApiError::internal(format!(
352            "cannot create ingest workdir {}: {err}",
353            workdir.display()
354        ))
355    })?;
356    run_git_clone(
357        validated.as_str(),
358        workdir,
359        caps.clone_timeout,
360        validated.pins(),
361    )?;
362    let bytes = tree_bytes(workdir, caps.max_tree_bytes);
363    if bytes > caps.max_tree_bytes {
364        return Err(ApiError::invalid_repo_url(&format!(
365            "the cloned tree is {bytes} bytes, past the {}-byte ingest limit",
366            caps.max_tree_bytes
367        )));
368    }
369    Ok(())
370}
371
372/// The `git clone` argument array, factored out of the spawn so the
373/// pin wiring is testable without a network: one
374/// `http.curloptResolve` config per pin, in the `host:port:address`
375/// form libcurl's `CURLOPT_RESOLVE` takes. The port is 443 because
376/// validation admits no other.
377fn clone_args(url: &str, dest: &str, pins: &[ResolvePin]) -> Vec<String> {
378    let mut args: Vec<String> = vec![
379        "-c".to_string(),
380        "http.followRedirects=false".to_string(),
381        "-c".to_string(),
382        "protocol.ext.allow=never".to_string(),
383    ];
384    for pin in pins {
385        args.push("-c".to_string());
386        args.push(format!(
387            "http.curloptResolve={}:443:{}",
388            pin.host, pin.address
389        ));
390    }
391    for arg in [
392        "clone",
393        "--depth",
394        "1",
395        "--single-branch",
396        "--no-recurse-submodules",
397        "--",
398        url,
399        dest,
400    ] {
401        args.push(arg.to_string());
402    }
403    args
404}
405
406/// The clone plumbing: runs `git clone` for an already-validated URL.
407///
408/// **Callers must validate first** ([`validate_repo_url`]); this
409/// function deliberately takes the URL as an opaque argument so tests
410/// can drive it at a local `git daemon`, and it applies none of the
411/// public-host policy itself. What it always applies is process
412/// hygiene: an argument array (the URL can never become a shell line
413/// or an option — `--` ends option parsing), no terminal prompt, no
414/// redirects, no submodule recursion, no LFS smudge (a clone-time
415/// network fetch to endpoints the repository chooses), the `ext::`
416/// transport disabled (it executes commands), a kill past
417/// `timeout`, and the validation pins of the checked resolution
418/// (`http.curloptResolve` — see [`validate_repo_url`] for what that
419/// closes and what it cannot).
420pub(crate) fn run_git_clone(
421    url: &str,
422    dest: &Path,
423    timeout: Duration,
424    pins: &[ResolvePin],
425) -> Result<(), ApiError> {
426    let dest_arg = dest
427        .to_str()
428        .ok_or_else(|| ApiError::internal("ingest workdir path is not valid UTF-8"))?;
429    let mut child = Command::new("git")
430        .args(clone_args(url, dest_arg, pins))
431        .env("GIT_TERMINAL_PROMPT", "0")
432        .env("GIT_LFS_SKIP_SMUDGE", "1")
433        .stdin(Stdio::null())
434        .stdout(Stdio::null())
435        .stderr(Stdio::piped())
436        .spawn()
437        .map_err(|err| ApiError::internal(format!("cannot run git: {err}")))?;
438
439    // Drain stderr on a thread: a clone that fills the pipe buffer
440    // would block mid-write and look exactly like a hang, and the
441    // captured tail is the error message when the clone fails.
442    let stderr_pipe = child.stderr.take();
443    let drain = std::thread::spawn(move || {
444        let mut buf = Vec::new();
445        if let Some(mut pipe) = stderr_pipe {
446            let _ = pipe.read_to_end(&mut buf);
447        }
448        buf
449    });
450
451    let started = Instant::now();
452    let status = loop {
453        match child.try_wait() {
454            Ok(Some(status)) => break status,
455            Ok(None) => {
456                if started.elapsed() >= timeout {
457                    let _ = child.kill();
458                    let _ = child.wait();
459                    let _ = drain.join();
460                    return Err(ApiError::invalid_repo_url(&format!(
461                        "git clone did not finish within {}s",
462                        timeout.as_secs()
463                    )));
464                }
465                std::thread::sleep(Duration::from_millis(50));
466            }
467            Err(err) => {
468                let _ = drain.join();
469                return Err(ApiError::internal(format!("cannot wait on git clone: {err}")));
470            }
471        }
472    };
473    let stderr = drain.join().unwrap_or_default();
474    if status.success() {
475        return Ok(());
476    }
477    let text = String::from_utf8_lossy(&stderr);
478    let last_line = text
479        .lines()
480        .rev()
481        .find(|line| !line.trim().is_empty())
482        .unwrap_or("no error output");
483    let last_line: String = last_line.chars().take(300).collect();
484    Err(ApiError::invalid_repo_url(&format!(
485        "git clone failed: {last_line}"
486    )))
487}
488
489/// Directory names the file counter never descends into — the same
490/// skip set the repository walker applies in
491/// `codetopo_cli::index_repo` (VCS internals, build and dependency
492/// output, hidden directories), mirrored here so the count is the
493/// number of files a tree presents to the indexer, not the number
494/// that happen to sit on disk inside a `.git` the index would never
495/// read. If the walker's set changes, this must change with it.
496fn skipped_dir_name(name: &str) -> bool {
497    matches!(name, ".git" | "target" | "node_modules" | ".corpus") || name.starts_with('.')
498}
499
500/// Counts the regular files under `root` an index of this tree
501/// would be built from, symlinks not followed, directories in
502/// [`skipped_dir_name`] not entered. Stops as soon as the count
503/// reaches `stop_after` — the per-index file cap's check passes
504/// `limit + 1`, so a returned `limit + 1` means "more than the
505/// limit" and the walk never pays for an exact count of a tree it
506/// is about to refuse.
507pub fn count_tree_files(root: &Path, stop_after: u64) -> u64 {
508    let mut count: u64 = 0;
509    let mut stack = vec![root.to_path_buf()];
510    while let Some(dir) = stack.pop() {
511        let Ok(entries) = std::fs::read_dir(&dir) else {
512            continue;
513        };
514        for entry in entries.flatten() {
515            let Ok(file_type) = entry.file_type() else {
516                continue;
517            };
518            if file_type.is_dir() {
519                if !skipped_dir_name(&entry.file_name().to_string_lossy()) {
520                    stack.push(entry.path());
521                }
522            } else if file_type.is_file() {
523                count += 1;
524                if count >= stop_after {
525                    return count;
526                }
527            }
528        }
529    }
530    count
531}
532
533/// Total bytes of regular files under `root`, symlinks not followed.
534/// Stops early once past `cap` — the caller only needs to know the cap
535/// fell, not by how much.
536fn tree_bytes(root: &Path, cap: u64) -> u64 {
537    let mut total: u64 = 0;
538    let mut stack = vec![root.to_path_buf()];
539    while let Some(dir) = stack.pop() {
540        let Ok(entries) = std::fs::read_dir(&dir) else {
541            continue;
542        };
543        for entry in entries.flatten() {
544            let Ok(file_type) = entry.file_type() else {
545                continue;
546            };
547            if file_type.is_dir() {
548                stack.push(entry.path());
549            } else if file_type.is_file() {
550                total += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
551                if total > cap {
552                    return total;
553                }
554            }
555        }
556    }
557    total
558}
559
560// ---------------------------------------------------------------------------
561// Archive upload: sniff, then extract with containment
562// ---------------------------------------------------------------------------
563
564/// The archive formats ingest accepts.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum ArchiveKind {
567    /// `.zip`, read through the `zip` crate.
568    Zip,
569    /// `.tar.gz`, read through `tar` over a gzip decoder.
570    TarGz,
571}
572
573/// Decides what a request body is: the declared content type first,
574/// the magic bytes when the declaration is missing or generic
575/// (`application/octet-stream`). `None` is neither archive format —
576/// the handler answers `invalid_body`.
577pub fn archive_kind(content_type: Option<&str>, bytes: &[u8]) -> Option<ArchiveKind> {
578    match content_type {
579        Some("application/zip") | Some("application/x-zip-compressed") => Some(ArchiveKind::Zip),
580        Some("application/gzip") | Some("application/x-gzip") => Some(ArchiveKind::TarGz),
581        _ => {
582            if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") {
583                Some(ArchiveKind::Zip)
584            } else if bytes.starts_with(b"\x1f\x8b") {
585                Some(ArchiveKind::TarGz)
586            } else {
587                None
588            }
589        }
590    }
591}
592
593/// Extracts an archive body into `workdir` (created here) and returns
594/// the directory to index. On any failure the workdir is removed.
595///
596/// Archives conventionally wrap the tree in one top-level directory
597/// (`repo-main/…`); when the extracted workdir holds exactly one entry
598/// and it is a directory, that directory is the index root, so node
599/// paths match what a clone of the same repository produces. Files at
600/// the archive root index from the workdir itself.
601pub fn extract_into(
602    kind: ArchiveKind,
603    bytes: &[u8],
604    workdir: &Path,
605    caps: IngestCaps,
606) -> Result<PathBuf, ApiError> {
607    let result = extract_into_inner(kind, bytes, workdir, caps);
608    if result.is_err() {
609        let _ = std::fs::remove_dir_all(workdir);
610    }
611    result
612}
613
614fn extract_into_inner(
615    kind: ArchiveKind,
616    bytes: &[u8],
617    workdir: &Path,
618    caps: IngestCaps,
619) -> Result<PathBuf, ApiError> {
620    std::fs::create_dir_all(workdir).map_err(|err| {
621        ApiError::internal(format!(
622            "cannot create ingest workdir {}: {err}",
623            workdir.display()
624        ))
625    })?;
626    match kind {
627        ArchiveKind::Zip => extract_zip(bytes, workdir, caps.max_archive_entries, caps.max_tree_bytes),
628        ArchiveKind::TarGz => {
629            extract_tar_gz(bytes, workdir, caps.max_archive_entries, caps.max_tree_bytes)
630        }
631    }?;
632    Ok(index_root(workdir))
633}
634
635/// The single-top-level-directory unwrap described on [`extract_into`].
636fn index_root(workdir: &Path) -> PathBuf {
637    let Ok(entries) = std::fs::read_dir(workdir) else {
638        return workdir.to_path_buf();
639    };
640    let entries: Vec<PathBuf> = entries.flatten().map(|entry| entry.path()).collect();
641    if entries.len() == 1 && entries[0].is_dir() {
642        return entries.into_iter().next().expect("one entry");
643    }
644    workdir.to_path_buf()
645}
646
647/// Reduces an archive entry's path to a relative path proven to stay
648/// inside the extraction root: `Normal` components only, `.` ignored.
649/// Absolute paths, `..`, and platform prefixes are refused (`None`) —
650/// this check, applied before any join, is the zip-slip/tar-slip guard.
651fn enclosed_relative(path: &Path) -> Option<PathBuf> {
652    let mut out = PathBuf::new();
653    for component in path.components() {
654        match component {
655            Component::Normal(part) => out.push(part),
656            Component::CurDir => {}
657            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
658        }
659    }
660    Some(out)
661}
662
663/// Copies `reader` into `file`, counting actual bytes against
664/// `budget` (the caller's remaining allowance of the `limit` total
665/// the error message names). Reading past the budget is the error,
666/// so a decompressed size that exceeds its declared size is caught
667/// by the same check as an honestly huge file.
668fn copy_capped(
669    reader: &mut impl Read,
670    file: &mut std::fs::File,
671    budget: u64,
672    limit: u64,
673) -> Result<u64, ApiError> {
674    let mut written: u64 = 0;
675    let mut buffer = [0u8; 64 * 1024];
676    loop {
677        let read = reader
678            .read(&mut buffer)
679            .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
680        if read == 0 {
681            return Ok(written);
682        }
683        written += read as u64;
684        if written > budget {
685            return Err(ApiError::invalid_body(&format!(
686                "archive expands past the {limit}-byte ingest limit"
687            )));
688        }
689        file.write_all(&buffer[..read])
690            .map_err(|err| ApiError::internal(format!("cannot write extracted file: {err}")))?;
691    }
692}
693
694fn escape_error() -> ApiError {
695    ApiError::invalid_body("archive entry path escapes the extraction root")
696}
697
698fn extract_zip(
699    bytes: &[u8],
700    dest: &Path,
701    max_entries: usize,
702    max_bytes: u64,
703) -> Result<(), ApiError> {
704    let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| {
705        ApiError::invalid_body(&format!("not a readable zip archive: {err}"))
706    })?;
707    if archive.len() > max_entries {
708        return Err(ApiError::invalid_body(&format!(
709            "archive carries {} entries, past the {max_entries}-entry ingest limit",
710            archive.len()
711        )));
712    }
713    let mut total: u64 = 0;
714    for i in 0..archive.len() {
715        let mut entry = archive
716            .by_index(i)
717            .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
718        // enclosed_name is the zip crate's own sanitised view; the
719        // component check over it is repeated here anyway, so both
720        // formats pass through the one guard in this module.
721        let enclosed = entry.enclosed_name().ok_or_else(escape_error)?;
722        let rel = enclosed_relative(&enclosed).ok_or_else(escape_error)?;
723        if rel.as_os_str().is_empty() {
724            continue;
725        }
726        let target = dest.join(&rel);
727        if entry.is_dir() {
728            std::fs::create_dir_all(&target).map_err(|err| {
729                ApiError::internal(format!("cannot create extracted directory: {err}"))
730            })?;
731            continue;
732        }
733        if entry.is_symlink() {
734            return Err(ApiError::invalid_body(
735                "archive contains a symbolic link, which ingest does not accept",
736            ));
737        }
738        if let Some(parent) = target.parent() {
739            std::fs::create_dir_all(parent).map_err(|err| {
740                ApiError::internal(format!("cannot create extracted directory: {err}"))
741            })?;
742        }
743        let mut file = std::fs::File::create(&target).map_err(|err| {
744            ApiError::internal(format!("cannot write extracted file: {err}"))
745        })?;
746        total += copy_capped(&mut entry, &mut file, max_bytes - total, max_bytes)?;
747    }
748    Ok(())
749}
750
751fn extract_tar_gz(
752    bytes: &[u8],
753    dest: &Path,
754    max_entries: usize,
755    max_bytes: u64,
756) -> Result<(), ApiError> {
757    let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
758    let mut archive = tar::Archive::new(decoder);
759    let entries = archive
760        .entries()
761        .map_err(|err| ApiError::invalid_body(&format!("not a readable tar.gz archive: {err}")))?;
762    let mut total: u64 = 0;
763    let mut count: usize = 0;
764    for entry in entries {
765        let mut entry = entry
766            .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry: {err}")))?;
767        count += 1;
768        if count > max_entries {
769            return Err(ApiError::invalid_body(&format!(
770                "archive carries more than {max_entries} entries, the ingest limit"
771            )));
772        }
773        let entry_path = entry
774            .path()
775            .map_err(|err| ApiError::invalid_body(&format!("cannot read archive entry path: {err}")))?
776            .into_owned();
777        let rel = enclosed_relative(&entry_path).ok_or_else(escape_error)?;
778        if rel.as_os_str().is_empty() {
779            continue;
780        }
781        let target = dest.join(&rel);
782        match entry.header().entry_type() {
783            tar::EntryType::Directory => {
784                std::fs::create_dir_all(&target).map_err(|err| {
785                    ApiError::internal(format!("cannot create extracted directory: {err}"))
786                })?;
787            }
788            tar::EntryType::Regular | tar::EntryType::Continuous => {
789                if let Some(parent) = target.parent() {
790                    std::fs::create_dir_all(parent).map_err(|err| {
791                        ApiError::internal(format!("cannot create extracted directory: {err}"))
792                    })?;
793                }
794                let mut file = std::fs::File::create(&target).map_err(|err| {
795                    ApiError::internal(format!("cannot write extracted file: {err}"))
796                })?;
797                total += copy_capped(&mut entry, &mut file, max_bytes - total, max_bytes)?;
798            }
799            // Symlinks, hard links, devices, fifos: refused wholesale.
800            // A link written by one entry is a path out of the root for
801            // every later entry, and ingest needs none of them.
802            _ => {
803                return Err(ApiError::invalid_body(
804                    "archive contains a link or special entry, which ingest does not accept",
805                ));
806            }
807        }
808    }
809    Ok(())
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn public_ip_classes() {
818        for public in ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700:4700::1111"] {
819            assert!(is_public_ip(public.parse().expect("ip")), "{public} is public");
820        }
821        for non_public in [
822            "127.0.0.1",       // loopback
823            "10.0.0.5",        // RFC 1918
824            "172.16.0.9",      // RFC 1918
825            "192.168.1.1",     // RFC 1918
826            "169.254.1.1",     // link local
827            "100.64.0.1",      // CGNAT shared space
828            "0.0.0.0",         // unspecified
829            "0.1.2.3",         // "this network"
830            "224.0.0.1",       // multicast
831            "192.0.2.1",       // documentation TEST-NET-1
832            "198.18.0.1",      // benchmarking
833            "240.0.0.1",       // reserved
834            "255.255.255.255", // broadcast
835            "::1",             // v6 loopback
836            "::",              // v6 unspecified
837            "fe80::1",         // v6 link local
838            "fc00::1",         // v6 unique local
839            "fd12:3456::1",    // v6 unique local
840            "ff02::1",         // v6 multicast
841            "2001:db8::1",     // v6 documentation
842            "::ffff:127.0.0.1", // v4-mapped loopback
843            "::ffff:10.0.0.1", // v4-mapped private
844        ] {
845            assert!(
846                !is_public_ip(non_public.parse().expect("ip")),
847                "{non_public} is not public"
848            );
849        }
850        assert!(
851            is_public_ip("::ffff:8.8.8.8".parse().expect("ip")),
852            "a v4-mapped public address stays public"
853        );
854    }
855
856    #[test]
857    fn parse_rejects_non_https_and_credentialed_urls() {
858        for (raw, why) in [
859            ("http://github.com/a/b", "plain http"),
860            ("ssh://git@github.com/a/b", "ssh scheme"),
861            ("git://github.com/a/b", "git scheme"),
862            ("file:///srv/repo", "file scheme"),
863            ("ext::sh -c id", "ext transport"),
864            ("https://user:secret@github.com/a/b", "embedded credentials"),
865            ("https://user@github.com/a/b", "embedded user"),
866            ("https://github.com:8443/a/b", "non-443 port"),
867            ("not a url", "unparseable"),
868            ("https://", "no host"),
869        ] {
870            let err = parse_repo_url(raw).expect_err(why);
871            assert_eq!(err.code(), "invalid_repo_url", "{raw}: {why}");
872        }
873    }
874
875    #[test]
876    fn parse_accepts_plain_https_urls() {
877        for raw in [
878            "https://github.com/nrupala/codetopo",
879            "https://github.com/nrupala/codetopo.git",
880            "https://github.com:443/nrupala/codetopo",
881            "https://git.example.com:443/group/repo.git",
882        ] {
883            assert!(parse_repo_url(raw).is_ok(), "{raw}");
884        }
885    }
886
887    /// IP-literal hosts need no DNS, so the full validation path —
888    /// resolution included — is exercisable offline.
889    #[tokio::test]
890    async fn validate_refuses_non_public_literal_hosts() {
891        for raw in [
892            "https://127.0.0.1/repo.git",
893            "https://10.1.2.3/repo.git",
894            "https://[::1]/repo.git",
895            "https://localhost/repo.git",
896            "https://localhost./repo.git",
897        ] {
898            let err = validate_repo_url(raw)
899                .await
900                .expect_err("non-public host refused");
901            assert_eq!(err.code(), "invalid_repo_url", "{raw}");
902        }
903    }
904
905    #[test]
906    fn package_name_comes_from_the_url_tail() {
907        let cases = [
908            ("https://github.com/nrupala/codetopo", "codetopo"),
909            ("https://github.com/nrupala/codetopo.git", "codetopo"),
910            ("https://github.com/nrupala/codetopo/", "codetopo"),
911            ("https://example.com/", "repo"),
912            ("https://example.com/.git", "repo"),
913        ];
914        for (raw, expected) in cases {
915            let url = parse_repo_url(raw).expect("url parses");
916            assert_eq!(package_name_for_url(&url), expected, "{raw}");
917        }
918    }
919
920    #[test]
921    fn archive_kind_reads_type_then_magic() {
922        assert_eq!(
923            archive_kind(Some("application/zip"), b"anything"),
924            Some(ArchiveKind::Zip)
925        );
926        assert_eq!(
927            archive_kind(Some("application/gzip"), b"anything"),
928            Some(ArchiveKind::TarGz)
929        );
930        assert_eq!(
931            archive_kind(Some("application/octet-stream"), b"PK\x03\x04rest"),
932            Some(ArchiveKind::Zip)
933        );
934        assert_eq!(
935            archive_kind(None, b"\x1f\x8brest"),
936            Some(ArchiveKind::TarGz)
937        );
938        assert_eq!(archive_kind(None, b"{\"json\":true}"), None);
939        assert_eq!(archive_kind(Some("text/plain"), b"hello"), None);
940    }
941
942    #[test]
943    fn clone_args_pin_each_validated_address() {
944        let pins = vec![
945            ResolvePin {
946                host: "github.com".to_string(),
947                address: "140.82.121.3".parse().expect("ip"),
948            },
949            ResolvePin {
950                host: "github.com".to_string(),
951                address: "2606:50c0:8000::153".parse().expect("ip"),
952            },
953        ];
954        let args = clone_args("https://github.com/a/b", "/tmp/dest", &pins);
955        let resolve: Vec<&str> = args
956            .iter()
957            .map(String::as_str)
958            .filter(|arg| arg.starts_with("http.curloptResolve="))
959            .collect();
960        assert_eq!(
961            resolve,
962            [
963                "http.curloptResolve=github.com:443:140.82.121.3",
964                "http.curloptResolve=github.com:443:2606:50c0:8000::153",
965            ],
966            "one pin per validated address, host:443:address"
967        );
968        // The pins ride as `-c` config ahead of the subcommand, and
969        // the URL still cannot become an option (`--` precedes it).
970        let dashdash = args.iter().position(|arg| arg == "--").expect("-- present");
971        assert_eq!(args[dashdash + 1], "https://github.com/a/b");
972
973        let bare = clone_args("git://127.0.0.1:9418/served", "/tmp/dest", &[]);
974        assert!(
975            !bare.iter().any(|arg| arg.contains("curloptResolve")),
976            "no pins, no resolve config"
977        );
978    }
979
980    /// Validation is async (DNS), so the pin *contents* are checked
981    /// here only where no lookup is needed: an IP-literal host is
982    /// checked directly and carries no pins, and a domain's pins are
983    /// exercised end-to-end only by the clone of a public repository
984    /// — the local daemon tests drive the plumbing with `&[]`.
985    #[tokio::test]
986    async fn validated_literal_hosts_carry_no_pins() {
987        let err = validate_repo_url("https://127.0.0.1/repo.git")
988            .await
989            .expect_err("loopback literal refused before pins matter");
990        assert_eq!(err.code(), "invalid_repo_url");
991    }
992
993    #[test]
994    fn count_tree_files_counts_what_the_indexer_would_read() {
995        let dir = tempfile::tempdir().expect("temp dir");
996        let root = dir.path();
997        let write = |rel: &str| {
998            let path = root.join(rel);
999            std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs");
1000            std::fs::write(path, b"x").expect("write");
1001        };
1002        write("src/lib.rs");
1003        write("src/main.rs");
1004        write("README.md");
1005        write(".git/objects/pack/pack-1.idx");
1006        write("node_modules/dep/index.js");
1007        write("target/debug/build/output");
1008        write(".hidden/secret.rs");
1009        write(".github/workflows/ci.yml");
1010
1011        assert_eq!(
1012            count_tree_files(root, u64::MAX),
1013            3,
1014            "only the three files outside the skipped directories count"
1015        );
1016        assert_eq!(
1017            count_tree_files(root, 2),
1018            2,
1019            "the walk stops at stop_after instead of counting a refused tree exactly"
1020        );
1021        assert_eq!(count_tree_files(root, 1), 1);
1022    }
1023
1024    // -- archive fixtures, built in memory -------------------------------
1025
1026    fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
1027        let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
1028        let options = zip::write::SimpleFileOptions::default()
1029            .compression_method(zip::CompressionMethod::Stored);
1030        for (name, contents) in entries {
1031            writer.start_file(*name, options).expect("start file");
1032            writer.write_all(contents).expect("write file");
1033        }
1034        writer.finish().expect("finish zip").into_inner()
1035    }
1036
1037    fn tar_gz_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
1038        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1039        {
1040            let mut builder = tar::Builder::new(&mut encoder);
1041            for (name, contents) in entries {
1042                let mut header = tar::Header::new_gnu();
1043                header.set_size(contents.len() as u64);
1044                header.set_mode(0o644);
1045                header.set_cksum();
1046                builder
1047                    .append_data(&mut header, name, *contents)
1048                    .expect("append entry");
1049            }
1050            builder.finish().expect("finish tar");
1051        }
1052        encoder.finish().expect("finish gzip")
1053    }
1054
1055    fn extract(kind: ArchiveKind, bytes: &[u8]) -> (tempfile::TempDir, Result<PathBuf, ApiError>) {
1056        let dir = tempfile::tempdir().expect("temp dir");
1057        let workdir = dir.path().join("work");
1058        let result = extract_into(kind, bytes, &workdir, IngestCaps::HOSTED);
1059        (dir, result)
1060    }
1061
1062    #[test]
1063    fn zip_extracts_and_unwraps_a_single_top_directory() {
1064        let bytes = zip_bytes(&[
1065            ("repo-main/src/lib.rs", b"pub fn f() {}"),
1066            ("repo-main/README.md", b"# repo"),
1067        ]);
1068        let (_dir, result) = extract(ArchiveKind::Zip, &bytes);
1069        let root = result.expect("extract");
1070        assert_eq!(root.file_name().expect("name"), "repo-main");
1071        assert!(root.join("src/lib.rs").is_file());
1072        assert!(root.join("README.md").is_file());
1073    }
1074
1075    #[test]
1076    fn tar_gz_extracts_files_at_the_root() {
1077        let bytes = tar_gz_bytes(&[("src/main.rs", b"fn main() {}"), ("Cargo.toml", b"[package]")]);
1078        let (dir, result) = extract(ArchiveKind::TarGz, &bytes);
1079        let root = result.expect("extract");
1080        assert_eq!(root, dir.path().join("work"), "no unwrap without a single top dir");
1081        assert!(root.join("src/main.rs").is_file());
1082    }
1083
1084    #[test]
1085    fn zip_traversal_is_refused_and_leaves_nothing() {
1086        let bytes = zip_bytes(&[("../evil.rs", b"pub fn evil() {}")]);
1087        let (dir, result) = extract(ArchiveKind::Zip, &bytes);
1088        let err = result.expect_err("traversal refused");
1089        assert_eq!(err.code(), "invalid_body");
1090        assert!(
1091            !dir.path().join("work").exists(),
1092            "a failed ingest removes its workdir"
1093        );
1094        assert!(
1095            !dir.path().join("evil.rs").exists(),
1096            "nothing was written outside the workdir"
1097        );
1098    }
1099
1100    /// A one-entry tarball with the entry name written byte-for-byte,
1101    /// for names the `tar` crate's own *builder* refuses to write —
1102    /// which is exactly the class a hostile archive arrives in, built
1103    /// by other tools. One ustar header block, the data, padding, and
1104    /// the two zero blocks that end a tar stream, gzipped.
1105    fn raw_tar_gz_bytes(name: &str, contents: &[u8]) -> Vec<u8> {
1106        let mut header = [0u8; 512];
1107        header[..name.len()].copy_from_slice(name.as_bytes());
1108        header[100..108].copy_from_slice(b"0000644\0");
1109        header[108..116].copy_from_slice(b"0000000\0");
1110        header[116..124].copy_from_slice(b"0000000\0");
1111        header[124..136].copy_from_slice(format!("{:011o}\0", contents.len()).as_bytes());
1112        header[136..148].copy_from_slice(b"00000000000\0");
1113        header[156] = b'0';
1114        header[257..263].copy_from_slice(b"ustar\0");
1115        header[263..265].copy_from_slice(b"00");
1116        for byte in &mut header[148..156] {
1117            *byte = b' ';
1118        }
1119        let checksum: u32 = header.iter().map(|byte| *byte as u32).sum();
1120        header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes());
1121
1122        let mut tar = Vec::new();
1123        tar.extend_from_slice(&header);
1124        tar.extend_from_slice(contents);
1125        tar.resize(tar.len() + (512 - contents.len() % 512) % 512, 0);
1126        tar.resize(tar.len() + 1024, 0);
1127
1128        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1129        encoder.write_all(&tar).expect("gzip write");
1130        encoder.finish().expect("finish gzip")
1131    }
1132
1133    #[test]
1134    fn tar_traversal_is_refused() {
1135        let bytes = raw_tar_gz_bytes("ok/../../evil.rs", b"x");
1136        let (dir, result) = extract(ArchiveKind::TarGz, &bytes);
1137        assert_eq!(result.expect_err("traversal refused").code(), "invalid_body");
1138        assert!(
1139            !dir.path().join("evil.rs").exists(),
1140            "nothing was written outside the workdir"
1141        );
1142    }
1143
1144    #[test]
1145    fn tar_symlink_is_refused() {
1146        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
1147        {
1148            let mut builder = tar::Builder::new(&mut encoder);
1149            let mut header = tar::Header::new_gnu();
1150            header.set_size(0);
1151            header.set_entry_type(tar::EntryType::Symlink);
1152            header.set_cksum();
1153            builder
1154                .append_link(&mut header, "link", "/etc/passwd")
1155                .expect("append link");
1156            builder.finish().expect("finish tar");
1157        }
1158        let bytes = encoder.finish().expect("finish gzip");
1159        let (_dir, result) = extract(ArchiveKind::TarGz, &bytes);
1160        assert_eq!(result.expect_err("link refused").code(), "invalid_body");
1161    }
1162
1163    #[test]
1164    fn extraction_counts_actual_bytes_against_the_cap() {
1165        // Two small entries, a one-byte budget: the second file's bytes
1166        // cross it even though each entry alone looks harmless.
1167        let bytes = zip_bytes(&[("a.rs", b"aaaa"), ("b.rs", b"bbbb")]);
1168        let dir = tempfile::tempdir().expect("temp dir");
1169        let dest = dir.path().join("work");
1170        std::fs::create_dir_all(&dest).expect("workdir");
1171        let err = extract_zip(&bytes, &dest, MAX_ARCHIVE_ENTRIES, 5).expect_err("cap enforced");
1172        assert_eq!(err.code(), "invalid_body");
1173    }
1174
1175    #[test]
1176    fn entry_count_cap_is_enforced() {
1177        let bytes = zip_bytes(&[("a.rs", b"a"), ("b.rs", b"b"), ("c.rs", b"c")]);
1178        let dir = tempfile::tempdir().expect("temp dir");
1179        let dest = dir.path().join("work");
1180        std::fs::create_dir_all(&dest).expect("workdir");
1181        let err = extract_zip(&bytes, &dest, 2, MAX_TREE_BYTES).expect_err("cap enforced");
1182        assert_eq!(err.code(), "invalid_body");
1183    }
1184
1185    #[test]
1186    fn garbage_is_not_an_archive() {
1187        let (_dir, result) = extract(ArchiveKind::Zip, b"definitely not a zip");
1188        assert_eq!(result.expect_err("refused").code(), "invalid_body");
1189        let (_dir, result) = extract(ArchiveKind::TarGz, b"definitely not a tarball");
1190        assert_eq!(result.expect_err("refused").code(), "invalid_body");
1191    }
1192
1193    // -- clone plumbing, against a local git daemon ----------------------
1194
1195    /// A directory holding one committed repository, served by
1196    /// `git daemon` on an ephemeral loopback port. The daemon transport
1197    /// is not HTTPS and its host is not public — exactly why the tests
1198    /// drive `run_git_clone` (the plumbing) rather than the validating
1199    /// entry point: validation is covered above, the subprocess here.
1200    struct DaemonRepo {
1201        _dir: tempfile::TempDir,
1202        url: String,
1203        child: std::process::Child,
1204    }
1205
1206    impl Drop for DaemonRepo {
1207        fn drop(&mut self) {
1208            let _ = self.child.kill();
1209            let _ = self.child.wait();
1210        }
1211    }
1212
1213    fn daemon_repo() -> DaemonRepo {
1214        let dir = tempfile::tempdir().expect("temp dir");
1215        let source = dir.path().join("served");
1216        std::fs::create_dir_all(source.join("src")).expect("source dirs");
1217        std::fs::write(source.join("src/lib.rs"), "pub fn served() {}\n").expect("write source");
1218        let run = |args: &[&str]| {
1219            let status = Command::new("git")
1220                .args(args)
1221                .current_dir(&source)
1222                .env("GIT_AUTHOR_NAME", "ingest test")
1223                .env("GIT_AUTHOR_EMAIL", "ingest@example.com")
1224                .env("GIT_COMMITTER_NAME", "ingest test")
1225                .env("GIT_COMMITTER_EMAIL", "ingest@example.com")
1226                .status()
1227                .expect("git runs");
1228            assert!(status.success(), "git {args:?} failed");
1229        };
1230        run(&["init", "--quiet", "."]);
1231        run(&["add", "."]);
1232        run(&["commit", "--quiet", "-m", "fixture"]);
1233
1234        // An ephemeral port: bind, read the number, release, hand it to
1235        // the daemon. A small race, accepted in tests (production git
1236        // never binds ports).
1237        let port = std::net::TcpListener::bind("127.0.0.1:0")
1238            .expect("bind")
1239            .local_addr()
1240            .expect("addr")
1241            .port();
1242        let child = Command::new("git")
1243            .args([
1244                "daemon",
1245                "--export-all",
1246                "--reuseaddr",
1247                &format!("--base-path={}", dir.path().display()),
1248                &format!("--port={port}"),
1249                "--listen=127.0.0.1",
1250            ])
1251            .stdin(Stdio::null())
1252            .stdout(Stdio::null())
1253            .stderr(Stdio::null())
1254            .spawn()
1255            .expect("git daemon spawns");
1256        let url = format!("git://127.0.0.1:{port}/served");
1257        // Wait until the daemon answers: clone attempts double as the
1258        // readiness probe, against a throwaway destination.
1259        let probe = dir.path().join("probe");
1260        let mut ready = false;
1261        for _ in 0..50 {
1262            if run_git_clone(&url, &probe, Duration::from_secs(5), &[]).is_ok() {
1263                let _ = std::fs::remove_dir_all(&probe);
1264                ready = true;
1265                break;
1266            }
1267            let _ = std::fs::remove_dir_all(&probe);
1268            std::thread::sleep(Duration::from_millis(100));
1269        }
1270        assert!(ready, "git daemon never answered a probe clone");
1271        DaemonRepo {
1272            _dir: dir,
1273            url,
1274            child,
1275        }
1276    }
1277
1278    #[test]
1279    fn clone_plumbing_lands_the_tree() {
1280        let daemon = daemon_repo();
1281        let dir = tempfile::tempdir().expect("temp dir");
1282        let dest = dir.path().join("clone");
1283        run_git_clone(&daemon.url, &dest, Duration::from_secs(30), &[]).expect("clone succeeds");
1284        assert!(dest.join("src/lib.rs").is_file(), "the tree landed");
1285        let bytes = tree_bytes(&dest, MAX_TREE_BYTES);
1286        assert!(bytes > 0 && bytes < MAX_TREE_BYTES);
1287    }
1288
1289    #[test]
1290    fn clone_of_a_missing_repo_is_an_error_not_a_hang() {
1291        let daemon = daemon_repo();
1292        let dir = tempfile::tempdir().expect("temp dir");
1293        let dest = dir.path().join("clone");
1294        let err = run_git_clone(
1295            &daemon.url.replace("/served", "/absent"),
1296            &dest,
1297            Duration::from_secs(10),
1298            &[],
1299        )
1300        .expect_err("missing repo refused");
1301        assert_eq!(err.code(), "invalid_repo_url");
1302    }
1303
1304    #[test]
1305    fn clone_timeout_kills_the_child() {
1306        // A listener that accepts and never speaks: git waits for the
1307        // daemon greeting forever, so only the timeout can end it.
1308        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1309        let port = listener.local_addr().expect("addr").port();
1310        let held = std::thread::spawn(move || {
1311            let _held_streams: Vec<std::net::TcpStream> = listener
1312                .incoming()
1313                .filter_map(Result::ok)
1314                .take(1)
1315                .collect();
1316            std::thread::sleep(Duration::from_secs(10));
1317        });
1318        let dir = tempfile::tempdir().expect("temp dir");
1319        let dest = dir.path().join("clone");
1320        let started = Instant::now();
1321        let err = run_git_clone(
1322            &format!("git://127.0.0.1:{port}/served"),
1323            &dest,
1324            Duration::from_secs(1),
1325            &[],
1326        )
1327        .expect_err("a silent peer times out");
1328        assert_eq!(err.code(), "invalid_repo_url");
1329        assert!(
1330            started.elapsed() < Duration::from_secs(8),
1331            "the clone was killed, not waited out: {:?}",
1332            started.elapsed()
1333        );
1334        drop(held);
1335    }
1336}