Skip to main content

gunnar_server/
mirror_fetch.rs

1//! **What `Mirror.Sync` actually does**: one fetch from an upstream, into this
2//! box's own storage engine, and one compare-and-swap that moves the
3//! references.
4//!
5//! # There is one client, and two ways to reach a remote
6//!
7//! [`gunnar_wire::fetch_pack`] is the whole protocol — the advertisement, the
8//! `ls-refs`, the want/have negotiation, the sideband demultiplex, the packfile
9//! streamed into a [`PackIngest`](gunnar_wire::PackIngest). It takes a
10//! `&mut dyn BufRead` and a `&mut dyn Write` and cannot tell what is behind
11//! them, which is the seam this module fills twice and only twice:
12//!
13//! | remote | the pair | who built it |
14//! |---|---|---|
15//! | `ssh://`, `git+ssh://`, `git@host:path` | a russh channel's two halves | `nornir-git`, through `gunnar_client::connect_upload_pack` — the **same dialler** replication pushes over |
16//! | `https://`, `http://` | a buffered request/response adapter | [`crate::mirror_http`] |
17//!
18//! Neither is a second protocol implementation, and that is the point. A bug in
19//! negotiation is one bug, in one file, in `gunnar-wire`.
20//!
21//! # This module writes the references, and `fetch_pack` deliberately does not
22//!
23//! `fetch_pack`'s module doc states the rule: it returns a **plan**
24//! (`FetchReport::updates`, each entry a name, the old value and the new one)
25//! and applies nothing, because applying a whole mirror's plan with no gate, no
26//! atomicity across the batch and no way for an operator to see it first is not
27//! something a protocol crate should be able to do.
28//!
29//! So the apply is here, and it is **one [`GitOps::put_refs_cas`]** with the
30//! plan's own `old` as every expectation. That is not tidiness:
31//!
32//! * A mirror is not the only writer. `git push` reaches the same repository
33//!   over SSH and over smart HTTP, and a fetch that read a tip, spent four
34//!   minutes on the network and then wrote unconditionally would **silently
35//!   destroy** whatever landed in between. The `old` in the plan is read from
36//!   the same `GitOps::refs()` snapshot the `have` list came from, so the CAS
37//!   is checking exactly the value the fetch reasoned about.
38//! * A CAS failure is a **FAILED job that names the reference**, never a skip.
39//!   "Synced" about a repository where one branch quietly did not move is the
40//!   answer that makes a mirror untrustworthy, and it reads identically to a
41//!   mirror that worked.
42//! * Every edit or none. A half-applied plan leaves the repository at a state
43//!   no upstream ever had.
44//!
45//! # …and the batch goes through branch protection first
46//!
47//! A mirror moves references, and until 2026-09-11 it was the only writer on
48//! this box that moved them without asking [`BranchProtectionGate`]. That is
49//! not a smaller version of a push: `Mirror.Sync` needs **Write** and
50//! `Policy.SetProtection` needs **Admin**, so a principal who may not configure
51//! a protection rule could register a mirror against the repository it protects
52//! and have the upstream's tips applied over it — a non-fast-forward onto a ref
53//! whose rule says `allow_force_push: false`, applied by the one path that
54//! never looked. See [`Protection`] for what is decided here and what is
55//! refused as undecidable.
56//!
57//! # The credential is never in this module's output
58//!
59//! The value is fetched from the sealed secret store, held for the length of
60//! one dial, and never formatted. An SSH key is materialised into a
61//! `0600` temporary file because `nornir-git` authenticates from a path and
62//! this tree does not fork a second dialler to change that (one implementation); the file is
63//! unlinked when the fetch returns, on every path including a panic, because
64//! [`tempfile::NamedTempFile`] unlinks on `Drop`. A token becomes an
65//! `Authorization` header value inside [`crate::mirror_http`] and nothing else.
66//! Errors that mention a URL go through [`scrub`] first.
67
68use std::io::{BufRead, Write};
69use std::sync::atomic::{AtomicU64, Ordering};
70use std::sync::Arc;
71
72use gix_hash::ObjectId;
73use gunnar_core::MirrorCredentialKind;
74use gunnar_store::git_storage_trait::{GitOps, RefCas};
75use gunnar_wire::fetch_pack::{self, FetchPack, FetchProgress, FetchReport, LocalRefs};
76
77use crate::job_queue::Reporter;
78use crate::mirror_queue::{Mirror, MirrorOrder};
79use crate::stores::ServerStores;
80
81/// The tracing target a sync's lines carry — the same one every other git
82/// transfer on this box uses, so `RUST_LOG=gunnar::git=info` selects a mirror
83/// as well as a clone.
84const TARGET_GIT: &str = "gunnar::git";
85
86/// **The `known_hosts` a mirror's SSH dial trusts**, under the data root.
87///
88/// Not `~/.ssh/known_hosts`, and this is the same ruling replication's
89/// `REPLICATION_KNOWN_HOSTS` makes for the push direction: a server's identity
90/// is the box, not a home directory. The appliance has no home directory at
91/// all, so the default would be a file that cannot be written and a
92/// trust-on-first-use that silently never remembers — every sync a first
93/// contact, which is the same as no host-key checking with extra steps.
94///
95/// A separate file from replication's, because they are separate rosters: the
96/// standbys this box pushes to and the upstreams it pulls from are different
97/// sets of machines, and a pin acquired for one is not consent for the other.
98pub const MIRROR_KNOWN_HOSTS: &str = "mirror_known_hosts";
99
100/// **The file that makes a mirror's SSH dial refuse an unknown host key**,
101/// under the data root beside [`MIRROR_KNOWN_HOSTS`].
102///
103/// Without it a mirror dials trust-on-first-use: an upstream this box has never
104/// seen is recorded and trusted, and only a *changed* key is refused. That is
105/// the right default for a developer's laptop and a defensible one for a server
106/// being brought up without a provisioning step — it is what replication's own
107/// `strict_host_key` defaults to, and its comment already says that on "is the
108/// right setting for anything that outlives an afternoon". The mirror side had
109/// no way to say on at all: the policy was a literal in [`dial_ssh`].
110///
111/// A file rather than a field on `MirrorRecord`, for the reason
112/// [`MIRROR_KNOWN_HOSTS`] is a file under the data root: the roster of hosts
113/// this box will pull from is the box's, not one repository's, and the pin it
114/// is checked against is already per-box. Create it once, and every mirror on
115/// the box requires a `known_hosts` entry that somebody put there.
116pub const MIRROR_STRICT_HOST_KEY: &str = "mirror_strict_host_key";
117
118/// Does this box refuse an unknown upstream host key? See
119/// [`MIRROR_STRICT_HOST_KEY`].
120///
121/// The presence of the file is read at each dial rather than cached, so an
122/// operator who creates it does not have to restart the server to mean it.
123pub fn strict_host_keys(data_root: &std::path::Path) -> bool {
124    data_root.join(MIRROR_STRICT_HOST_KEY).exists()
125}
126
127/// **The file that lets this box's mirrors dial an address that is not
128/// publicly routable**, under the data root beside [`MIRROR_KNOWN_HOSTS`].
129///
130/// A file and not an environment variable, for the reason `wg.rs` writes out at
131/// length: an env var is process-global, so a rule that can only be exercised
132/// by `set_var` is a rule whose tests flake the moment `cargo test` runs them
133/// on two threads. A file under the data root is per-box, survives a restart,
134/// is visible to an operator looking at the box, and each test gets its own.
135///
136/// It exists because refusing every private destination outright would be a
137/// product regression, not a fix: mirroring from a forge on the operator's own
138/// LAN is an ordinary thing for an appliance to do. What must not be ordinary
139/// is a **low-privileged** caller getting it for free — see
140/// [`check_destination`].
141pub const MIRROR_ALLOW_PRIVATE: &str = "mirror_allow_private_destinations";
142
143/// **The credential a mirror dials with**, in the one shape it exists in
144/// memory.
145///
146/// Deliberately not `Debug`, `Clone` or `Serialize`. The compiler is the only
147/// mechanism that reliably stops a secret being formatted into a log line by
148/// somebody adding a `{:?}` three months from now, so the derive that would
149/// allow it is simply absent.
150pub enum Credential {
151    /// No credential. A public upstream needs none, and a mirror of one works.
152    None,
153    /// An OpenSSH private key, for an `ssh://` upstream.
154    SshKey(Vec<u8>),
155    /// A token, for an `https://` upstream. `username` empty means `Bearer`.
156    Token { username: String, secret: String },
157}
158
159impl Credential {
160    fn kind(&self) -> MirrorCredentialKind {
161        match self {
162            Credential::None => MirrorCredentialKind::None,
163            Credential::SshKey(_) => MirrorCredentialKind::SshKey,
164            Credential::Token { .. } => MirrorCredentialKind::Token,
165        }
166    }
167}
168
169/// **Which transport a remote URL names.**
170///
171/// Decided by the URL and by nothing else — not by which credential happens to
172/// be stored, and not by trying one and falling back. A fallback would turn "I
173/// gave you the wrong kind of key" into "your upstream is down", and those go
174/// to two different people.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum Wire {
177    /// `ssh://`, `git+ssh://`, or the scp-like `git@host:ns/repo.git`.
178    Ssh,
179    /// `https://` or `http://`.
180    Http,
181}
182
183impl Wire {
184    /// The word an operator sees in a refusal.
185    fn name(self) -> &'static str {
186        match self {
187            Wire::Ssh => "ssh",
188            Wire::Http => "smart http",
189        }
190    }
191}
192
193/// **What transport `url` names, or a refusal that says what gunnar can dial.**
194///
195/// The scp-like form is recognised the way git recognises it: a `:` before any
196/// `/`, with no scheme. A URL that is neither is refused rather than guessed
197/// at — a mirror registered against a path on the local disk would otherwise
198/// look like it was configured and then fail on the first sync with a
199/// connection error.
200pub fn classify(url: &str) -> Result<Wire, String> {
201    let url = url.trim();
202    if url.starts_with("ssh://") || url.starts_with("git+ssh://") {
203        return Ok(Wire::Ssh);
204    }
205    if url.starts_with("https://") || url.starts_with("http://") {
206        return Ok(Wire::Http);
207    }
208    if url.contains("://") {
209        return Err(format!(
210            "{} names a scheme gunnar cannot mirror from. The two transports are `ssh://` \
211             and `https://`",
212            scrub(url)
213        ));
214    }
215    // `git@host:ns/repo.git` — a colon before any slash, and a host in front
216    // of it.
217    match (url.find(':'), url.find('/')) {
218        (Some(colon), slash) if colon > 0 && slash.is_none_or(|slash| colon < slash) => {
219            Ok(Wire::Ssh)
220        }
221        _ => Err(format!(
222            "{url:?} is not a remote gunnar can mirror from. Write it as \
223             `ssh://git@host/ns/repo.git`, `git@host:ns/repo.git` or `https://host/ns/repo.git`"
224        )),
225    }
226}
227
228// ── where a mirror may dial ──────────────────────────────────────────────────
229
230/// **The host and port a mirror URL names**, in the three shapes
231/// [`classify`] admits.
232///
233/// Deliberately not a URL parser: the only question asked of this string is
234/// *which machine would be dialled*, and the answer is the authority. A
235/// userinfo half is dropped — `git@host` names `host` — and an IPv6 literal
236/// keeps its brackets off so it can be parsed as an address.
237pub(crate) fn authority(url: &str) -> Result<(String, u16), String> {
238    let url = url.trim();
239    let (default_port, rest) = if let Some(rest) = url.strip_prefix("ssh://") {
240        (22, rest)
241    } else if let Some(rest) = url.strip_prefix("git+ssh://") {
242        (22, rest)
243    } else if let Some(rest) = url.strip_prefix("https://") {
244        (443, rest)
245    } else if let Some(rest) = url.strip_prefix("http://") {
246        (80, rest)
247    } else {
248        // The scp-like `git@host:ns/repo.git`. `classify` has already decided
249        // this shape is ssh; the colon here separates a PATH, never a port.
250        // The userinfo goes first so a `@` inside it cannot be read as a host,
251        // and a bracketed literal is taken whole — `[::1]:path` is all colons
252        // and splitting on the first one would yield `[`.
253        let rest = url.rsplit('@').next().unwrap_or_default();
254        let host = match rest.strip_prefix('[') {
255            Some(inside) => inside.split(']').next().unwrap_or_default(),
256            None => rest.split(':').next().unwrap_or_default(),
257        };
258        return non_empty(host, url).map(|host| (host, 22));
259    };
260    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
261    let authority = authority.rsplit('@').next().unwrap_or_default();
262    // `[::1]:9418` and `[::1]`, before the bare-host split, because an IPv6
263    // literal is all colons.
264    if let Some(rest) = authority.strip_prefix('[') {
265        let Some((host, tail)) = rest.split_once(']') else {
266            return Err(format!("{} names an unterminated IPv6 literal", scrub(url)));
267        };
268        let port = match tail.strip_prefix(':') {
269            None => default_port,
270            Some(digits) => digits
271                .parse()
272                .map_err(|_| format!("{} names a port that is not a number", scrub(url)))?,
273        };
274        return non_empty(host, url).map(|host| (host, port));
275    }
276    match authority.split_once(':') {
277        None => non_empty(authority, url).map(|host| (host, default_port)),
278        Some((host, digits)) => {
279            let port = digits
280                .parse()
281                .map_err(|_| format!("{} names a port that is not a number", scrub(url)))?;
282            non_empty(host, url).map(|host| (host, port))
283        }
284    }
285}
286
287fn non_empty(host: &str, url: &str) -> Result<String, String> {
288    if host.is_empty() {
289        return Err(format!("{} names no host to dial", scrub(url)));
290    }
291    Ok(host.to_owned())
292}
293
294/// **Refuse a destination this server can reach and its caller cannot** — the
295/// pure half, with the addresses handed in.
296///
297/// `Mirror.Add` needs Admin and `Mirror.Sync` needs Write, but *every enrolled
298/// principal is intrinsically Admin in its own namespace*
299/// (`AuthTable::owns_account`), so `Mirror.Add{create_repo}` against
300/// `alice/anything` is available to anybody with a key. Before this check the
301/// only thing standing between that caller and an arbitrary request from the
302/// server's own network position was `remote_url.trim().is_empty()`: the cloud
303/// metadata endpoint at `169.254.169.254`, a control plane on `127.0.0.1`, a
304/// printer on `10.0.0.0/8` — all dialled by the appliance, with the appliance's
305/// routes, and the outcome partly readable back through `Mirror.Status`.
306///
307/// **Every** resolved address has to be routable, not merely the first: a name
308/// that resolves to one public address and one loopback address is the
309/// rebinding shape, and taking the best of the two is how a check like this
310/// gets to be decorative.
311///
312/// The ranges are written out rather than taken from `IpAddr::is_global`, which
313/// is unstable, and each line is the reason it is there.
314pub(crate) fn address_verdict(host: &str, addrs: &[std::net::IpAddr]) -> Result<(), String> {
315    if addrs.is_empty() {
316        return Err(format!("{host} resolves to no address at all"));
317    }
318    for addr in addrs {
319        if let Some(why) = not_routable(*addr) {
320            return Err(format!(
321                "{host} resolves to {addr}, which is {why}. A mirror dials from the server, so a \
322                 destination the caller could not reach on their own is the server's network \
323                 position being borrowed. Create `{MIRROR_ALLOW_PRIVATE}` under this box's data \
324                 root to allow it deliberately."
325            ));
326        }
327    }
328    Ok(())
329}
330
331/// Why `addr` is not somewhere an arbitrary caller should be able to point this
332/// server, or `None` when it is an ordinary public address.
333fn not_routable(addr: std::net::IpAddr) -> Option<&'static str> {
334    use std::net::IpAddr;
335    match addr {
336        IpAddr::V4(v4) => {
337            let [a, b, c, _] = v4.octets();
338            match () {
339                _ if v4.is_unspecified() => Some("the unspecified address"),
340                _ if v4.is_loopback() => Some("loopback — this server itself"),
341                _ if v4.is_private() => Some("a private network (RFC 1918)"),
342                _ if v4.is_link_local() => {
343                    Some("link-local (RFC 3927), the range cloud metadata services answer on")
344                }
345                _ if v4.is_broadcast() => Some("the broadcast address"),
346                _ if v4.is_multicast() => Some("multicast"),
347                _ if v4.is_documentation() => Some("a documentation range (RFC 5737)"),
348                // 100.64.0.0/10, carrier-grade NAT: the provider's own network,
349                // not the caller's.
350                _ if a == 100 && (64..128).contains(&b) => Some("carrier-grade NAT (RFC 6598)"),
351                // 192.0.0.0/24, IETF protocol assignments.
352                _ if a == 192 && b == 0 && c == 0 => Some("an IETF protocol assignment"),
353                // 198.18.0.0/15, benchmarking.
354                _ if a == 198 && (b == 18 || b == 19) => Some("a benchmarking range (RFC 2544)"),
355                // 240.0.0.0/4, reserved.
356                _ if a >= 240 => Some("a reserved range"),
357                _ => None,
358            }
359        }
360        // A v4 address written the long way is the same address. Checked
361        // first, or `::ffff:127.0.0.1` walks straight past every arm below it.
362        IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => {
363            not_routable(IpAddr::V4(v6.to_ipv4_mapped().expect("just matched")))
364        }
365        IpAddr::V6(v6) => {
366            let segments = v6.segments();
367            match () {
368                _ if v6.is_unspecified() => Some("the unspecified address"),
369                _ if v6.is_loopback() => Some("loopback — this server itself"),
370                _ if v6.is_multicast() => Some("multicast"),
371                // fc00::/7, unique local.
372                _ if segments[0] & 0xfe00 == 0xfc00 => Some("a unique-local address (RFC 4193)"),
373                // fe80::/10, link-local.
374                _ if segments[0] & 0xffc0 == 0xfe80 => Some("link-local (RFC 4291)"),
375                // 2001:db8::/32, documentation.
376                _ if segments[0] == 0x2001 && segments[1] == 0x0db8 => {
377                    Some("a documentation range (RFC 3849)")
378                }
379                _ => None,
380            }
381        }
382    }
383}
384
385/// [`address_verdict`] with the resolution and the opt-out file read — the
386/// edge, and the only part of this that touches the world.
387///
388/// # The window this does not close, said rather than glossed
389///
390/// The name is resolved here and again by `ureq` or by the ssh dialler a moment
391/// later, so a resolver that answers differently the second time (DNS
392/// rebinding) is not stopped by this. Closing that needs the dial pinned to the
393/// address that was checked, which is a change inside `ureq`'s and
394/// `nornir-git`'s connect paths rather than in this file. What this does close
395/// is the whole of the *static* case — a URL that simply names an internal
396/// address — which is what `Mirror.Add` accepted unchecked.
397pub fn check_destination(url: &str, data_root: &std::path::Path) -> Result<(), String> {
398    use std::net::ToSocketAddrs as _;
399    if data_root.join(MIRROR_ALLOW_PRIVATE).exists() {
400        return Ok(());
401    }
402    let (host, port) = authority(url)?;
403    // A literal needs no resolver, and asking one for it would let a DNS server
404    // answer a question that has no question in it.
405    if let Ok(addr) = host.parse::<std::net::IpAddr>() {
406        return address_verdict(&host, &[addr]);
407    }
408    let addrs: Vec<std::net::IpAddr> = (host.as_str(), port)
409        .to_socket_addrs()
410        .map_err(|err| format!("{host} could not be resolved: {err}"))?
411        .map(|socket| socket.ip())
412        .collect();
413    address_verdict(&host, &addrs)
414}
415
416/// **Refuse a credential that cannot be offered over the transport the URL
417/// names — by name, before anything is dialled.**
418///
419/// The failure this prevents is the quiet one. An SSH key against an `https://`
420/// remote is not "authentication failed": there is nowhere in an HTTP request
421/// to put a private key, so the request goes out anonymous and the upstream
422/// answers 401 — and the operator, who can see that a credential IS stored,
423/// concludes the key is wrong and rotates it. Forever. The same in reverse: a
424/// token has no place in an SSH publickey exchange, so the dial falls through
425/// to whatever else is offerable and fails on authentication with no mention of
426/// the token at all.
427///
428/// A stored credential of no kind is not a mismatch — a public upstream mirrors
429/// fine with nothing, and that path must keep working.
430pub fn check_credential(wire: Wire, credential: &Credential) -> Result<(), String> {
431    match (wire, credential) {
432        (_, Credential::None) => Ok(()),
433        (Wire::Ssh, Credential::SshKey(_)) => Ok(()),
434        (Wire::Http, Credential::Token { .. }) => Ok(()),
435        (wire, held) => Err(format!(
436            "this mirror's remote is dialled over {}, and the credential stored for it is {}. \
437             {} cannot carry it, so the fetch would go out unauthenticated and be refused as \
438             though no credential were set. Store {} instead, with `Mirror.SetCredential`.",
439            wire.name(),
440            held.kind().name(),
441            wire.name(),
442            match wire {
443                Wire::Ssh => "an ssh private key",
444                Wire::Http => "a token",
445            }
446        )),
447    }
448}
449
450// ── the gate ─────────────────────────────────────────────────────────────────
451
452/// **The repository's branch protection, over the batch a mirror is about to
453/// apply.**
454///
455/// One implementation: this is `gunnar_policy::BranchProtectionGate`, the same
456/// object `receive_pack`'s [`crate::transport::PolicyGate`] evaluates, reading
457/// the same document through the same [`crate::transport::load_policy`]. A
458/// second copy of "what does `allow_force_push` mean" is precisely the pair
459/// that drifts, and the three independent ways past branch protection this
460/// repository has had are all the same shape: a second writer that does what
461/// the gated writer does, without the gate.
462///
463/// # What a mirror can decide, and the one thing it cannot
464///
465/// `allow_create`, `allow_delete` and `allow_force_push` are decidable from the
466/// batch and the objects, which are already in the store when [`apply`] runs.
467/// `require_signature` is decidable too — but only against the set of objects
468/// the fetch introduced, so a repository carrying any protection rule makes the
469/// ingest ask for that set ([`crate::storage::ObjectSet::Required`]) instead of
470/// paying nothing for it, exactly as a push does when a gate is live.
471///
472/// `allowed_principals` is the one that cannot be decided here, and it is
473/// refused rather than skipped. The caller's identity does not cross the
474/// control-plane seam on this verb — `mirror_sync(&self, path)` carries a
475/// repository and nothing about who asked — so there is no principal to compare
476/// against the list. "I could not tell" is not "it is fine", so a rule that
477/// names its principals refuses the whole sync by name, and the operator either
478/// drops the rule or stops mirroring onto the refs it covers.
479struct Protection {
480    gate: gunnar_policy::BranchProtectionGate,
481    keys: gunnar_policy::StaticKeyDirectory,
482    repo: gunnar_policy::RepoId,
483}
484
485impl Protection {
486    /// The rules covering `repo`, or `None` when it has none configured.
487    ///
488    /// Read **before** the fetch, because whether any rule exists is what
489    /// decides if the ingest pays for an object-set enumeration — the same
490    /// ordering, for the same reason, as `receive_pack`'s.
491    ///
492    /// `Err` is a policy document that could not be read, and it REFUSES the
493    /// sync — the same fail-closed rule the push path has (GUN-007). A mirror
494    /// that synced with its gates disabled because the policy did not parse
495    /// would be the bypass GUN-032 closed, back through the side door.
496    fn open(
497        stores: &ServerStores,
498        row: &gunnar_core::Repo,
499        git_dir: &std::path::Path,
500    ) -> std::result::Result<Option<Self>, String> {
501        let config = crate::transport::load_policy(
502            stores,
503            row,
504            &git_dir.join(crate::transport::POLICY_FILE),
505        )
506        .map_err(|why| format!("refusing to mirror into {}: {why}", row.key()))?;
507        if config.protection.is_empty() {
508            return Ok(None);
509        }
510        Ok(Some(Protection {
511            gate: gunnar_policy::BranchProtectionGate::new(config.protection.clone()),
512            keys: crate::transport::key_directory(stores),
513            repo: gunnar_policy::RepoId::new(&row.namespace, &row.name),
514        }))
515    }
516
517    /// **Refuse the batch, or let it through.** `Err` is the refusal a FAILED
518    /// job carries, and nothing has been written when it is returned.
519    ///
520    /// `new_objects` is the fetch's own receipt. An ingest that did not
521    /// enumerate hands back the reason rather than an empty set, and an empty
522    /// set here would be a silent always-allow for `require_signature` — so the
523    /// `Err` arm refuses, quoting why.
524    fn check(
525        &self,
526        git: &dyn gunnar_store::git_storage_trait::GitServe,
527        updates: &[gunnar_policy::RefUpdate],
528        new_objects: std::result::Result<&[ObjectId], &str>,
529    ) -> Result<(), String> {
530        use gunnar_policy::Gate as _;
531
532        // The field with no answer on this path, refused first and by name.
533        for update in updates {
534            for rule in self.gate.rules() {
535                if !rule.pattern.matches_ref(&update.name) {
536                    continue;
537                }
538                if rule.allowed_principals.is_some() {
539                    return Err(format!(
540                        "{} is protected by {}, which names the principals that may move it. A \
541                         mirror sync carries no principal — the control-plane verb takes a \
542                         repository and not a caller — so that rule cannot be satisfied and this \
543                         sync is refused rather than applied unchecked. Nothing was moved.",
544                        update.name, rule.pattern
545                    ));
546                }
547                break;
548            }
549        }
550
551        let new_objects = new_objects.map_err(|reason| {
552            format!(
553                "this repository has branch protection and the fetched pack's object set could \
554                 not be enumerated ({reason}), so the gate could not decide. Nothing was moved."
555            )
556        })?;
557        // Never consulted: every rule that could read it has already refused
558        // above. Named rather than left as a plausible-looking value.
559        let principal = gunnar_policy::Principal::machine("mirror");
560        let ctx = gunnar_policy::PushContext::new(
561            &principal,
562            &self.repo,
563            updates,
564            new_objects,
565            git,
566            &self.keys,
567        );
568        match self.gate.check(&ctx) {
569            Ok(gunnar_policy::Verdict::Allow) => Ok(()),
570            Ok(gunnar_policy::Verdict::Reject(rejection)) => Err(format!(
571                "branch protection refused this mirror's reference batch: {}. Nothing was moved.",
572                rejection.reason
573            )),
574            // A gate that could not decide refuses; it does not allow.
575            Err(err) => Err(format!(
576                "branch protection could not decide this mirror's reference batch: {err}. \
577                 Nothing was moved."
578            )),
579        }
580    }
581}
582
583// ── the local half ───────────────────────────────────────────────────────────
584
585/// **What this repository already holds**, as `fetch_pack`'s have-source.
586///
587/// One `GitOps::refs()` call, one snapshot, taken inside `FetchPack::fetch` so
588/// the `have` list and every `old` in the plan come from the same read — a
589/// reference that moves under the fetch cannot make the plan promise an old
590/// value the store never held.
591///
592/// `HEAD` and the pseudo-refs are not rows in `refs()` at all, and a purely
593/// symbolic row under `refs/` carries no oid and is skipped: it is not a thing
594/// to offer as a `have`, and `fetch_pack`'s own doc says an unborn ref should
595/// simply be absent.
596struct StoreRefs<'a> {
597    git: &'a dyn gunnar_store::git_storage_trait::GitServe,
598    /// Objects this store holds that no reference names yet — an interrupted
599    /// import's ([`already_here`]). Offered as `have`s under names the plan
600    /// never matches, so they change what is sent and nothing that is written.
601    extra_haves: Vec<ObjectId>,
602}
603
604impl LocalRefs for StoreRefs<'_> {
605    fn tips(&self) -> gunnar_wire::Result<Vec<(String, ObjectId)>> {
606        let rows = GitOps::refs(self.git).map_err(|err| {
607            gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
608                "reading this repository's refs to offer them as haves: {err:#}"
609            )))
610        })?;
611        let mut out = Vec::with_capacity(rows.len());
612        for row in rows {
613            if row.name == "HEAD" {
614                continue;
615            }
616            let Some(raw) = row.oid.as_deref() else {
617                continue;
618            };
619            let Ok(oid) = gix_hash::oid::try_from_bytes(raw) else {
620                return Err(gunnar_wire::Error::Store(gunnar_store::Error::backend(
621                    format!("{} holds an oid this store cannot decode", row.name),
622                )));
623            };
624            out.push((row.name, oid.to_owned()));
625        }
626        for (n, oid) in self.extra_haves.iter().enumerate() {
627            out.push((format!("refs/gunnar/resume/{n}"), *oid));
628        }
629        Ok(out)
630    }
631}
632
633/// **The queue's progress field, written from the wire and from the walk.**
634///
635/// Three publishers, one job record, and each of the three is the only place on
636/// the system that knows its own number:
637///
638/// | published by | what | when |
639/// |---|---|---|
640/// | [`FetchProgress::objects_total`] | the denominator | once, the instant the packfile header is read |
641/// | [`FetchProgress::pack_bytes`] | the byte count | as the stream is consumed, while the network is the bottleneck |
642/// | [`PackProgress::indexed`] | the numerator | once per object, as the engine's pack walk splits them |
643///
644/// # Why the numerator arrives from the other side of the seam
645///
646/// `gunnar-wire` reads bytes 8..12 of the header and can publish `(0, N)`
647/// before an object is stored — twelve bytes, and it is the reason the
648/// denominator is live at all. It cannot publish the numerator: counting
649/// objects as they pass means inflating every one of them, which is a second
650/// pack decoder, and gunnar has exactly one (one implementation, and `fetch_pack`'s own
651/// module doc refuses it in those words). The one decoder is the storage
652/// engine's walk, which has to split every entry anyway, so the count is free
653/// exactly there and nowhere else. This type is where the two halves meet.
654///
655/// # `total` is remembered here, and that is not a second counter
656///
657/// [`PackProgress::indexed`] carries its own `total` — the count the engine
658/// read from the same header — and it is *checked* against the one the wire
659/// announced rather than trusted, because two readings of one field that
660/// disagree mean the ingest is being handed a different pack from the one that
661/// was negotiated. `fetch_pack` makes the same comparison at the end, on the
662/// receipt; this is the same fact asserted while there is still a transfer to
663/// abandon.
664struct QueueProgress<'a> {
665    report: &'a Reporter<Mirror>,
666    total: AtomicU64,
667    /// A second sink, for a caller that has to see the *series* rather than the
668    /// job's latest reading. See [`sync_once_watching`].
669    watching: Option<&'a dyn git_storage_trait::PackProgress>,
670}
671
672impl QueueProgress<'_> {
673    /// **The one place a sync's progress pair is written.**
674    ///
675    /// All three publishers go through here — the header's `(0, N)`, the
676    /// engine's per-object readings, and [`finish`]'s `(N, N)` — so the job and
677    /// any watcher see the same values in the same order. Two routes to the
678    /// same field would be two places the series could differ, and the series
679    /// is the whole subject.
680    fn publish(&self, done: u64, total: u64) {
681        self.report.progress(done, total);
682        if let Some(watcher) = self.watching {
683            watcher.indexed(done, total);
684        }
685    }
686}
687
688impl FetchProgress for QueueProgress<'_> {
689    fn objects_total(&self, objects: u32) {
690        self.total.store(u64::from(objects), Ordering::SeqCst);
691        self.publish(0, u64::from(objects));
692    }
693
694    fn pack_bytes(&self, streamed: u64) {
695        self.report.report(|mirror| mirror.bytes = streamed);
696    }
697}
698
699impl git_storage_trait::PackProgress for QueueProgress<'_> {
700    fn indexed(&self, done: u64, total: u64) {
701        // The wire's denominator wins. An engine that read a different total
702        // from the same twelve bytes is a bug worth a log line, not a reason to
703        // renumber a progress bar mid-flight and leave a console watching a
704        // percentage that jumped sideways.
705        let announced = self.total.load(Ordering::SeqCst);
706        let total = if announced == 0 || announced == total {
707            total
708        } else {
709            tracing::warn!(
710                target: TARGET_GIT,
711                announced,
712                indexed_total = total,
713                "the packfile header the wire read and the one the store read declare \
714                 different object counts; the wire's is the one being rendered"
715            );
716            announced
717        };
718        self.publish(done, total);
719    }
720}
721
722// ── the fetch ────────────────────────────────────────────────────────────────
723
724/// **One sync, start to finish.** The [`crate::mirror_queue::MirrorRunner`]
725/// body.
726///
727/// Errors are `String` because they are going onto a job a console will read,
728/// not up a call stack — the same reason `gc_queue`'s runner returns one.
729pub fn sync_once(
730    stores: &Arc<ServerStores>,
731    order: &MirrorOrder,
732    credential: &Credential,
733    report: &Reporter<Mirror>,
734) -> Result<(), String> {
735    sync_once_watching(stores, order, credential, report, None)
736}
737
738/// [`sync_once`], with a **second** place the object count is published to.
739///
740/// # Why this door exists, and why it is not a test hook
741///
742/// [`Reporter::progress`] *overwrites*, by design — its own doc says a progress
743/// that accumulated would be a second counter to keep right — so the job record
744/// holds the latest reading and never the series. That is correct for a console,
745/// which only ever wants the latest reading, and it means the one thing that
746/// cannot be established from the job record afterwards is **whether the count
747/// moved at all**. A publisher that fires `(0, N)` and then `(N, N)` and a
748/// publisher that fires five hundred times in between leave the job in exactly
749/// the same state, so from the outside they are the same publisher.
750///
751/// That is measure-never-assert in its plainest form: a stub is
752/// indistinguishable from an
753/// implementation unless something can see it work — and it is precisely the
754/// defect this seam was opened to close: `(0, N) → (N, N)` was the shape for as
755/// long as no engine published from inside its pack walk. So the series gets a
756/// door, and the acceptance test stands at it. There is one other caller,
757/// [`sync_once`], which passes `None`.
758///
759/// The sink is *additional*: the job is written exactly as before, in the same
760/// order, whether or not anybody is watching. A test that changed what the
761/// production path publishes would be testing itself.
762pub fn sync_once_watching(
763    stores: &Arc<ServerStores>,
764    order: &MirrorOrder,
765    credential: &Credential,
766    report: &Reporter<Mirror>,
767    watching: Option<&dyn git_storage_trait::PackProgress>,
768) -> Result<(), String> {
769    let wire = classify(&order.upstream)?;
770    check_credential(wire, credential)?;
771    // Checked again on the worker, and not only at the door where
772    // `Mirror.Add` checked it: a registration made before this rule existed,
773    // or a name whose A record has moved since, reaches the dial through here
774    // and nowhere else.
775    check_destination(&order.upstream, stores.root())?;
776
777    // Resolved here, on the worker, and not at enqueue time: a repository can
778    // be deleted while its sync waits, and discovering that when the work
779    // starts is a FAILED job saying so, which is the truth.
780    let repo_ref = gunnar_core::resolve_repo_path(&order.path).map_err(|err| {
781        format!(
782            "`{}` is not a valid <namespace>/<name>: {err:#}",
783            order.path
784        )
785    })?;
786    let existing = stores
787        .catalog()
788        .get(&repo_ref)
789        .map_err(|err| format!("reading the catalog row for {}: {err:#}", order.path))?;
790    // **An import's repository is made HERE, by the first sync that reaches
791    // the upstream** (owner order 2026-10-18; `MirrorRecord::creates_repo`).
792    let to_create = existing.is_none()
793        && stores
794            .mirrors()
795            .get(&repo_ref)
796            .map_err(|err| format!("reading the registration for {}: {err:#}", order.path))?
797            .is_some_and(|record| record.creates_repo);
798    if existing.is_none() && !to_create {
799        return Err(format!("{} is no longer a live repository", order.path));
800    }
801    // **An encrypted repository is never fetched into** (wiring tracer B
802    // 2026-10-08): the ingest below runs no encrypted gate (`PushIngest::new`),
803    // so an upstream's plaintext would be stored and framed for the twin.
804    // `Mirror.Add` / `Repos.SetEncrypted` refuse making such a mirror; this is
805    // a registration older than that rule, or a flag set below the door.
806    if existing.as_ref().is_some_and(|row| row.encrypted) {
807        return Err(format!(
808            "{} is encrypted, so it is not fetched into: a mirror copies another server's \
809             plaintext, and an encrypted repository holds only what its members sealed \
810             (remove the mirror)",
811            order.path
812        ));
813    }
814    // **The namespace's room, asked of the same gates a push is held to**
815    // (2026-10-02). A suspended namespace or one already at its cap fetches
816    // nothing — refused here, before the dial, in the gate's own words — and
817    // the fetch is held to the tightest ceiling in the chain as it streams.
818    let ceiling = room_for(&stores.plan_gates(), repo_ref.namespace()).map_err(|(why, line)| {
819        report.report(|r| r.refused = why);
820        line
821    })?;
822
823    let auth = match credential {
824        Credential::Token { username, secret } if username.is_empty() => {
825            Some(format!("Bearer {secret}"))
826        }
827        Credential::Token { username, secret } => {
828            Some(format!("Basic {}", base64_basic(username, secret)))
829        }
830        _ => None,
831    };
832
833    let row = match existing {
834        Some(row) => row,
835        None => create_from_upstream(stores, &repo_ref, order, wire, credential, auth.clone())?,
836    };
837    let fetched = fetch_into(
838        stores, &row, order, wire, credential, auth, ceiling, report, watching,
839    )
840    .and_then(|advertised| landed(stores, &row, order, &advertised).map(|()| advertised));
841    match &fetched {
842        Ok(advertised) => after_landing(stores, &repo_ref, &row, order, advertised),
843        Err(_) if to_create => discard_created(stores, &repo_ref, order),
844        Err(_) => {}
845    }
846    fetched.map(|_| ())
847}
848
849/// **A sync is DONE only when what the upstream advertised is here**
850/// (import-big, 2026-10-26).
851///
852/// Every branch and tag the upstream advertised must be a reference in this
853/// repository when the sync says it is done, and an upstream that advertised
854/// none is not an import that worked: it is an empty import, said as a
855/// failure. Read back from the store, after the apply, rather than inferred
856/// from the plan — the plan is what was meant, this is what landed. Before it,
857/// a fetch whose plan came out empty applied nothing and settled DONE over a
858/// repository with no references at all.
859fn landed(
860    stores: &Arc<ServerStores>,
861    row: &gunnar_core::model::Repo,
862    order: &MirrorOrder,
863    advertised: &[gunnar_wire::upload_pack::AdvertisedRef],
864) -> Result<(), String> {
865    let wanted: Vec<&str> = advertised
866        .iter()
867        .filter(|r| fetch_pack::mirror(&r.name) && r.target.is_some())
868        .map(|r| r.name.as_str())
869        .collect();
870    let upstream = scrub(&order.upstream);
871    if wanted.is_empty() {
872        return Err(format!(
873            "{upstream} has no branches or tags, so there was nothing to import into {}. \
874             Check the address, or push a first commit there and sync again.",
875            order.path
876        ));
877    }
878    let store = stores
879        .repo_store(row)
880        .map_err(|err| format!("opening {} to check what landed: {err:#}", order.path))?;
881    let held = GitOps::refs(store.serve()).map_err(|err| {
882        format!(
883            "reading {}'s references to check what landed: {err:#}",
884            order.path
885        )
886    })?;
887    let missing: Vec<&str> = wanted
888        .iter()
889        .copied()
890        .filter(|name| !held.iter().any(|r| r.name == *name && r.oid.is_some()))
891        .collect();
892    if let Some(first) = missing.first() {
893        return Err(format!(
894            "the fetch from {upstream} ended without its references landing: {} of {} \
895             (first {first}) are not in {}. Nothing is reported as imported that is not \
896             here; sync again.",
897            missing.len(),
898            wanted.len(),
899            order.path
900        ));
901    }
902    Ok(())
903}
904
905/// **Ask the upstream what it is, then make the repository to match.**
906///
907/// One `ls-refs` at the upstream's own object format (`ls_remote_any`): the
908/// format becomes the repository's, and `HEAD`'s `symref-target` its default
909/// branch. Nothing is created when the upstream cannot be reached or refuses
910/// (a private repository without its token), so a failed first import leaves
911/// no repository behind.
912fn create_from_upstream(
913    stores: &Arc<ServerStores>,
914    repo_ref: &gunnar_core::RepoRef,
915    order: &MirrorOrder,
916    wire: Wire,
917    credential: &Credential,
918    auth: Option<String>,
919) -> Result<gunnar_core::model::Repo, String> {
920    let advertisement = ls_remote(stores, order, wire, credential, auth)?;
921    let hash = match advertisement.hash_kind {
922        gix_hash::Kind::Sha1 => gunnar_core::HashKind::Sha1,
923        _ => gunnar_core::HashKind::Sha256,
924    };
925    let mut new = gunnar_core::NewRepo::new(repo_ref.clone())
926        .hash_kind(hash)
927        .visibility(gunnar_core::Visibility::Private);
928    new.description = format!("mirror of {}", scrub(&order.upstream));
929    if let Some(branch) = advertised_head(&advertisement.refs) {
930        new = new.default_branch(branch);
931    }
932    tracing::info!(
933        target: TARGET_GIT,
934        event = "mirror.create_from_upstream",
935        repo = %order.path,
936        hash = ?hash,
937        "an import's repository is made at the upstream's object format and default branch"
938    );
939    let row = stores
940        .create_repo(new, false)
941        .map_err(|err| format!("creating {} for the import: {err:#}", order.path))?;
942    stores
943        .repo_store(&row)
944        .map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
945    Ok(row)
946}
947
948/// **One `ls-refs` against the upstream**, at its own object format.
949fn ls_remote(
950    stores: &Arc<ServerStores>,
951    order: &MirrorOrder,
952    wire: Wire,
953    credential: &Credential,
954    auth: Option<String>,
955) -> Result<gunnar_wire::fetch_pack::Advertisement, String> {
956    let listed =
957        |err: gunnar_wire::Error| format!("asking {} what it holds: {err}", scrub(&order.upstream));
958    match wire {
959        Wire::Http => {
960            let (mut reader, mut writer) = open_http(&order.upstream, &order.ca, auth)?;
961            gunnar_wire::ls_remote_any(&mut reader, &mut writer).map_err(listed)
962        }
963        Wire::Ssh => dial_ssh(
964            &order.upstream,
965            credential,
966            &stores.root().join(MIRROR_KNOWN_HOSTS),
967            strict_host_keys(stores.root()),
968            |reader, writer| gunnar_wire::ls_remote_any(reader, writer),
969        )?
970        .map_err(listed),
971    }
972}
973
974/// **What an interrupted import already brought in**, offered back to the
975/// upstream as `have`s (import-big, 2026-10-26).
976///
977/// A repository that holds no branch and no tag is either new or an import
978/// that stopped between its pack landing and its references being written —
979/// a restart in the index, the longest step of a big import. The pack's bytes
980/// were durable before that step, and the indexer resumes them on its own after
981/// the restart; what was missing is only the reference write. So the resumed
982/// sync asks the upstream what it advertises, lets the index catch up, and
983/// offers every advertised tip this store already has as a `have`: the
984/// upstream then sends nothing it sent before, and a 6.6 GB kernel pack is not
985/// fetched (or stored) a second time. A new repository has nothing, and pays
986/// one `ls-refs` for finding out.
987#[allow(clippy::too_many_arguments)]
988fn already_here(
989    stores: &Arc<ServerStores>,
990    git: &dyn gunnar_store::git_storage_trait::GitServe,
991    git_dir: &std::path::Path,
992    order: &MirrorOrder,
993    wire: Wire,
994    credential: &Credential,
995    auth: Option<String>,
996    report: &Reporter<Mirror>,
997) -> Result<Vec<ObjectId>, String> {
998    let held =
999        GitOps::refs(git).map_err(|err| format!("reading {}'s references: {err:#}", order.path))?;
1000    if held
1001        .iter()
1002        .any(|r| fetch_pack::mirror(&r.name) && r.oid.is_some())
1003    {
1004        return Ok(Vec::new());
1005    }
1006    let advertisement = ls_remote(stores, order, wire, credential, auth)?;
1007    // An interrupted import's pack is indexed here: held to a cgroup's memory as the
1008    // first try was (IMPORT-2); its object count is not known again, so no refusal.
1009    let held = crate::import_room::MemoryLimit::read()
1010        .and_then(|l| CgroupHold::start(stores, git_dir, &l));
1011    index_landed(git, git_dir, stores.engine(), report, held.as_ref()).map_err(|err| {
1012        format!(
1013            "{} holds objects this server could not index: {err}",
1014            order.path
1015        )
1016    })?;
1017    let mut out = Vec::new();
1018    for advertised in &advertisement.refs {
1019        let Some(target) = advertised.target else {
1020            continue;
1021        };
1022        if !fetch_pack::mirror(&advertised.name) || out.contains(&target) {
1023            continue;
1024        }
1025        if GitOps::has(git, target.as_bytes()).unwrap_or(false) {
1026            out.push(target);
1027        }
1028    }
1029    if !out.is_empty() {
1030        tracing::info!(
1031            target: TARGET_GIT,
1032            event = "mirror.sync.resumed",
1033            repo = %order.path,
1034            haves = out.len(),
1035            "an import that stopped after its pack landed resumes without fetching it again"
1036        );
1037    }
1038    Ok(out)
1039}
1040
1041/// `HEAD`'s branch, as the upstream advertised it, when it is a branch.
1042fn advertised_head(refs: &[gunnar_wire::upload_pack::AdvertisedRef]) -> Option<String> {
1043    refs.iter()
1044        .find(|r| r.name == "HEAD")
1045        .and_then(|r| r.symref_target.clone())
1046        .filter(|target| {
1047            target.starts_with("refs/heads/") && gunnar_store::refname::validate(target).is_ok()
1048        })
1049}
1050
1051/// A first import that failed after its repository was made: the repository
1052/// goes again, so nothing empty is left behind. Best effort — a removal that
1053/// fails is logged, and the registration still says the repository is to come.
1054fn discard_created(
1055    stores: &Arc<ServerStores>,
1056    repo_ref: &gunnar_core::RepoRef,
1057    order: &MirrorOrder,
1058) {
1059    let removed = stores
1060        .catalog()
1061        .delete(repo_ref)
1062        .map_err(|err| format!("{err:#}"))
1063        .and_then(|_| {
1064            stores
1065                .purge_repo(repo_ref)
1066                .map_err(|err| format!("{err:#}"))
1067        });
1068    if let Err(err) = removed {
1069        tracing::warn!(
1070            target: TARGET_GIT,
1071            repo = %order.path,
1072            %err,
1073            "a failed first import could not remove the repository it had made"
1074        );
1075    }
1076}
1077
1078/// **What a landed sync owes the mirror besides its references**: the
1079/// upstream's default branch (when it holds that branch now) and whether the
1080/// upstream uses Git LFS. Neither fails the sync — the references landed.
1081fn after_landing(
1082    stores: &Arc<ServerStores>,
1083    repo_ref: &gunnar_core::RepoRef,
1084    row: &gunnar_core::model::Repo,
1085    order: &MirrorOrder,
1086    advertised: &[gunnar_wire::upload_pack::AdvertisedRef],
1087) {
1088    let mut row = row.clone();
1089    if let Some(branch) = advertised_head(advertised) {
1090        let held = stores
1091            .repo_store(&row)
1092            .ok()
1093            .and_then(|store| GitOps::refs(store.serve()).ok())
1094            .is_some_and(|refs| refs.iter().any(|r| r.name == branch && r.oid.is_some()));
1095        if held && branch != row.default_branch {
1096            match stores.catalog().set_default_branch(repo_ref, &branch) {
1097                Ok(moved) => {
1098                    row = moved;
1099                    if let Err(err) = stores.repoint_head(&row) {
1100                        tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "pointing HEAD at the upstream's default branch");
1101                    }
1102                }
1103                Err(err) => {
1104                    tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "taking the upstream's default branch");
1105                }
1106            }
1107        }
1108    }
1109    let lfs = uses_lfs(stores, &row);
1110    if let Err(err) = stores.mirrors().set_lfs_detected(repo_ref, lfs) {
1111        tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "recording whether the upstream uses LFS");
1112    }
1113}
1114
1115/// **Does the default branch route files through Git LFS?** A `filter=lfs`
1116/// in its root `.gitattributes` is the sign every LFS repository carries; the
1117/// files it names arrive as pointer files, and their content is not mirrored.
1118fn uses_lfs(stores: &Arc<ServerStores>, row: &gunnar_core::model::Repo) -> bool {
1119    use gunnar_store::git_storage_trait::ObjType;
1120    use gunnar_store::object;
1121    let Ok(store) = stores.repo_store(row) else {
1122        return false;
1123    };
1124    let git = store.serve();
1125    let hash = store.hash_kind();
1126    let Some(tip) = GitOps::refs(git)
1127        .ok()
1128        .and_then(|refs| refs.into_iter().find(|r| r.name == row.default_branch))
1129        .and_then(|r| r.oid)
1130        .and_then(|raw| ObjectId::try_from(&raw[..]).ok())
1131    else {
1132        return false;
1133    };
1134    let read = |id: &ObjectId, want: ObjType| match git.read(id.as_bytes()) {
1135        Ok(Some((kind, bytes))) if kind == want => Some(bytes),
1136        _ => None,
1137    };
1138    let Some(commit) =
1139        read(&tip, ObjType::Commit).and_then(|bytes| object::commit(&bytes, hash).ok())
1140    else {
1141        return false;
1142    };
1143    let Some(tree) = read(&commit.tree, ObjType::Tree) else {
1144        return false;
1145    };
1146    let Ok(Some(entry)) = object::tree_entry_named(&tree, hash, b".gitattributes") else {
1147        return false;
1148    };
1149    read(&entry.oid, ObjType::Blob).is_some_and(|attributes| {
1150        String::from_utf8_lossy(&attributes)
1151            .lines()
1152            .any(|line| !line.trim_start().starts_with('#') && line.contains("filter=lfs"))
1153    })
1154}
1155
1156/// **The fetch and the apply**, into a repository that exists. Answers what
1157/// the upstream advertised, for [`after_landing`].
1158#[allow(clippy::too_many_arguments)]
1159fn fetch_into(
1160    stores: &Arc<ServerStores>,
1161    row: &gunnar_core::model::Repo,
1162    order: &MirrorOrder,
1163    wire: Wire,
1164    credential: &Credential,
1165    auth: Option<String>,
1166    ceiling: Option<gunnar_wire::PackCeiling>,
1167    report: &Reporter<Mirror>,
1168    watching: Option<&dyn git_storage_trait::PackProgress>,
1169) -> Result<Vec<gunnar_wire::upload_pack::AdvertisedRef>, String> {
1170    let row = row.clone();
1171    let store = stores
1172        .repo_store(&row)
1173        .map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
1174    let git = store.serve();
1175    let hash_kind = store.hash_kind();
1176
1177    // Opened BEFORE the ingest, and that ordering is load-bearing for the same
1178    // reason it is on the push path: whether this repository has a protection
1179    // rule at all is what decides if the fetch pays for an object-set
1180    // enumeration.
1181    let protection = Protection::open(stores, &row, store.git_dir())?;
1182
1183    let extra_haves = already_here(
1184        stores,
1185        git,
1186        store.git_dir(),
1187        order,
1188        wire,
1189        credential,
1190        auth.clone(),
1191        report,
1192    )?;
1193    let local = StoreRefs { git, extra_haves };
1194    let progress = QueueProgress {
1195        report,
1196        total: AtomicU64::new(0),
1197        watching,
1198    };
1199    // The ingest a PUSH lands through, unchanged. A fetched pack and a pushed
1200    // pack are the same bytes (`fetch_pack`'s module doc), so this is the same
1201    // storage authority and not a second one.
1202    //
1203    // The object set is asked for exactly when something reads it. A repository
1204    // with a protection rule has a gate that does (`require_signature` walks the
1205    // commits this fetch introduced), so the enumeration is `Required` and an
1206    // engine that cannot produce one must refuse rather than let the gate decide
1207    // on nothing. With no rule configured nobody reads it, and `NotNeeded` keeps
1208    // the fetch from waiting on index work no answer depends on.
1209    //
1210    // The last argument is the numerator's whole route: the ingest hands it to
1211    // the engine, the engine hands it to the pack walk, and the walk publishes
1212    // once per object into `progress` above — which is the same value the wire
1213    // is publishing the denominator and the byte count into. One sink, three
1214    // publishers, no second decoder anywhere on the path.
1215    let ingest = crate::transport::PushIngest::new(
1216        store.git_dir().to_path_buf(),
1217        hash_kind,
1218        stores.engine(),
1219        if protection.is_some() {
1220            crate::storage::ObjectSet::Required
1221        } else {
1222            crate::storage::ObjectSet::NotNeeded
1223        },
1224        Some(&progress),
1225        // A fetched pack is a transaction exactly as a pushed one is: framed before it lands.
1226        Some(crate::transport::PackFrame::of(stores, &row)),
1227    )
1228    // …and held to the data set's room the way a push is (cov-13).
1229    .with_space(stores.space_ledger());
1230    // **Heimdall first** (feature `heimdall`, `crate::heimdall_gate`): with the
1231    // gate installed, the fetched pack is parsed in a Heimdall job and only a
1232    // pack whose signed result checks out reaches the ingest above.
1233    #[cfg(feature = "heimdall")]
1234    let checked = stores
1235        .pack_gate()
1236        .map(|gate| gate.checked_as(&ingest, hash_kind));
1237    #[cfg(feature = "heimdall")]
1238    let ingest: &dyn gunnar_wire::PackIngest = match &checked {
1239        Some(c) => c,
1240        None => &ingest,
1241    };
1242    #[cfg(not(feature = "heimdall"))]
1243    let ingest: &dyn gunnar_wire::PackIngest = &ingest;
1244
1245    let mut fetch = FetchPack::new(hash_kind, &fetch_pack::mirror, &local).with_progress(&progress);
1246    if let Some(ceiling) = ceiling {
1247        fetch = fetch.with_ceiling(ceiling);
1248    }
1249
1250    let outcome = match wire {
1251        Wire::Http => {
1252            let (mut reader, mut writer) = open_http(&order.upstream, &order.ca, auth)?;
1253            fetch.fetch(ingest, &mut reader, &mut writer)
1254        }
1255        Wire::Ssh => dial_ssh(
1256            &order.upstream,
1257            credential,
1258            &stores.root().join(MIRROR_KNOWN_HOSTS),
1259            strict_host_keys(stores.root()),
1260            |reader, writer| fetch.fetch(ingest, reader, writer),
1261        )?,
1262    }
1263    .map_err(|err| {
1264        // Cut at the namespace's ceiling: the plan's refusal, as a field.
1265        if matches!(err, gunnar_wire::Error::OverCeiling(_)) {
1266            report.report(|r| r.refused = crate::mirror_queue::Refusal::PackCap);
1267        }
1268        format!(
1269            "fetching {} from {}: {err}",
1270            order.path,
1271            scrub(&order.upstream)
1272        )
1273    })?;
1274
1275    // **Index the fetched pack ONCE, here, before a single read by oid**
1276    // (import-big, 2026-10-26) — the line `receive_pack::apply` has carried
1277    // since 2026-08-30 and this path never got.
1278    //
1279    // The znippy engine stores a pack's bytes before its oids exist and indexes
1280    // them behind the ack. `apply` reads by oid (the branch-protection gate, and
1281    // `put_refs_cas`, whose dangling-target check asks `has` for every new tip),
1282    // so without this its first read fell into the engine's read-time fallback:
1283    // a whole-pack resolve on this thread beside the indexer resolving the same
1284    // bytes. MEASURED on oden 2026-10-26, torvalds/linux from GitHub (6.60 GB,
1285    // 11 986 735 objects): the import landed only after the two resolvers had
1286    // driven the process to a 19.4 GB peak — past a 16 GB Mini, which is where
1287    // the owner's import of it died with nothing to say so.
1288    //
1289    // A refusal is the backend's own sentence (its memory watchdog gives up by
1290    // name, with what was available and what was needed): the pack is stored
1291    // and could not be indexed on this box, which is "this repository does not
1292    // fit here", never a sync that waits for ever or reads around it.
1293    if outcome.receipt.is_some() {
1294        // **IMPORT-2: the index must fit the memory this server may really use**: the box's
1295        // MemTotal, or the cgroup's memory.max when that is smaller (a container, a
1296        // MemoryMax= unit, where /proc/meminfo is the host's). Refused by name before
1297        // the index when it does not fit with the margin; under a cgroup the store's
1298        // memory guard reads the cgroup's memory, kept fresh while the index runs.
1299        let objects = progress.total.load(Ordering::SeqCst);
1300        let limit = crate::import_room::MemoryLimit::read();
1301        if let Some(limit) = &limit {
1302            crate::import_room::import_room(&order.path, objects, limit)?;
1303        }
1304        let held = limit
1305            .as_ref()
1306            .and_then(|l| CgroupHold::start(stores, store.git_dir(), l));
1307        index_landed(git, store.git_dir(), stores.engine(), report, held.as_ref()).map_err(
1308            |err| {
1309                format!(
1310                "{} was fetched and stored, and this server could not index it: {err}. Nothing \
1311                 was moved. The repository is larger than this server can take in; a server \
1312                 with more memory imports it.",
1313                order.path
1314            )
1315            },
1316        )?;
1317    }
1318    apply(git, order, &outcome, &progress, protection.as_ref())?;
1319    Ok(outcome.advertised)
1320}
1321
1322/// **`GitServe::index_now`, with the index's own progress on the job.**
1323///
1324/// For a kernel-sized pack the index is minutes after the last byte arrived,
1325/// and a job that sat at `N/N` for all of them would read as stalled. So while
1326/// it runs, the engine's index row for this repository (`Engine::index_progress`,
1327/// the same row the dashboard reads) is published as the job's progress: the
1328/// objects indexed of the pack's total. When it returns the pair is the walk's
1329/// again (`finish`).
1330fn index_landed(
1331    git: &dyn gunnar_store::git_storage_trait::GitServe,
1332    git_dir: &std::path::Path,
1333    engine: crate::storage::Engine,
1334    report: &Reporter<Mirror>,
1335    held: Option<&CgroupHold>,
1336) -> Result<usize, String> {
1337    let indexed = indexed_with_progress(
1338        || git.index_now().map_err(|err| format!("{err:#}")),
1339        || {
1340            if let Some(held) = held {
1341                held.refresh();
1342            }
1343            if let Some((_, row)) = engine
1344                .index_progress()
1345                .into_iter()
1346                .find(|(dir, row)| dir == git_dir && row.active && row.objects_total > 0)
1347            {
1348                report.progress(row.objects_done, row.objects_total);
1349            }
1350        },
1351    );
1352    if let Ok(packs) = &indexed {
1353        tracing::info!(
1354            target: TARGET_GIT,
1355            event = "mirror.sync.indexed",
1356            packs,
1357            "the fetched pack was indexed once, before the references were checked"
1358        );
1359    }
1360    indexed
1361}
1362
1363/// **The cgroup's memory, held under one import's index** (IMPORT-2).
1364///
1365/// Only when the limit is a cgroup's: the store's memory guard is pointed at a
1366/// meminfo-shaped file of the cgroup's memory (`import_room::cgroup_meminfo`), refreshed
1367/// every half second by the index's progress poller, with the import's margin as the
1368/// floor. Dropped after the index: the file goes and the store gets the process-wide
1369/// guard back, so a later push into the same repository reads no stale file.
1370struct CgroupHold {
1371    engine: crate::storage::Engine,
1372    git_dir: std::path::PathBuf,
1373    cgroup: std::path::PathBuf,
1374    file: std::path::PathBuf,
1375}
1376
1377impl CgroupHold {
1378    fn start(
1379        stores: &Arc<ServerStores>,
1380        git_dir: &std::path::Path,
1381        limit: &crate::import_room::MemoryLimit,
1382    ) -> Option<CgroupHold> {
1383        let cgroup = limit.cgroup_dir()?.to_path_buf();
1384        let dir = stores.root().join("import-memory");
1385        std::fs::create_dir_all(&dir).ok()?;
1386        let name: String = git_dir
1387            .to_string_lossy()
1388            .chars()
1389            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1390            .collect();
1391        let held = CgroupHold {
1392            engine: stores.engine(),
1393            git_dir: git_dir.to_path_buf(),
1394            cgroup,
1395            file: dir.join(format!("{name}.meminfo")),
1396        };
1397        if !held.refresh() {
1398            return None;
1399        }
1400        let floor = limit.bytes / 100 * crate::import_room::MARGIN_PERCENT;
1401        if !held
1402            .engine
1403            .hold_index_memory(&held.git_dir, Some((&held.file, floor)))
1404        {
1405            let _ = std::fs::remove_file(&held.file);
1406            return None;
1407        }
1408        tracing::info!(
1409            target: TARGET_GIT,
1410            event = "mirror.import.memory_held",
1411            repo = %held.git_dir.display(),
1412            limit = limit.bytes,
1413            floor,
1414            "the import's index reads this server's cgroup memory ({}) and pauses under {}",
1415            limit.words(),
1416            crate::budget::human(floor)
1417        );
1418        Some(held)
1419    }
1420
1421    /// Write the cgroup's memory now (a temp file and a rename, so the guard never
1422    /// reads half a file).
1423    fn refresh(&self) -> bool {
1424        let Some(c) = crate::import_room::cgroup_memory_in(&self.cgroup) else {
1425            return false;
1426        };
1427        let tmp = self.file.with_extension("meminfo.tmp");
1428        std::fs::write(&tmp, crate::import_room::cgroup_meminfo(&c)).is_ok()
1429            && std::fs::rename(&tmp, &self.file).is_ok()
1430    }
1431}
1432
1433impl Drop for CgroupHold {
1434    fn drop(&mut self) {
1435        self.engine.hold_index_memory(&self.git_dir, None);
1436        let _ = std::fs::remove_file(&self.file);
1437    }
1438}
1439
1440/// **`index` on this thread while `progress` is asked every half second on a scoped one**
1441/// ([`index_landed`]). The poller stops when the index ends, however it ends: a panic in
1442/// the index is an `Err` naming it (RA4-9).
1443fn indexed_with_progress(
1444    index: impl FnOnce() -> Result<usize, String>,
1445    progress: impl Fn() + Sync,
1446) -> Result<usize, String> {
1447    let done = std::sync::atomic::AtomicBool::new(false);
1448    std::thread::scope(|scope| {
1449        // A32-14, stays a scoped thread: it reads the index row for the length of one
1450        // `index_now` on the mirror job's own lane and is joined before this returns.
1451        scope.spawn(|| {
1452            while !done.load(Ordering::SeqCst) {
1453                progress();
1454                for _ in 0..10 {
1455                    if done.load(Ordering::SeqCst) {
1456                        return;
1457                    }
1458                    std::thread::sleep(std::time::Duration::from_millis(50));
1459                }
1460            }
1461        });
1462        // RA4-9: a panic in the index is caught here, so `done` is set however it ends and the
1463        // scope (which waits on the poller) returns; the mirror job then fails by name.
1464        let indexed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(index));
1465        done.store(true, Ordering::SeqCst);
1466        indexed.unwrap_or_else(|panic| {
1467            let words = panic
1468                .downcast_ref::<&str>()
1469                .map(|s| s.to_string())
1470                .or_else(|| panic.downcast_ref::<String>().cloned())
1471                .unwrap_or_else(|| "a panic with no words".into());
1472            Err(format!("the index panicked: {words}"))
1473        })
1474    })
1475}
1476
1477/// **How much a mirror sync into `namespace` may fetch, decided by the front
1478/// doors' own plan gates** ([`ServerStores::install_plan_gates`]).
1479///
1480/// The push path's questions, in its order (`gunnar_ssh::server`'s
1481/// `ask_plan`): the gate's verdict on a `Write`, then — for a write that grows
1482/// data, which a fetch is — `pack_quota` (is the namespace already at its
1483/// cap?) and the TIGHTEST `pack_ceiling` in the chain. The first `Refuse` is
1484/// the answer, verbatim. The ceiling is bound to the namespace's shared pack
1485/// ledger, marked here before any gate measured, so a mirror fetch and a push
1486/// landing at the same moment share the room instead of each spending it.
1487///
1488/// `Ok(None)` is "nothing caps it": a chain with no gate that answers, which is
1489/// the community appliance's ordinary state.
1490///
1491/// A refusal comes back with WHICH question refused it — the plan's verdict
1492/// on a write ([`crate::mirror_queue::Refusal::Plan`]) or the pack room
1493/// ([`crate::mirror_queue::Refusal::PackCap`]) — so the job can say so as a
1494/// field (`MirrorJob.refused_reason`).
1495pub(crate) fn room_for(
1496    gates: &[Arc<dyn gunnar_ssh::PlanGate>],
1497    namespace: &str,
1498) -> Result<Option<gunnar_wire::PackCeiling>, (crate::mirror_queue::Refusal, String)> {
1499    use gunnar_ssh::{PlanAction, PlanVerdict};
1500    if gates.is_empty() {
1501        return Ok(None);
1502    }
1503    // Taken BEFORE any gate measures, for the reason the push path gives:
1504    // only one of the two roundings is a quota that leaks.
1505    let mark = gunnar_lfs::AllowanceLedger::shared_pack().mark(namespace);
1506    let mut tightest: Option<gunnar_ssh::PackCeiling> = None;
1507    for gate in gates {
1508        if let PlanVerdict::Refuse(line) = gate.verdict(namespace, PlanAction::Write) {
1509            return Err((crate::mirror_queue::Refusal::Plan, line));
1510        }
1511        if let PlanVerdict::Refuse(line) = gate.pack_quota(namespace) {
1512            return Err((crate::mirror_queue::Refusal::PackCap, line));
1513        }
1514        if let Some(asked) = gate.pack_ceiling(namespace) {
1515            if tightest
1516                .as_ref()
1517                .is_none_or(|held| asked.bytes < held.bytes)
1518            {
1519                tightest = Some(asked);
1520            }
1521        }
1522    }
1523    Ok(tightest.map(|ceiling| gunnar_wire::PackCeiling {
1524        bytes: ceiling.bytes,
1525        refusal: ceiling.refusal,
1526        ledger: Some(
1527            Arc::new(crate::transport::PackMark { mark }) as Arc<dyn gunnar_wire::PackLedger>
1528        ),
1529    }))
1530}
1531
1532/// **Apply the plan, in one transaction, against the values the fetch
1533/// reasoned about.**
1534///
1535/// See the module doc for why this is `put_refs_cas` and why a rejection is a
1536/// failure rather than a skip.
1537fn apply(
1538    git: &dyn gunnar_store::git_storage_trait::GitServe,
1539    order: &MirrorOrder,
1540    outcome: &FetchReport,
1541    progress: &QueueProgress<'_>,
1542    protection: Option<&Protection>,
1543) -> Result<(), String> {
1544    let zero = "0".repeat(outcome.hash_kind.len_in_hex());
1545
1546    // Owned first, because `RefCas` borrows its oids and the batch has to
1547    // outlive every one of them.
1548    // (name, old, new, peeled)
1549    type OwnedUpdate = (String, Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
1550    let mut owned: Vec<OwnedUpdate> = Vec::new();
1551    let mut updates: Vec<(String, String, String)> = Vec::new();
1552
1553    for plan in &outcome.updates {
1554        // **The name came off somebody else's wire, so it goes through the ref
1555        // name rules before it is written.**
1556        //
1557        // `plan.name` is the string an upstream's `ls-refs` answered with,
1558        // carried untouched from `fetch_pack`, and the only filter it passed on
1559        // the way is `fetch_pack::mirror`'s prefix test — which admits
1560        // `refs/heads/../../etc/passwd` exactly as readily as `refs/heads/main`.
1561        // Receive-pack puts every client-supplied name through
1562        // `gunnar_store::refname::validate` at the same point in its own path
1563        // (`receive_pack.rs`'s `funny refname`), and a fetch is the same
1564        // untrusted input arriving through a different door.
1565        //
1566        // It is checked HERE rather than relied on from the storage engine. The
1567        // memory backend does validate in `put_refs_cas`, so a name like this
1568        // would be refused there today — but that is a property of one engine,
1569        // and "a rule one backend enforces by accident is not a rule" is the
1570        // sentence `refname` itself is written under. The engines are swappable
1571        // (`GUNNAR_ENGINE`), and this is the one caller that can guarantee the
1572        // check ran.
1573        //
1574        // Only the names that came from the upstream. The prune half below
1575        // reads its names out of this repository's OWN refs, and a bad name
1576        // already in the store is one a prune should be able to delete rather
1577        // than one it must refuse to touch.
1578        gunnar_store::refname::validate(plan.name.as_bytes()).map_err(|err| {
1579            format!(
1580                "the upstream advertised a reference name gunnar will not write: {err}. Nothing \
1581                 was moved. A name is not a path, and this one would not have been a name."
1582            )
1583        })?;
1584        // The peel the remote already sent, rather than a peel computed here.
1585        // `ls-refs` was asked with `peel`, so an annotated tag arrived with the
1586        // commit it resolves to; recomputing it would be an object read per tag
1587        // for an answer already in hand (the tag re-peel, from the other side).
1588        let peeled = outcome
1589            .advertised
1590            .iter()
1591            .find(|advertised| advertised.name == plan.name)
1592            .and_then(|advertised| advertised.peeled)
1593            .map(|oid| oid.as_bytes().to_vec());
1594        updates.push((
1595            plan.name.clone(),
1596            plan.old
1597                .map(|old| old.to_string())
1598                .unwrap_or_else(|| zero.clone()),
1599            plan.new.to_string(),
1600        ));
1601        owned.push((
1602            plan.name.clone(),
1603            plan.old.map(|old| old.as_bytes().to_vec()),
1604            Some(plan.new.as_bytes().to_vec()),
1605            peeled,
1606        ));
1607    }
1608
1609    if order.prune {
1610        // **What the upstream no longer has.** Only over the same namespace the
1611        // ref plan covers (`refs/heads/*`, `refs/tags/*`): a prune that reached
1612        // wider would delete this box's own `refs/gunnar/*` bookkeeping on the
1613        // grounds that an upstream never advertised it.
1614        let held = GitOps::refs(git)
1615            .map_err(|err| format!("reading {}'s refs to prune them: {err:#}", order.path))?;
1616        for row in held {
1617            if !fetch_pack::mirror(&row.name) {
1618                continue;
1619            }
1620            if outcome
1621                .advertised
1622                .iter()
1623                .any(|advertised| advertised.name == row.name)
1624            {
1625                continue;
1626            }
1627            let Some(old) = row.oid else { continue };
1628            updates.push((row.name.clone(), hex(&old), zero.clone()));
1629            owned.push((row.name, Some(old), None, None));
1630        }
1631    }
1632
1633    if owned.is_empty() {
1634        finish(outcome, progress, Vec::new());
1635        return Ok(());
1636    }
1637
1638    // **The gate, between the plan and the write.** Before this line the batch
1639    // below was the one reference write on the box that no gate had seen; a
1640    // repository with no protection rule configured has no `Protection` at all,
1641    // so a mirror of an unprotected repository pays nothing for this.
1642    if let Some(protection) = protection {
1643        let gated: Vec<gunnar_policy::RefUpdate> = owned
1644            .iter()
1645            .map(|(name, old, new, _)| {
1646                // The null-oid spelling never appears here: `owned` already
1647                // carries absence as `None`, which is what `RefUpdate`'s three
1648                // constructors take. `try_from_bytes` cannot fail on a value the
1649                // store or the wire produced, and an oid this store cannot
1650                // decode is the one `StoreRefs::tips` already refuses by name.
1651                let decode = |raw: &Vec<u8>| {
1652                    gix_hash::oid::try_from_bytes(raw)
1653                        .map(gix_hash::oid::to_owned)
1654                        .map_err(|err| {
1655                            format!("{name} holds an oid this server cannot decode: {err}")
1656                        })
1657                };
1658                Ok(gunnar_policy::RefUpdate {
1659                    name: name.clone(),
1660                    old: old.as_ref().map(&decode).transpose()?,
1661                    new: new.as_ref().map(&decode).transpose()?,
1662                })
1663            })
1664            .collect::<Result<_, String>>()?;
1665        let new_objects = match outcome.receipt.as_ref() {
1666            // Nothing was fetched, so nothing was introduced. That is a measured
1667            // empty set and not an unasked question — see `FetchReport::receipt`.
1668            None => Ok(Vec::new()),
1669            Some(receipt) => receipt.new_objects.clone(),
1670        };
1671        protection.check(git, &gated, new_objects.as_deref().map_err(String::as_str))?;
1672    }
1673
1674    let batch: Vec<RefCas<'_>> = owned
1675        .iter()
1676        .map(|(name, old, new, peeled)| RefCas {
1677            name: name.clone(),
1678            old: old.as_deref(),
1679            new: new.as_deref(),
1680            peeled: peeled.clone(),
1681        })
1682        .collect();
1683
1684    // ONE call. Every edit or none — a loop with a break leaves what it already
1685    // applied applied, and a half-mirrored repository is a state no upstream
1686    // ever had.
1687    GitOps::put_refs_cas(git, &batch).map_err(|err| {
1688        format!(
1689            "the fetch succeeded and the reference update did not: {err:#}. Nothing was moved. \
1690             The usual cause is a push that landed on {} while the fetch was running; the \
1691             expected values came from the snapshot the fetch negotiated against, and \
1692             overwriting a concurrent push is the one thing a mirror must never do. Sync again.",
1693            order.path
1694        )
1695    })?;
1696
1697    tracing::info!(
1698        target: TARGET_GIT,
1699        event = "mirror.sync.applied",
1700        repo = %order.path,
1701        refs = updates.len(),
1702        objects = outcome.objects_done,
1703        bytes = outcome.pack_bytes,
1704        "a mirror sync moved this repository's references"
1705    );
1706
1707    finish(outcome, progress, updates);
1708    Ok(())
1709}
1710
1711/// **Close the job's numbers out**, on both paths out of [`apply`].
1712///
1713/// One function and not two copies, because the pair below is the whole of what
1714/// a console reads when a sync ends and the two paths differ only in whether
1715/// any reference moved.
1716///
1717/// The `progress` call is the second half of the pair `FetchProgress` opened.
1718/// `objects_total` published `(0, N)` the instant the packfile header arrived;
1719/// **this publishes `(N, N)`**, and without it a finished job sits for ever at
1720/// nought per cent of a total it knows — which is worse than no progress bar,
1721/// because it reads as a transfer that stalled. `fetch_pack` has already
1722/// refused the transfer if the declared count and the indexed count disagree,
1723/// so `objects_done` is the same `N` and this cannot report a completion the
1724/// store did not make.
1725///
1726/// A fetch that wanted nothing — the ordinary answer for a poll of an idle
1727/// upstream — has no header, no `N`, and therefore **no progress at all**: the
1728/// pair stays `None`, and a caller reads `has_progress = false` rather than a
1729/// fabricated `0/0`.
1730fn finish(
1731    outcome: &FetchReport,
1732    progress: &QueueProgress<'_>,
1733    updates: Vec<(String, String, String)>,
1734) {
1735    if outcome.receipt.is_some() {
1736        let total = u64::from(outcome.objects_total);
1737        progress.publish(u64::from(outcome.objects_done), total);
1738    }
1739    progress.report.report(|mirror| {
1740        mirror.objects = u64::from(outcome.objects_done);
1741        mirror.bytes = outcome.pack_bytes;
1742        mirror.updates = updates;
1743    });
1744}
1745
1746/// Lower-case hex, the spelling `gunnar.v1.RefUpdate` uses.
1747fn hex(raw: &[u8]) -> String {
1748    let mut out = String::with_capacity(raw.len() * 2);
1749    for byte in raw {
1750        use std::fmt::Write as _;
1751        let _ = write!(out, "{byte:02x}");
1752    }
1753    out
1754}
1755
1756/// `base64(user:secret)`, the Basic-auth pair.
1757///
1758/// Written out rather than pulled in: the whole alphabet is 64 characters and
1759/// the encoder is nine lines, against a dependency that would be in the
1760/// appliance's graph for this one call. It is not a general base64 — it does
1761/// exactly the one thing, and `mirror_http`'s tests assert on the bytes it
1762/// produces.
1763fn base64_basic(user: &str, secret: &str) -> String {
1764    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1765    let raw = format!("{user}:{secret}");
1766    let raw = raw.as_bytes();
1767    let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);
1768    for chunk in raw.chunks(3) {
1769        let b = [
1770            chunk[0],
1771            chunk.get(1).copied().unwrap_or(0),
1772            chunk.get(2).copied().unwrap_or(0),
1773        ];
1774        let packed = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
1775        out.push(ALPHABET[(packed >> 18) as usize & 63] as char);
1776        out.push(ALPHABET[(packed >> 12) as usize & 63] as char);
1777        out.push(if chunk.len() > 1 {
1778            ALPHABET[(packed >> 6) as usize & 63] as char
1779        } else {
1780            '='
1781        });
1782        out.push(if chunk.len() > 2 {
1783            ALPHABET[packed as usize & 63] as char
1784        } else {
1785            '='
1786        });
1787    }
1788    out
1789}
1790
1791// ── the ssh half ─────────────────────────────────────────────────────────────
1792
1793/// **Dial `git-upload-pack` over SSH and hand the channel's two halves to
1794/// `run`.**
1795///
1796/// `gunnar_client::connect_upload_pack` is the fetch-side twin of the
1797/// `connect_receive_pack` replication already pushes with, over
1798/// `nornir-git`'s russh transport. There is no second dialler here and there
1799/// must not be: that transport owns the connect, the publickey exchange, the
1800/// `known_hosts` policy, the blocking bridge and — load-bearing — the `Drop`
1801/// that tears the channel down *inside* the driving runtime.
1802///
1803/// # The key is a file for the length of one dial, and then it is not
1804///
1805/// `nornir_git::ssh::SshAuth` authenticates from a **path**. gunnar's mirror
1806/// credential is sealed bytes in the control database, because a private key
1807/// sitting in the clear on `/data` is the thing that store exists to prevent.
1808/// The two are bridged by a [`tempfile::NamedTempFile`] created `0600` and
1809/// dropped — and therefore unlinked — before this function returns, on every
1810/// path out of it including a panic.
1811///
1812/// The alternative was an in-memory key API on `nornir-git`, which is the right
1813/// long-term shape, and it is a change to a crate outside this repository. It
1814/// is named here rather than left as a silent compromise.
1815#[cfg(feature = "replication")]
1816fn dial_ssh<T>(
1817    url: &str,
1818    credential: &Credential,
1819    known_hosts: &std::path::Path,
1820    strict: bool,
1821    run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
1822) -> Result<T, String> {
1823    use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
1824    use std::io::BufReader;
1825
1826    let mut options = SshOptions::new();
1827    // A server has no ssh-agent and no interactive user. Offering the agent
1828    // would be offering identities nobody configured for this mirror — the
1829    // silent substitution `gunnar_client::resolve_identity` exists to forbid.
1830    options.use_agent = false;
1831
1832    // Held for the whole of `run`: dropping it unlinks the file, and the dial
1833    // reads it during `connect_upload_pack`.
1834    let key_file = match credential {
1835        Credential::SshKey(bytes) => {
1836            let mut file = tempfile::Builder::new()
1837                .prefix("gunnar-mirror-key-")
1838                .tempfile()
1839                .map_err(|err| format!("no room to materialise the mirror's ssh key: {err}"))?;
1840            #[cfg(unix)]
1841            {
1842                use std::os::unix::fs::PermissionsExt as _;
1843                std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o600))
1844                    .map_err(|err| {
1845                        format!("the mirror's ssh key could not be made private: {err}")
1846                    })?;
1847            }
1848            file.write_all(bytes)
1849                .and_then(|()| file.flush())
1850                .map_err(|err| format!("writing the mirror's ssh key: {err}"))?;
1851            options.key_path = Some(file.path().to_path_buf());
1852            Some(file)
1853        }
1854        // No key named means the ladder, which on a server finds nothing; that
1855        // is the honest behaviour for a public upstream over ssh and it is what
1856        // `gunnar-client` warns about at start-up.
1857        _ => None,
1858    };
1859    // Trust-on-first-use against a `known_hosts` under the data root is what
1860    // replication does when an operator has not said otherwise, and a
1861    // mirror is dialling the same kind of box. See [`MIRROR_KNOWN_HOSTS`] for
1862    // why the file is NOT `~/.ssh/known_hosts`.
1863    //
1864    // **Unless the box says otherwise**, which it could not before: the policy
1865    // was this literal and nothing else, so a first sync trusted whatever
1866    // answered on the upstream's address, on a path that is about to offer it a
1867    // private key. See [`MIRROR_STRICT_HOST_KEY`].
1868    options.host_key = if strict {
1869        HostKeyPolicy::StrictKnownHosts
1870    } else {
1871        HostKeyPolicy::TofuKnownHosts
1872    };
1873    options.known_hosts = Some(known_hosts.to_path_buf());
1874
1875    let location = nornir_git::ssh::parse_ssh_url(url)
1876        .map_err(|err| format!("{} is not an ssh url: {err}", scrub(url)))?;
1877    let mut channel = gunnar_client::connect_upload_pack(&location, &options).map_err(|err| {
1878        // `{:#}` over an `anyhow::Error`, and not `{err}`: `gunnar_client::Error`
1879        // is a thiserror enum whose `Ssh` variant Displays as the bare word
1880        // "ssh" and carries the real sentence — which host key, which policy —
1881        // as its `source`. A FAILED job reading `opening git-upload-pack on …:
1882        // ssh` tells an operator nothing they can act on, and the reason was
1883        // one formatting flag away the whole time.
1884        format!(
1885            "opening git-upload-pack on {}: {:#}",
1886            scrub(url),
1887            anyhow::Error::new(err)
1888        )
1889    })?;
1890    let out = {
1891        let (reader, writer) = channel.io();
1892        let mut reader = BufReader::new(reader);
1893        run(&mut reader, writer)
1894    };
1895    drop(key_file);
1896    Ok(out)
1897}
1898
1899/// **What a build with no ssh dialler says**, rather than what it does.
1900///
1901/// `replication` is the feature that carries `gunnar-client`'s wire half and
1902/// `nornir-git`'s russh transport; without it there is no dialler in the
1903/// binary. A mirror registered against an `ssh://` remote on such a build is a
1904/// registration that can never run, and it says so at the one place it can be
1905/// acted on — a FAILED job naming the flag — rather than failing as a
1906/// connection error that sends an operator to look at the network.
1907///
1908/// The smart-HTTP half is behind its own feature, `mirror-http` (A32-18), which every
1909/// appliance row names: [`NO_MIRROR_HTTP`] is its refusal.
1910#[cfg(not(feature = "replication"))]
1911fn dial_ssh<T>(
1912    url: &str,
1913    _credential: &Credential,
1914    _known_hosts: &std::path::Path,
1915    _strict: bool,
1916    _run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
1917) -> Result<T, String> {
1918    Err(format!(
1919        "{} is an ssh remote and this build carries no ssh dialler: it was compiled without \
1920         `--features replication`, which is what links `gunnar-client`'s russh transport. \
1921         Mirror from the upstream's `https://` URL, or use a build that has the feature.",
1922        scrub(url)
1923    ))
1924}
1925
1926/// **Take any userinfo out of a URL before it is shown to anybody.**
1927///
1928/// `https://user:token@host/path` is a legal way to write a remote and an
1929/// operator may well have written one. Everything between `//` and the last
1930/// `@` of the authority is replaced, so a token that arrived in the URL cannot
1931/// leave through an error string.
1932///
1933/// Not a parser: this runs in an error path, and a URL this cannot make sense
1934/// of is returned with the whole authority elided rather than passed through.
1935pub(crate) fn scrub(url: &str) -> String {
1936    let Some((scheme, rest)) = url.split_once("://") else {
1937        return url.to_owned();
1938    };
1939    let (authority, path) = match rest.find('/') {
1940        Some(cut) => (&rest[..cut], &rest[cut..]),
1941        None => (rest, ""),
1942    };
1943    match authority.rfind('@') {
1944        Some(cut) => format!("{scheme}://<redacted>@{}{path}", &authority[cut + 1..]),
1945        None => url.to_owned(),
1946    }
1947}
1948
1949/// **An `https://` remote's two halves** ([`crate::mirror_http`], feature `mirror-http`).
1950#[cfg(feature = "mirror-http")]
1951fn open_http(
1952    url: &str,
1953    ca: &[Vec<u8>],
1954    auth: Option<String>,
1955) -> Result<(crate::mirror_http::HttpRead, crate::mirror_http::HttpWrite), String> {
1956    crate::mirror_http::open(url, ca, auth)
1957}
1958
1959/// The refusal an `https://` remote gets in a build without `mirror-http` (A32-18): the
1960/// airgap default has no HTTP client, and says so where it can be acted on.
1961pub const NO_MIRROR_HTTP: &str = "this build carries no HTTP client: it was compiled without gunnar-server's `mirror-http` feature (the appliance rows name it)";
1962
1963#[cfg(not(feature = "mirror-http"))]
1964fn open_http(
1965    url: &str,
1966    _ca: &[Vec<u8>],
1967    _auth: Option<String>,
1968) -> Result<(std::io::Empty, std::io::Sink), String> {
1969    Err(format!("{} is an https remote and {NO_MIRROR_HTTP}. Mirror from the upstream's ssh:// URL, or use a build that has the feature.", scrub(url)))
1970}
1971
1972#[cfg(test)]
1973mod index_panic_tests {
1974    use super::*;
1975
1976    /// ★ RA4-9 RED: **an index that panics ends the index step with a named refusal**, at
1977    /// once — the progress poller is stopped however the index ends, so the mirror job
1978    /// reaches FAILED instead of hanging for good.
1979    #[test]
1980    fn an_index_that_panics_is_a_named_failure_not_a_hung_job() {
1981        let (tx, rx) = std::sync::mpsc::channel();
1982        std::thread::spawn(move || {
1983            let r = std::panic::catch_unwind(|| {
1984                indexed_with_progress(|| panic!("the index broke"), || {})
1985            });
1986            let _ = tx.send(r);
1987        });
1988        let r = rx
1989            .recv_timeout(std::time::Duration::from_secs(5))
1990            .expect("the index step ended within 5 s: no hang");
1991        let said = r.expect("a panic in the index is an answer, not a panic up the mirror job");
1992        let why = said.expect_err("a refusal");
1993        assert!(
1994            why.contains("the index panicked") && why.contains("the index broke"),
1995            "{why}"
1996        );
1997    }
1998}
1999
2000#[cfg(test)]
2001mod tests {
2002    use super::*;
2003
2004    /// **The userinfo scrub is the last thing between a token in a URL and the
2005    /// log.** Asserted on the bytes, because "it does not appear" is the whole
2006    /// property.
2007    /// ★ A32-18: the airgap default carries no HTTP client, so an `https://` remote is refused
2008    /// by name (and its token never shown), never dialled.
2009    #[cfg(not(feature = "mirror-http"))]
2010    #[test]
2011    fn an_https_remote_is_refused_by_name_in_a_build_without_mirror_http() {
2012        let e = open_http(
2013            "https://user:tok3n@forge.mock.invalid/ns/repo.git",
2014            &[],
2015            Some("Bearer tok3n".into()),
2016        )
2017        .expect_err("refused");
2018        assert!(
2019            e.contains(NO_MIRROR_HTTP) && e.contains("mirror-http"),
2020            "{e}"
2021        );
2022        assert!(!e.contains("tok3n"), "{e}");
2023    }
2024
2025    #[test]
2026    fn a_url_with_userinfo_never_shows_it() {
2027        let scrubbed = scrub("https://alice:ghp_SUPERSECRET@example.test/ns/repo.git");
2028        assert!(
2029            !scrubbed.contains("ghp_SUPERSECRET"),
2030            "the token survived the scrub: {scrubbed}"
2031        );
2032        assert!(
2033            !scrubbed.contains("alice"),
2034            "the whole userinfo goes, not just the password half: {scrubbed}"
2035        );
2036        assert_eq!(scrubbed, "https://<redacted>@example.test/ns/repo.git");
2037    }
2038
2039    /// A URL with no userinfo is left exactly as the operator wrote it — an
2040    /// error that mangled the ordinary case would be worse than no scrub.
2041    #[test]
2042    fn a_plain_url_is_untouched() {
2043        let url = "https://example.test/ns/repo.git";
2044        assert_eq!(scrub(url), url);
2045        assert_eq!(
2046            scrub("ssh://git@example.test/ns/repo.git"),
2047            "ssh://<redacted>@example.test/ns/repo.git"
2048        );
2049    }
2050
2051    /// **Both transports are recognised from the URL alone**, and the scp-like
2052    /// form is ssh — the shape every forge prints on its clone button.
2053    #[test]
2054    fn a_remote_url_names_its_transport() {
2055        assert_eq!(classify("ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
2056        assert_eq!(classify("git+ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
2057        assert_eq!(classify("git@host:ns/repo.git"), Ok(Wire::Ssh));
2058        assert_eq!(classify("https://host/ns/repo.git"), Ok(Wire::Http));
2059        assert_eq!(
2060            classify("http://127.0.0.1:9418/ns/repo.git"),
2061            Ok(Wire::Http)
2062        );
2063    }
2064
2065    /// A remote gunnar cannot dial is refused when it is REGISTERED, not on the
2066    /// first sync — and the refusal names the two forms that work.
2067    #[test]
2068    fn a_remote_that_is_neither_is_refused_by_name() {
2069        let err = classify("/srv/git/ns/repo.git").expect_err("a local path is not a remote");
2070        assert!(err.contains("ssh://") && err.contains("https://"), "{err}");
2071        let err = classify("git://host/ns/repo.git").expect_err("the dumb git protocol");
2072        assert!(err.contains("scheme gunnar cannot mirror from"), "{err}");
2073    }
2074
2075    /// **The mismatch is refused by name, in both directions.**
2076    ///
2077    /// The failure being prevented is not an error — it is a 401 (or an SSH
2078    /// authentication failure) against an operator who can see that a
2079    /// credential IS stored, and who will therefore rotate a key that was never
2080    /// the problem.
2081    #[test]
2082    fn a_credential_of_the_wrong_kind_is_refused_by_name() {
2083        let key = Credential::SshKey(b"-----BEGIN OPENSSH PRIVATE KEY-----".to_vec());
2084        let token = Credential::Token {
2085            username: String::new(),
2086            secret: "ghp_SECRET".to_owned(),
2087        };
2088
2089        let err = check_credential(Wire::Http, &key).expect_err("a key cannot ride on http");
2090        assert!(
2091            err.contains("smart http") && err.contains("ssh private key"),
2092            "{err}"
2093        );
2094        assert!(
2095            !err.contains("BEGIN OPENSSH"),
2096            "the refusal quoted the key: {err}"
2097        );
2098
2099        let err = check_credential(Wire::Ssh, &token).expect_err("a token cannot ride on ssh");
2100        assert!(err.contains("ssh") && err.contains("a token"), "{err}");
2101        assert!(
2102            !err.contains("ghp_SECRET"),
2103            "the refusal quoted the token: {err}"
2104        );
2105
2106        // …and the matching pairs, plus the public case, are all fine.
2107        check_credential(Wire::Ssh, &key).expect("a key over ssh");
2108        check_credential(Wire::Http, &token).expect("a token over http");
2109        check_credential(Wire::Ssh, &Credential::None).expect("a public ssh upstream");
2110        check_credential(Wire::Http, &Credential::None).expect("a public http upstream");
2111    }
2112
2113    /// **The authority, out of each of the three shapes** — and the default
2114    /// port with it, because the port is half of what gets dialled.
2115    #[test]
2116    fn a_remote_url_names_the_machine_it_would_dial() {
2117        for (url, want) in [
2118            ("https://host/ns/repo.git", ("host".to_owned(), 443)),
2119            ("http://host/ns/repo.git", ("host".to_owned(), 80)),
2120            ("http://host:9418/ns/repo.git", ("host".to_owned(), 9418)),
2121            ("ssh://git@host/ns/repo.git", ("host".to_owned(), 22)),
2122            ("ssh://git@host:2222/ns/repo.git", ("host".to_owned(), 2222)),
2123            ("git@host:ns/repo.git", ("host".to_owned(), 22)),
2124            // The userinfo is not the host, and a `@` inside it must not become
2125            // one either.
2126            (
2127                "https://user:pass@host/ns/repo.git",
2128                ("host".to_owned(), 443),
2129            ),
2130            // An IPv6 literal is all colons; the brackets are what separates the
2131            // address from the port, and splitting on the first colon would
2132            // yield `[`.
2133            ("ssh://git@[::1]:2222/ns/repo.git", ("::1".to_owned(), 2222)),
2134            (
2135                "https://[2606:4700::1111]/ns/repo.git",
2136                ("2606:4700::1111".to_owned(), 443),
2137            ),
2138            ("git@[::1]:ns/repo.git", ("::1".to_owned(), 22)),
2139        ] {
2140            assert_eq!(authority(url), Ok(want.clone()), "{url}");
2141        }
2142        assert!(authority("https:///ns/repo.git").is_err(), "no host at all");
2143    }
2144
2145    /// **Every range a caller must not be able to point this server at**, and
2146    /// the two that must still work.
2147    ///
2148    /// Asserted on [`address_verdict`] rather than on [`check_destination`]
2149    /// because the addresses are handed in: no resolver runs, so this is the
2150    /// same answer on a laptop, on a build box with a corporate DNS and in a
2151    /// container with none.
2152    #[test]
2153    fn a_mirror_refuses_every_address_only_the_server_can_reach() {
2154        for addr in [
2155            "127.0.0.1",
2156            "0.0.0.0",
2157            "10.0.0.5",
2158            "172.16.0.1",
2159            "192.168.1.1",
2160            // The one the finding names: cloud instance metadata.
2161            "169.254.169.254",
2162            "100.64.0.1",
2163            "192.0.0.1",
2164            "198.18.0.1",
2165            "240.0.0.1",
2166            "255.255.255.255",
2167            "224.0.0.1",
2168            "::1",
2169            "::",
2170            "fc00::1",
2171            "fe80::1",
2172            "ff02::1",
2173            // …written the long way. Without the mapped-address arm this one
2174            // walks past every v6 test and is allowed.
2175            "::ffff:127.0.0.1",
2176            "::ffff:169.254.169.254",
2177        ] {
2178            let ip: std::net::IpAddr = addr.parse().expect("a literal");
2179            let err = address_verdict("upstream.invalid", &[ip])
2180                .expect_err("{addr} must not be dialled on a caller's say-so");
2181            assert!(
2182                err.contains(addr) || err.contains(&ip.to_string()),
2183                "{addr}: {err}"
2184            );
2185            assert!(err.contains(MIRROR_ALLOW_PRIVATE), "{addr}: {err}");
2186        }
2187
2188        for addr in ["93.184.216.34", "8.8.8.8", "2606:4700:4700::1111"] {
2189            let ip: std::net::IpAddr = addr.parse().expect("a literal");
2190            address_verdict("upstream.invalid", &[ip])
2191                .unwrap_or_else(|err| panic!("{addr} is an ordinary public address: {err}"));
2192        }
2193    }
2194
2195    /// **One bad answer out of several is a refusal.** A name that resolves to a
2196    /// routable address and a loopback address is the rebinding shape, and a
2197    /// check that took the first answer, or the best one, would pass it.
2198    #[test]
2199    fn one_private_answer_among_several_refuses_the_whole_name() {
2200        let addrs: Vec<std::net::IpAddr> = ["93.184.216.34", "127.0.0.1"]
2201            .iter()
2202            .map(|a| a.parse().expect("a literal"))
2203            .collect();
2204        let err = address_verdict("both.invalid", &addrs).expect_err("one of them is loopback");
2205        assert!(err.contains("127.0.0.1"), "{err}");
2206
2207        // And nothing at all is not "fine": a name with no address is refused
2208        // rather than allowed through to a dial that would fail anyway.
2209        assert!(address_verdict("nowhere.invalid", &[]).is_err());
2210    }
2211
2212    /// The Basic pair is encoded the way every HTTP client encodes it. Checked
2213    /// against a value anybody can verify by hand rather than against this
2214    /// function's own output.
2215    #[test]
2216    fn basic_auth_is_ordinary_base64() {
2217        assert_eq!(
2218            base64_basic("Aladdin", "open sesame"),
2219            "QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
2220        );
2221        assert_eq!(base64_basic("a", "b"), "YTpi");
2222        assert_eq!(base64_basic("ab", "b"), "YWI6Yg==");
2223    }
2224}