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 [`crate::mirror_http::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 crate::mirror_http::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!(
267 "{} names an unterminated IPv6 literal",
268 crate::mirror_http::scrub(url)
269 ));
270 };
271 let port = match tail.strip_prefix(':') {
272 None => default_port,
273 Some(digits) => digits.parse().map_err(|_| {
274 format!(
275 "{} names a port that is not a number",
276 crate::mirror_http::scrub(url)
277 )
278 })?,
279 };
280 return non_empty(host, url).map(|host| (host, port));
281 }
282 match authority.split_once(':') {
283 None => non_empty(authority, url).map(|host| (host, default_port)),
284 Some((host, digits)) => {
285 let port = digits.parse().map_err(|_| {
286 format!(
287 "{} names a port that is not a number",
288 crate::mirror_http::scrub(url)
289 )
290 })?;
291 non_empty(host, url).map(|host| (host, port))
292 }
293 }
294}
295
296fn non_empty(host: &str, url: &str) -> Result<String, String> {
297 if host.is_empty() {
298 return Err(format!(
299 "{} names no host to dial",
300 crate::mirror_http::scrub(url)
301 ));
302 }
303 Ok(host.to_owned())
304}
305
306/// **Refuse a destination this server can reach and its caller cannot** — the
307/// pure half, with the addresses handed in.
308///
309/// `Mirror.Add` needs Admin and `Mirror.Sync` needs Write, but *every enrolled
310/// principal is intrinsically Admin in its own namespace*
311/// (`AuthTable::owns_account`), so `Mirror.Add{create_repo}` against
312/// `alice/anything` is available to anybody with a key. Before this check the
313/// only thing standing between that caller and an arbitrary request from the
314/// server's own network position was `remote_url.trim().is_empty()`: the cloud
315/// metadata endpoint at `169.254.169.254`, a control plane on `127.0.0.1`, a
316/// printer on `10.0.0.0/8` — all dialled by the appliance, with the appliance's
317/// routes, and the outcome partly readable back through `Mirror.Status`.
318///
319/// **Every** resolved address has to be routable, not merely the first: a name
320/// that resolves to one public address and one loopback address is the
321/// rebinding shape, and taking the best of the two is how a check like this
322/// gets to be decorative.
323///
324/// The ranges are written out rather than taken from `IpAddr::is_global`, which
325/// is unstable, and each line is the reason it is there.
326pub(crate) fn address_verdict(host: &str, addrs: &[std::net::IpAddr]) -> Result<(), String> {
327 if addrs.is_empty() {
328 return Err(format!("{host} resolves to no address at all"));
329 }
330 for addr in addrs {
331 if let Some(why) = not_routable(*addr) {
332 return Err(format!(
333 "{host} resolves to {addr}, which is {why}. A mirror dials from the server, so a \
334 destination the caller could not reach on their own is the server's network \
335 position being borrowed. Create `{MIRROR_ALLOW_PRIVATE}` under this box's data \
336 root to allow it deliberately."
337 ));
338 }
339 }
340 Ok(())
341}
342
343/// Why `addr` is not somewhere an arbitrary caller should be able to point this
344/// server, or `None` when it is an ordinary public address.
345fn not_routable(addr: std::net::IpAddr) -> Option<&'static str> {
346 use std::net::IpAddr;
347 match addr {
348 IpAddr::V4(v4) => {
349 let [a, b, c, _] = v4.octets();
350 match () {
351 _ if v4.is_unspecified() => Some("the unspecified address"),
352 _ if v4.is_loopback() => Some("loopback — this server itself"),
353 _ if v4.is_private() => Some("a private network (RFC 1918)"),
354 _ if v4.is_link_local() => Some(
355 "link-local (RFC 3927), the range cloud metadata services answer on",
356 ),
357 _ if v4.is_broadcast() => Some("the broadcast address"),
358 _ if v4.is_multicast() => Some("multicast"),
359 _ if v4.is_documentation() => Some("a documentation range (RFC 5737)"),
360 // 100.64.0.0/10, carrier-grade NAT: the provider's own network,
361 // not the caller's.
362 _ if a == 100 && (64..128).contains(&b) => Some("carrier-grade NAT (RFC 6598)"),
363 // 192.0.0.0/24, IETF protocol assignments.
364 _ if a == 192 && b == 0 && c == 0 => Some("an IETF protocol assignment"),
365 // 198.18.0.0/15, benchmarking.
366 _ if a == 198 && (b == 18 || b == 19) => Some("a benchmarking range (RFC 2544)"),
367 // 240.0.0.0/4, reserved.
368 _ if a >= 240 => Some("a reserved range"),
369 _ => None,
370 }
371 }
372 // A v4 address written the long way is the same address. Checked
373 // first, or `::ffff:127.0.0.1` walks straight past every arm below it.
374 IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some() => {
375 not_routable(IpAddr::V4(v6.to_ipv4_mapped().expect("just matched")))
376 }
377 IpAddr::V6(v6) => {
378 let segments = v6.segments();
379 match () {
380 _ if v6.is_unspecified() => Some("the unspecified address"),
381 _ if v6.is_loopback() => Some("loopback — this server itself"),
382 _ if v6.is_multicast() => Some("multicast"),
383 // fc00::/7, unique local.
384 _ if segments[0] & 0xfe00 == 0xfc00 => Some("a unique-local address (RFC 4193)"),
385 // fe80::/10, link-local.
386 _ if segments[0] & 0xffc0 == 0xfe80 => Some("link-local (RFC 4291)"),
387 // 2001:db8::/32, documentation.
388 _ if segments[0] == 0x2001 && segments[1] == 0x0db8 => {
389 Some("a documentation range (RFC 3849)")
390 }
391 _ => None,
392 }
393 }
394 }
395}
396
397/// [`address_verdict`] with the resolution and the opt-out file read — the
398/// edge, and the only part of this that touches the world.
399///
400/// # The window this does not close, said rather than glossed
401///
402/// The name is resolved here and again by `ureq` or by the ssh dialler a moment
403/// later, so a resolver that answers differently the second time (DNS
404/// rebinding) is not stopped by this. Closing that needs the dial pinned to the
405/// address that was checked, which is a change inside `ureq`'s and
406/// `nornir-git`'s connect paths rather than in this file. What this does close
407/// is the whole of the *static* case — a URL that simply names an internal
408/// address — which is what `Mirror.Add` accepted unchecked.
409pub fn check_destination(url: &str, data_root: &std::path::Path) -> Result<(), String> {
410 use std::net::ToSocketAddrs as _;
411 if data_root.join(MIRROR_ALLOW_PRIVATE).exists() {
412 return Ok(());
413 }
414 let (host, port) = authority(url)?;
415 // A literal needs no resolver, and asking one for it would let a DNS server
416 // answer a question that has no question in it.
417 if let Ok(addr) = host.parse::<std::net::IpAddr>() {
418 return address_verdict(&host, &[addr]);
419 }
420 let addrs: Vec<std::net::IpAddr> = (host.as_str(), port)
421 .to_socket_addrs()
422 .map_err(|err| format!("{host} could not be resolved: {err}"))?
423 .map(|socket| socket.ip())
424 .collect();
425 address_verdict(&host, &addrs)
426}
427
428/// **Refuse a credential that cannot be offered over the transport the URL
429/// names — by name, before anything is dialled.**
430///
431/// The failure this prevents is the quiet one. An SSH key against an `https://`
432/// remote is not "authentication failed": there is nowhere in an HTTP request
433/// to put a private key, so the request goes out anonymous and the upstream
434/// answers 401 — and the operator, who can see that a credential IS stored,
435/// concludes the key is wrong and rotates it. Forever. The same in reverse: a
436/// token has no place in an SSH publickey exchange, so the dial falls through
437/// to whatever else is offerable and fails on authentication with no mention of
438/// the token at all.
439///
440/// A stored credential of no kind is not a mismatch — a public upstream mirrors
441/// fine with nothing, and that path must keep working.
442pub fn check_credential(wire: Wire, credential: &Credential) -> Result<(), String> {
443 match (wire, credential) {
444 (_, Credential::None) => Ok(()),
445 (Wire::Ssh, Credential::SshKey(_)) => Ok(()),
446 (Wire::Http, Credential::Token { .. }) => Ok(()),
447 (wire, held) => Err(format!(
448 "this mirror's remote is dialled over {}, and the credential stored for it is {}. \
449 {} cannot carry it, so the fetch would go out unauthenticated and be refused as \
450 though no credential were set. Store {} instead, with `Mirror.SetCredential`.",
451 wire.name(),
452 held.kind().name(),
453 wire.name(),
454 match wire {
455 Wire::Ssh => "an ssh private key",
456 Wire::Http => "a token",
457 }
458 )),
459 }
460}
461
462// ── the gate ─────────────────────────────────────────────────────────────────
463
464/// **The repository's branch protection, over the batch a mirror is about to
465/// apply.**
466///
467/// One implementation: this is `gunnar_policy::BranchProtectionGate`, the same
468/// object `receive_pack`'s [`crate::transport::PolicyGate`] evaluates, reading
469/// the same document through the same [`crate::transport::load_policy`]. A
470/// second copy of "what does `allow_force_push` mean" is precisely the pair
471/// that drifts, and the three independent ways past branch protection this
472/// repository has had are all the same shape: a second writer that does what
473/// the gated writer does, without the gate.
474///
475/// # What a mirror can decide, and the one thing it cannot
476///
477/// `allow_create`, `allow_delete` and `allow_force_push` are decidable from the
478/// batch and the objects, which are already in the store when [`apply`] runs.
479/// `require_signature` is decidable too — but only against the set of objects
480/// the fetch introduced, so a repository carrying any protection rule makes the
481/// ingest ask for that set ([`crate::storage::ObjectSet::Required`]) instead of
482/// paying nothing for it, exactly as a push does when a gate is live.
483///
484/// `allowed_principals` is the one that cannot be decided here, and it is
485/// refused rather than skipped. The caller's identity does not cross the
486/// control-plane seam on this verb — `mirror_sync(&self, path)` carries a
487/// repository and nothing about who asked — so there is no principal to compare
488/// against the list. "I could not tell" is not "it is fine", so a rule that
489/// names its principals refuses the whole sync by name, and the operator either
490/// drops the rule or stops mirroring onto the refs it covers.
491struct Protection {
492 gate: gunnar_policy::BranchProtectionGate,
493 keys: gunnar_policy::StaticKeyDirectory,
494 repo: gunnar_policy::RepoId,
495}
496
497impl Protection {
498 /// The rules covering `repo`, or `None` when it has none configured.
499 ///
500 /// Read **before** the fetch, because whether any rule exists is what
501 /// decides if the ingest pays for an object-set enumeration — the same
502 /// ordering, for the same reason, as `receive_pack`'s.
503 ///
504 /// `Err` is a policy document that could not be read, and it REFUSES the
505 /// sync — the same fail-closed rule the push path has (GUN-007). A mirror
506 /// that synced with its gates disabled because the policy did not parse
507 /// would be the bypass GUN-032 closed, back through the side door.
508 fn open(
509 stores: &ServerStores,
510 row: &gunnar_core::Repo,
511 git_dir: &std::path::Path,
512 ) -> std::result::Result<Option<Self>, String> {
513 let config = crate::transport::load_policy(
514 stores,
515 row,
516 &git_dir.join(crate::transport::POLICY_FILE),
517 )
518 .map_err(|why| format!("refusing to mirror into {}: {why}", row.key()))?;
519 if config.protection.is_empty() {
520 return Ok(None);
521 }
522 Ok(Some(Protection {
523 gate: gunnar_policy::BranchProtectionGate::new(config.protection.clone()),
524 keys: crate::transport::key_directory(stores),
525 repo: gunnar_policy::RepoId::new(&row.namespace, &row.name),
526 }))
527 }
528
529 /// **Refuse the batch, or let it through.** `Err` is the refusal a FAILED
530 /// job carries, and nothing has been written when it is returned.
531 ///
532 /// `new_objects` is the fetch's own receipt. An ingest that did not
533 /// enumerate hands back the reason rather than an empty set, and an empty
534 /// set here would be a silent always-allow for `require_signature` — so the
535 /// `Err` arm refuses, quoting why.
536 fn check(
537 &self,
538 git: &dyn gunnar_store::git_storage_trait::GitServe,
539 updates: &[gunnar_policy::RefUpdate],
540 new_objects: std::result::Result<&[ObjectId], &str>,
541 ) -> Result<(), String> {
542 use gunnar_policy::Gate as _;
543
544 // The field with no answer on this path, refused first and by name.
545 for update in updates {
546 for rule in self.gate.rules() {
547 if !rule.pattern.matches_ref(&update.name) {
548 continue;
549 }
550 if rule.allowed_principals.is_some() {
551 return Err(format!(
552 "{} is protected by {}, which names the principals that may move it. A \
553 mirror sync carries no principal — the control-plane verb takes a \
554 repository and not a caller — so that rule cannot be satisfied and this \
555 sync is refused rather than applied unchecked. Nothing was moved.",
556 update.name, rule.pattern
557 ));
558 }
559 break;
560 }
561 }
562
563 let new_objects = new_objects.map_err(|reason| {
564 format!(
565 "this repository has branch protection and the fetched pack's object set could \
566 not be enumerated ({reason}), so the gate could not decide. Nothing was moved."
567 )
568 })?;
569 // Never consulted: every rule that could read it has already refused
570 // above. Named rather than left as a plausible-looking value.
571 let principal = gunnar_policy::Principal::machine("mirror");
572 let ctx = gunnar_policy::PushContext::new(
573 &principal,
574 &self.repo,
575 updates,
576 new_objects,
577 git,
578 &self.keys,
579 );
580 match self.gate.check(&ctx) {
581 Ok(gunnar_policy::Verdict::Allow) => Ok(()),
582 Ok(gunnar_policy::Verdict::Reject(rejection)) => Err(format!(
583 "branch protection refused this mirror's reference batch: {}. Nothing was moved.",
584 rejection.reason
585 )),
586 // A gate that could not decide refuses; it does not allow.
587 Err(err) => Err(format!(
588 "branch protection could not decide this mirror's reference batch: {err}. \
589 Nothing was moved."
590 )),
591 }
592 }
593}
594
595// ── the local half ───────────────────────────────────────────────────────────
596
597/// **What this repository already holds**, as `fetch_pack`'s have-source.
598///
599/// One `GitOps::refs()` call, one snapshot, taken inside `FetchPack::fetch` so
600/// the `have` list and every `old` in the plan come from the same read — a
601/// reference that moves under the fetch cannot make the plan promise an old
602/// value the store never held.
603///
604/// `HEAD` and the pseudo-refs are not rows in `refs()` at all, and a purely
605/// symbolic row under `refs/` carries no oid and is skipped: it is not a thing
606/// to offer as a `have`, and `fetch_pack`'s own doc says an unborn ref should
607/// simply be absent.
608struct StoreRefs<'a> {
609 git: &'a dyn gunnar_store::git_storage_trait::GitServe,
610}
611
612impl LocalRefs for StoreRefs<'_> {
613 fn tips(&self) -> gunnar_wire::Result<Vec<(String, ObjectId)>> {
614 let rows = GitOps::refs(self.git).map_err(|err| {
615 gunnar_wire::Error::Store(gunnar_store::Error::backend(format!(
616 "reading this repository's refs to offer them as haves: {err:#}"
617 )))
618 })?;
619 let mut out = Vec::with_capacity(rows.len());
620 for row in rows {
621 if row.name == "HEAD" {
622 continue;
623 }
624 let Some(raw) = row.oid.as_deref() else {
625 continue;
626 };
627 let Ok(oid) = gix_hash::oid::try_from_bytes(raw) else {
628 return Err(gunnar_wire::Error::Store(gunnar_store::Error::backend(
629 format!("{} holds an oid this store cannot decode", row.name),
630 )));
631 };
632 out.push((row.name, oid.to_owned()));
633 }
634 Ok(out)
635 }
636}
637
638/// **The queue's progress field, written from the wire and from the walk.**
639///
640/// Three publishers, one job record, and each of the three is the only place on
641/// the system that knows its own number:
642///
643/// | published by | what | when |
644/// |---|---|---|
645/// | [`FetchProgress::objects_total`] | the denominator | once, the instant the packfile header is read |
646/// | [`FetchProgress::pack_bytes`] | the byte count | as the stream is consumed, while the network is the bottleneck |
647/// | [`PackProgress::indexed`] | the numerator | once per object, as the engine's pack walk splits them |
648///
649/// # Why the numerator arrives from the other side of the seam
650///
651/// `gunnar-wire` reads bytes 8..12 of the header and can publish `(0, N)`
652/// before an object is stored — twelve bytes, and it is the reason the
653/// denominator is live at all. It cannot publish the numerator: counting
654/// objects as they pass means inflating every one of them, which is a second
655/// pack decoder, and gunnar has exactly one (one implementation, and `fetch_pack`'s own
656/// module doc refuses it in those words). The one decoder is the storage
657/// engine's walk, which has to split every entry anyway, so the count is free
658/// exactly there and nowhere else. This type is where the two halves meet.
659///
660/// # `total` is remembered here, and that is not a second counter
661///
662/// [`PackProgress::indexed`] carries its own `total` — the count the engine
663/// read from the same header — and it is *checked* against the one the wire
664/// announced rather than trusted, because two readings of one field that
665/// disagree mean the ingest is being handed a different pack from the one that
666/// was negotiated. `fetch_pack` makes the same comparison at the end, on the
667/// receipt; this is the same fact asserted while there is still a transfer to
668/// abandon.
669struct QueueProgress<'a> {
670 report: &'a Reporter<Mirror>,
671 total: AtomicU64,
672 /// A second sink, for a caller that has to see the *series* rather than the
673 /// job's latest reading. See [`sync_once_watching`].
674 watching: Option<&'a dyn git_storage_trait::PackProgress>,
675}
676
677impl QueueProgress<'_> {
678 /// **The one place a sync's progress pair is written.**
679 ///
680 /// All three publishers go through here — the header's `(0, N)`, the
681 /// engine's per-object readings, and [`finish`]'s `(N, N)` — so the job and
682 /// any watcher see the same values in the same order. Two routes to the
683 /// same field would be two places the series could differ, and the series
684 /// is the whole subject.
685 fn publish(&self, done: u64, total: u64) {
686 self.report.progress(done, total);
687 if let Some(watcher) = self.watching {
688 watcher.indexed(done, total);
689 }
690 }
691}
692
693impl FetchProgress for QueueProgress<'_> {
694 fn objects_total(&self, objects: u32) {
695 self.total.store(u64::from(objects), Ordering::SeqCst);
696 self.publish(0, u64::from(objects));
697 }
698
699 fn pack_bytes(&self, streamed: u64) {
700 self.report.report(|mirror| mirror.bytes = streamed);
701 }
702}
703
704impl git_storage_trait::PackProgress for QueueProgress<'_> {
705 fn indexed(&self, done: u64, total: u64) {
706 // The wire's denominator wins. An engine that read a different total
707 // from the same twelve bytes is a bug worth a log line, not a reason to
708 // renumber a progress bar mid-flight and leave a console watching a
709 // percentage that jumped sideways.
710 let announced = self.total.load(Ordering::SeqCst);
711 let total = if announced == 0 || announced == total {
712 total
713 } else {
714 tracing::warn!(
715 target: TARGET_GIT,
716 announced,
717 indexed_total = total,
718 "the packfile header the wire read and the one the store read declare \
719 different object counts; the wire's is the one being rendered"
720 );
721 announced
722 };
723 self.publish(done, total);
724 }
725}
726
727// ── the fetch ────────────────────────────────────────────────────────────────
728
729/// **One sync, start to finish.** The [`crate::mirror_queue::MirrorRunner`]
730/// body.
731///
732/// Errors are `String` because they are going onto a job a console will read,
733/// not up a call stack — the same reason `gc_queue`'s runner returns one.
734pub fn sync_once(
735 stores: &Arc<ServerStores>,
736 order: &MirrorOrder,
737 credential: &Credential,
738 report: &Reporter<Mirror>,
739) -> Result<(), String> {
740 sync_once_watching(stores, order, credential, report, None)
741}
742
743/// [`sync_once`], with a **second** place the object count is published to.
744///
745/// # Why this door exists, and why it is not a test hook
746///
747/// [`Reporter::progress`] *overwrites*, by design — its own doc says a progress
748/// that accumulated would be a second counter to keep right — so the job record
749/// holds the latest reading and never the series. That is correct for a console,
750/// which only ever wants the latest reading, and it means the one thing that
751/// cannot be established from the job record afterwards is **whether the count
752/// moved at all**. A publisher that fires `(0, N)` and then `(N, N)` and a
753/// publisher that fires five hundred times in between leave the job in exactly
754/// the same state, so from the outside they are the same publisher.
755///
756/// That is measure-never-assert in its plainest form: a stub is
757/// indistinguishable from an
758/// implementation unless something can see it work — and it is precisely the
759/// defect this seam was opened to close: `(0, N) → (N, N)` was the shape for as
760/// long as no engine published from inside its pack walk. So the series gets a
761/// door, and the acceptance test stands at it. There is one other caller,
762/// [`sync_once`], which passes `None`.
763///
764/// The sink is *additional*: the job is written exactly as before, in the same
765/// order, whether or not anybody is watching. A test that changed what the
766/// production path publishes would be testing itself.
767pub fn sync_once_watching(
768 stores: &Arc<ServerStores>,
769 order: &MirrorOrder,
770 credential: &Credential,
771 report: &Reporter<Mirror>,
772 watching: Option<&dyn git_storage_trait::PackProgress>,
773) -> Result<(), String> {
774 let wire = classify(&order.upstream)?;
775 check_credential(wire, credential)?;
776 // Checked again on the worker, and not only at the door where
777 // `Mirror.Add` checked it: a registration made before this rule existed,
778 // or a name whose A record has moved since, reaches the dial through here
779 // and nowhere else.
780 check_destination(&order.upstream, stores.root())?;
781
782 // Resolved here, on the worker, and not at enqueue time: a repository can
783 // be deleted while its sync waits, and discovering that when the work
784 // starts is a FAILED job saying so, which is the truth.
785 let repo_ref = gunnar_core::resolve_repo_path(&order.path)
786 .map_err(|err| format!("`{}` is not a valid <namespace>/<name>: {err:#}", order.path))?;
787 let existing = stores
788 .catalog()
789 .get(&repo_ref)
790 .map_err(|err| format!("reading the catalog row for {}: {err:#}", order.path))?;
791 // **An import's repository is made HERE, by the first sync that reaches
792 // the upstream** (owner order 2026-10-18; `MirrorRecord::creates_repo`).
793 let to_create = existing.is_none()
794 && stores
795 .mirrors()
796 .get(&repo_ref)
797 .map_err(|err| format!("reading the registration for {}: {err:#}", order.path))?
798 .is_some_and(|record| record.creates_repo);
799 if existing.is_none() && !to_create {
800 return Err(format!("{} is no longer a live repository", order.path));
801 }
802 // **An encrypted repository is never fetched into** (wiring tracer B
803 // 2026-10-08): the ingest below runs no encrypted gate (`PushIngest::new`),
804 // so an upstream's plaintext would be stored and framed for the twin.
805 // `Mirror.Add` / `Repos.SetEncrypted` refuse making such a mirror; this is
806 // a registration older than that rule, or a flag set below the door.
807 if existing.as_ref().is_some_and(|row| row.encrypted) {
808 return Err(format!(
809 "{} is encrypted, so it is not fetched into: a mirror copies another server's \
810 plaintext, and an encrypted repository holds only what its members sealed \
811 (remove the mirror)",
812 order.path
813 ));
814 }
815 // **The namespace's room, asked of the same gates a push is held to**
816 // (2026-10-02). A suspended namespace or one already at its cap fetches
817 // nothing — refused here, before the dial, in the gate's own words — and
818 // the fetch is held to the tightest ceiling in the chain as it streams.
819 let ceiling = room_for(&stores.plan_gates(), repo_ref.namespace()).map_err(|(why, line)| {
820 report.report(|r| r.refused = why);
821 line
822 })?;
823
824 let auth = match credential {
825 Credential::Token { username, secret } if username.is_empty() => {
826 Some(format!("Bearer {secret}"))
827 }
828 Credential::Token { username, secret } => {
829 Some(format!("Basic {}", base64_basic(username, secret)))
830 }
831 _ => None,
832 };
833
834 let row = match existing {
835 Some(row) => row,
836 None => create_from_upstream(stores, &repo_ref, order, wire, credential, auth.clone())?,
837 };
838 let fetched = fetch_into(stores, &row, order, wire, credential, auth, ceiling, report, watching);
839 match &fetched {
840 Ok(advertised) => after_landing(stores, &repo_ref, &row, order, advertised),
841 Err(_) if to_create => discard_created(stores, &repo_ref, order),
842 Err(_) => {}
843 }
844 fetched.map(|_| ())
845}
846
847/// **Ask the upstream what it is, then make the repository to match.**
848///
849/// One `ls-refs` at the upstream's own object format (`ls_remote_any`): the
850/// format becomes the repository's, and `HEAD`'s `symref-target` its default
851/// branch. Nothing is created when the upstream cannot be reached or refuses
852/// (a private repository without its token), so a failed first import leaves
853/// no repository behind.
854fn create_from_upstream(
855 stores: &Arc<ServerStores>,
856 repo_ref: &gunnar_core::RepoRef,
857 order: &MirrorOrder,
858 wire: Wire,
859 credential: &Credential,
860 auth: Option<String>,
861) -> Result<gunnar_core::model::Repo, String> {
862 let listed = |err: gunnar_wire::Error| {
863 format!(
864 "asking {} what it holds: {err}",
865 crate::mirror_http::scrub(&order.upstream)
866 )
867 };
868 let advertisement = match wire {
869 Wire::Http => {
870 let (mut reader, mut writer) = crate::mirror_http::open(&order.upstream, auth)?;
871 gunnar_wire::ls_remote_any(&mut reader, &mut writer).map_err(listed)?
872 }
873 Wire::Ssh => dial_ssh(
874 &order.upstream,
875 credential,
876 &stores.root().join(MIRROR_KNOWN_HOSTS),
877 strict_host_keys(stores.root()),
878 |reader, writer| gunnar_wire::ls_remote_any(reader, writer),
879 )?
880 .map_err(listed)?,
881 };
882 let hash = match advertisement.hash_kind {
883 gix_hash::Kind::Sha1 => gunnar_core::HashKind::Sha1,
884 _ => gunnar_core::HashKind::Sha256,
885 };
886 let mut new = gunnar_core::NewRepo::new(repo_ref.clone())
887 .hash_kind(hash)
888 .visibility(gunnar_core::Visibility::Private);
889 new.description = format!("mirror of {}", crate::mirror_http::scrub(&order.upstream));
890 if let Some(branch) = advertised_head(&advertisement.refs) {
891 new = new.default_branch(branch);
892 }
893 tracing::info!(
894 target: TARGET_GIT,
895 event = "mirror.create_from_upstream",
896 repo = %order.path,
897 hash = ?hash,
898 "an import's repository is made at the upstream's object format and default branch"
899 );
900 let row = stores
901 .create_repo(new, false)
902 .map_err(|err| format!("creating {} for the import: {err:#}", order.path))?;
903 stores
904 .repo_store(&row)
905 .map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
906 Ok(row)
907}
908
909/// `HEAD`'s branch, as the upstream advertised it, when it is a branch.
910fn advertised_head(refs: &[gunnar_wire::upload_pack::AdvertisedRef]) -> Option<String> {
911 refs.iter()
912 .find(|r| r.name == "HEAD")
913 .and_then(|r| r.symref_target.clone())
914 .filter(|target| target.starts_with("refs/heads/") && gunnar_store::refname::validate(target).is_ok())
915}
916
917/// A first import that failed after its repository was made: the repository
918/// goes again, so nothing empty is left behind. Best effort — a removal that
919/// fails is logged, and the registration still says the repository is to come.
920fn discard_created(stores: &Arc<ServerStores>, repo_ref: &gunnar_core::RepoRef, order: &MirrorOrder) {
921 let removed = stores
922 .catalog()
923 .delete(repo_ref)
924 .map_err(|err| format!("{err:#}"))
925 .and_then(|_| stores.purge_repo(repo_ref).map_err(|err| format!("{err:#}")));
926 if let Err(err) = removed {
927 tracing::warn!(
928 target: TARGET_GIT,
929 repo = %order.path,
930 %err,
931 "a failed first import could not remove the repository it had made"
932 );
933 }
934}
935
936/// **What a landed sync owes the mirror besides its references**: the
937/// upstream's default branch (when it holds that branch now) and whether the
938/// upstream uses Git LFS. Neither fails the sync — the references landed.
939fn after_landing(
940 stores: &Arc<ServerStores>,
941 repo_ref: &gunnar_core::RepoRef,
942 row: &gunnar_core::model::Repo,
943 order: &MirrorOrder,
944 advertised: &[gunnar_wire::upload_pack::AdvertisedRef],
945) {
946 let mut row = row.clone();
947 if let Some(branch) = advertised_head(advertised) {
948 let held = stores
949 .repo_store(&row)
950 .ok()
951 .and_then(|store| GitOps::refs(store.serve()).ok())
952 .is_some_and(|refs| refs.iter().any(|r| r.name == branch && r.oid.is_some()));
953 if held && branch != row.default_branch {
954 match stores.catalog().set_default_branch(repo_ref, &branch) {
955 Ok(moved) => {
956 row = moved;
957 if let Err(err) = stores.repoint_head(&row) {
958 tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "pointing HEAD at the upstream's default branch");
959 }
960 }
961 Err(err) => {
962 tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "taking the upstream's default branch");
963 }
964 }
965 }
966 }
967 let lfs = uses_lfs(stores, &row);
968 if let Err(err) = stores.mirrors().set_lfs_detected(repo_ref, lfs) {
969 tracing::warn!(target: TARGET_GIT, repo = %order.path, %err, "recording whether the upstream uses LFS");
970 }
971}
972
973/// **Does the default branch route files through Git LFS?** A `filter=lfs`
974/// in its root `.gitattributes` is the sign every LFS repository carries; the
975/// files it names arrive as pointer files, and their content is not mirrored.
976fn uses_lfs(stores: &Arc<ServerStores>, row: &gunnar_core::model::Repo) -> bool {
977 use gunnar_store::git_storage_trait::ObjType;
978 use gunnar_store::object;
979 let Ok(store) = stores.repo_store(row) else {
980 return false;
981 };
982 let git = store.serve();
983 let hash = store.hash_kind();
984 let Some(tip) = GitOps::refs(git)
985 .ok()
986 .and_then(|refs| refs.into_iter().find(|r| r.name == row.default_branch))
987 .and_then(|r| r.oid)
988 .and_then(|raw| ObjectId::try_from(&raw[..]).ok())
989 else {
990 return false;
991 };
992 let read = |id: &ObjectId, want: ObjType| match git.read(id.as_bytes()) {
993 Ok(Some((kind, bytes))) if kind == want => Some(bytes),
994 _ => None,
995 };
996 let Some(commit) = read(&tip, ObjType::Commit).and_then(|bytes| object::commit(&bytes, hash).ok()) else {
997 return false;
998 };
999 let Some(tree) = read(&commit.tree, ObjType::Tree) else {
1000 return false;
1001 };
1002 let Ok(Some(entry)) = object::tree_entry_named(&tree, hash, b".gitattributes") else {
1003 return false;
1004 };
1005 read(&entry.oid, ObjType::Blob).is_some_and(|attributes| {
1006 String::from_utf8_lossy(&attributes)
1007 .lines()
1008 .any(|line| !line.trim_start().starts_with('#') && line.contains("filter=lfs"))
1009 })
1010}
1011
1012/// **The fetch and the apply**, into a repository that exists. Answers what
1013/// the upstream advertised, for [`after_landing`].
1014#[allow(clippy::too_many_arguments)]
1015fn fetch_into(
1016 stores: &Arc<ServerStores>,
1017 row: &gunnar_core::model::Repo,
1018 order: &MirrorOrder,
1019 wire: Wire,
1020 credential: &Credential,
1021 auth: Option<String>,
1022 ceiling: Option<gunnar_wire::PackCeiling>,
1023 report: &Reporter<Mirror>,
1024 watching: Option<&dyn git_storage_trait::PackProgress>,
1025) -> Result<Vec<gunnar_wire::upload_pack::AdvertisedRef>, String> {
1026 let row = row.clone();
1027 let store = stores
1028 .repo_store(&row)
1029 .map_err(|err| format!("opening {} to mirror into it: {err:#}", order.path))?;
1030 let git = store.serve();
1031 let hash_kind = store.hash_kind();
1032
1033 // Opened BEFORE the ingest, and that ordering is load-bearing for the same
1034 // reason it is on the push path: whether this repository has a protection
1035 // rule at all is what decides if the fetch pays for an object-set
1036 // enumeration.
1037 let protection = Protection::open(stores, &row, store.git_dir())?;
1038
1039 let local = StoreRefs { git };
1040 let progress = QueueProgress {
1041 report,
1042 total: AtomicU64::new(0),
1043 watching,
1044 };
1045 // The ingest a PUSH lands through, unchanged. A fetched pack and a pushed
1046 // pack are the same bytes (`fetch_pack`'s module doc), so this is the same
1047 // storage authority and not a second one.
1048 //
1049 // The object set is asked for exactly when something reads it. A repository
1050 // with a protection rule has a gate that does (`require_signature` walks the
1051 // commits this fetch introduced), so the enumeration is `Required` and an
1052 // engine that cannot produce one must refuse rather than let the gate decide
1053 // on nothing. With no rule configured nobody reads it, and `NotNeeded` keeps
1054 // the fetch from waiting on index work no answer depends on.
1055 //
1056 // The last argument is the numerator's whole route: the ingest hands it to
1057 // the engine, the engine hands it to the pack walk, and the walk publishes
1058 // once per object into `progress` above — which is the same value the wire
1059 // is publishing the denominator and the byte count into. One sink, three
1060 // publishers, no second decoder anywhere on the path.
1061 let ingest = crate::transport::PushIngest::new(
1062 store.git_dir().to_path_buf(),
1063 hash_kind,
1064 stores.engine(),
1065 if protection.is_some() {
1066 crate::storage::ObjectSet::Required
1067 } else {
1068 crate::storage::ObjectSet::NotNeeded
1069 },
1070 Some(&progress),
1071 // A fetched pack is a transaction exactly as a pushed one is: framed before it lands.
1072 Some(crate::transport::PackFrame::of(stores, &row)),
1073 )
1074 // …and held to the data set's room the way a push is (cov-13).
1075 .with_space(stores.space_ledger());
1076
1077 let mut fetch =
1078 FetchPack::new(hash_kind, &fetch_pack::mirror, &local).with_progress(&progress);
1079 if let Some(ceiling) = ceiling {
1080 fetch = fetch.with_ceiling(ceiling);
1081 }
1082
1083 let outcome = match wire {
1084 Wire::Http => {
1085 let (mut reader, mut writer) = crate::mirror_http::open(&order.upstream, auth)?;
1086 fetch.fetch(&ingest, &mut reader, &mut writer)
1087 }
1088 Wire::Ssh => dial_ssh(
1089 &order.upstream,
1090 credential,
1091 &stores.root().join(MIRROR_KNOWN_HOSTS),
1092 strict_host_keys(stores.root()),
1093 |reader, writer| fetch.fetch(&ingest, reader, writer),
1094 )?,
1095 }
1096 .map_err(|err| {
1097 // Cut at the namespace's ceiling: the plan's refusal, as a field.
1098 if matches!(err, gunnar_wire::Error::OverCeiling(_)) {
1099 report.report(|r| r.refused = crate::mirror_queue::Refusal::PackCap);
1100 }
1101 format!(
1102 "fetching {} from {}: {err}",
1103 order.path,
1104 crate::mirror_http::scrub(&order.upstream)
1105 )
1106 })?;
1107
1108 apply(git, order, &outcome, &progress, protection.as_ref())?;
1109 Ok(outcome.advertised)
1110}
1111
1112/// **How much a mirror sync into `namespace` may fetch, decided by the front
1113/// doors' own plan gates** ([`ServerStores::install_plan_gates`]).
1114///
1115/// The push path's questions, in its order (`gunnar_ssh::server`'s
1116/// `ask_plan`): the gate's verdict on a `Write`, then — for a write that grows
1117/// data, which a fetch is — `pack_quota` (is the namespace already at its
1118/// cap?) and the TIGHTEST `pack_ceiling` in the chain. The first `Refuse` is
1119/// the answer, verbatim. The ceiling is bound to the namespace's shared pack
1120/// ledger, marked here before any gate measured, so a mirror fetch and a push
1121/// landing at the same moment share the room instead of each spending it.
1122///
1123/// `Ok(None)` is "nothing caps it": a chain with no gate that answers, which is
1124/// the community appliance's ordinary state.
1125///
1126/// A refusal comes back with WHICH question refused it — the plan's verdict
1127/// on a write ([`crate::mirror_queue::Refusal::Plan`]) or the pack room
1128/// ([`crate::mirror_queue::Refusal::PackCap`]) — so the job can say so as a
1129/// field (`MirrorJob.refused_reason`).
1130pub(crate) fn room_for(
1131 gates: &[Arc<dyn gunnar_ssh::PlanGate>],
1132 namespace: &str,
1133) -> Result<Option<gunnar_wire::PackCeiling>, (crate::mirror_queue::Refusal, String)> {
1134 use gunnar_ssh::{PlanAction, PlanVerdict};
1135 if gates.is_empty() {
1136 return Ok(None);
1137 }
1138 // Taken BEFORE any gate measures, for the reason the push path gives:
1139 // only one of the two roundings is a quota that leaks.
1140 let mark = gunnar_lfs::AllowanceLedger::shared_pack().mark(namespace);
1141 let mut tightest: Option<gunnar_ssh::PackCeiling> = None;
1142 for gate in gates {
1143 if let PlanVerdict::Refuse(line) = gate.verdict(namespace, PlanAction::Write) {
1144 return Err((crate::mirror_queue::Refusal::Plan, line));
1145 }
1146 if let PlanVerdict::Refuse(line) = gate.pack_quota(namespace) {
1147 return Err((crate::mirror_queue::Refusal::PackCap, line));
1148 }
1149 if let Some(asked) = gate.pack_ceiling(namespace) {
1150 if tightest.as_ref().is_none_or(|held| asked.bytes < held.bytes) {
1151 tightest = Some(asked);
1152 }
1153 }
1154 }
1155 Ok(tightest.map(|ceiling| gunnar_wire::PackCeiling {
1156 bytes: ceiling.bytes,
1157 refusal: ceiling.refusal,
1158 ledger: Some(Arc::new(crate::transport::PackMark { mark })
1159 as Arc<dyn gunnar_wire::PackLedger>),
1160 }))
1161}
1162
1163/// **Apply the plan, in one transaction, against the values the fetch
1164/// reasoned about.**
1165///
1166/// See the module doc for why this is `put_refs_cas` and why a rejection is a
1167/// failure rather than a skip.
1168fn apply(
1169 git: &dyn gunnar_store::git_storage_trait::GitServe,
1170 order: &MirrorOrder,
1171 outcome: &FetchReport,
1172 progress: &QueueProgress<'_>,
1173 protection: Option<&Protection>,
1174) -> Result<(), String> {
1175 let zero = "0".repeat(outcome.hash_kind.len_in_hex());
1176
1177 // Owned first, because `RefCas` borrows its oids and the batch has to
1178 // outlive every one of them.
1179 // (name, old, new, peeled)
1180 type OwnedUpdate = (String, Option<Vec<u8>>, Option<Vec<u8>>, Option<Vec<u8>>);
1181 let mut owned: Vec<OwnedUpdate> = Vec::new();
1182 let mut updates: Vec<(String, String, String)> = Vec::new();
1183
1184 for plan in &outcome.updates {
1185 // **The name came off somebody else's wire, so it goes through the ref
1186 // name rules before it is written.**
1187 //
1188 // `plan.name` is the string an upstream's `ls-refs` answered with,
1189 // carried untouched from `fetch_pack`, and the only filter it passed on
1190 // the way is `fetch_pack::mirror`'s prefix test — which admits
1191 // `refs/heads/../../etc/passwd` exactly as readily as `refs/heads/main`.
1192 // Receive-pack puts every client-supplied name through
1193 // `gunnar_store::refname::validate` at the same point in its own path
1194 // (`receive_pack.rs`'s `funny refname`), and a fetch is the same
1195 // untrusted input arriving through a different door.
1196 //
1197 // It is checked HERE rather than relied on from the storage engine. The
1198 // memory backend does validate in `put_refs_cas`, so a name like this
1199 // would be refused there today — but that is a property of one engine,
1200 // and "a rule one backend enforces by accident is not a rule" is the
1201 // sentence `refname` itself is written under. The engines are swappable
1202 // (`GUNNAR_ENGINE`), and this is the one caller that can guarantee the
1203 // check ran.
1204 //
1205 // Only the names that came from the upstream. The prune half below
1206 // reads its names out of this repository's OWN refs, and a bad name
1207 // already in the store is one a prune should be able to delete rather
1208 // than one it must refuse to touch.
1209 gunnar_store::refname::validate(plan.name.as_bytes()).map_err(|err| {
1210 format!(
1211 "the upstream advertised a reference name gunnar will not write: {err}. Nothing \
1212 was moved. A name is not a path, and this one would not have been a name."
1213 )
1214 })?;
1215 // The peel the remote already sent, rather than a peel computed here.
1216 // `ls-refs` was asked with `peel`, so an annotated tag arrived with the
1217 // commit it resolves to; recomputing it would be an object read per tag
1218 // for an answer already in hand (the tag re-peel, from the other side).
1219 let peeled = outcome
1220 .advertised
1221 .iter()
1222 .find(|advertised| advertised.name == plan.name)
1223 .and_then(|advertised| advertised.peeled)
1224 .map(|oid| oid.as_bytes().to_vec());
1225 updates.push((
1226 plan.name.clone(),
1227 plan.old.map(|old| old.to_string()).unwrap_or_else(|| zero.clone()),
1228 plan.new.to_string(),
1229 ));
1230 owned.push((
1231 plan.name.clone(),
1232 plan.old.map(|old| old.as_bytes().to_vec()),
1233 Some(plan.new.as_bytes().to_vec()),
1234 peeled,
1235 ));
1236 }
1237
1238 if order.prune {
1239 // **What the upstream no longer has.** Only over the same namespace the
1240 // ref plan covers (`refs/heads/*`, `refs/tags/*`): a prune that reached
1241 // wider would delete this box's own `refs/gunnar/*` bookkeeping on the
1242 // grounds that an upstream never advertised it.
1243 let held = GitOps::refs(git)
1244 .map_err(|err| format!("reading {}'s refs to prune them: {err:#}", order.path))?;
1245 for row in held {
1246 if !fetch_pack::mirror(&row.name) {
1247 continue;
1248 }
1249 if outcome
1250 .advertised
1251 .iter()
1252 .any(|advertised| advertised.name == row.name)
1253 {
1254 continue;
1255 }
1256 let Some(old) = row.oid else { continue };
1257 updates.push((row.name.clone(), hex(&old), zero.clone()));
1258 owned.push((row.name, Some(old), None, None));
1259 }
1260 }
1261
1262 if owned.is_empty() {
1263 finish(outcome, progress, Vec::new());
1264 return Ok(());
1265 }
1266
1267 // **The gate, between the plan and the write.** Before this line the batch
1268 // below was the one reference write on the box that no gate had seen; a
1269 // repository with no protection rule configured has no `Protection` at all,
1270 // so a mirror of an unprotected repository pays nothing for this.
1271 if let Some(protection) = protection {
1272 let gated: Vec<gunnar_policy::RefUpdate> = owned
1273 .iter()
1274 .map(|(name, old, new, _)| {
1275 // The null-oid spelling never appears here: `owned` already
1276 // carries absence as `None`, which is what `RefUpdate`'s three
1277 // constructors take. `try_from_bytes` cannot fail on a value the
1278 // store or the wire produced, and an oid this store cannot
1279 // decode is the one `StoreRefs::tips` already refuses by name.
1280 let decode = |raw: &Vec<u8>| {
1281 gix_hash::oid::try_from_bytes(raw)
1282 .map(gix_hash::oid::to_owned)
1283 .map_err(|err| {
1284 format!("{name} holds an oid this server cannot decode: {err}")
1285 })
1286 };
1287 Ok(gunnar_policy::RefUpdate {
1288 name: name.clone(),
1289 old: old.as_ref().map(&decode).transpose()?,
1290 new: new.as_ref().map(&decode).transpose()?,
1291 })
1292 })
1293 .collect::<Result<_, String>>()?;
1294 let new_objects = match outcome.receipt.as_ref() {
1295 // Nothing was fetched, so nothing was introduced. That is a measured
1296 // empty set and not an unasked question — see `FetchReport::receipt`.
1297 None => Ok(Vec::new()),
1298 Some(receipt) => receipt.new_objects.clone(),
1299 };
1300 protection.check(git, &gated, new_objects.as_deref().map_err(String::as_str))?;
1301 }
1302
1303 let batch: Vec<RefCas<'_>> = owned
1304 .iter()
1305 .map(|(name, old, new, peeled)| RefCas {
1306 name: name.clone(),
1307 old: old.as_deref(),
1308 new: new.as_deref(),
1309 peeled: peeled.clone(),
1310 })
1311 .collect();
1312
1313 // ONE call. Every edit or none — a loop with a break leaves what it already
1314 // applied applied, and a half-mirrored repository is a state no upstream
1315 // ever had.
1316 GitOps::put_refs_cas(git, &batch).map_err(|err| {
1317 format!(
1318 "the fetch succeeded and the reference update did not: {err:#}. Nothing was moved. \
1319 The usual cause is a push that landed on {} while the fetch was running; the \
1320 expected values came from the snapshot the fetch negotiated against, and \
1321 overwriting a concurrent push is the one thing a mirror must never do. Sync again.",
1322 order.path
1323 )
1324 })?;
1325
1326 tracing::info!(
1327 target: TARGET_GIT,
1328 event = "mirror.sync.applied",
1329 repo = %order.path,
1330 refs = updates.len(),
1331 objects = outcome.objects_done,
1332 bytes = outcome.pack_bytes,
1333 "a mirror sync moved this repository's references"
1334 );
1335
1336 finish(outcome, progress, updates);
1337 Ok(())
1338}
1339
1340/// **Close the job's numbers out**, on both paths out of [`apply`].
1341///
1342/// One function and not two copies, because the pair below is the whole of what
1343/// a console reads when a sync ends and the two paths differ only in whether
1344/// any reference moved.
1345///
1346/// The `progress` call is the second half of the pair `FetchProgress` opened.
1347/// `objects_total` published `(0, N)` the instant the packfile header arrived;
1348/// **this publishes `(N, N)`**, and without it a finished job sits for ever at
1349/// nought per cent of a total it knows — which is worse than no progress bar,
1350/// because it reads as a transfer that stalled. `fetch_pack` has already
1351/// refused the transfer if the declared count and the indexed count disagree,
1352/// so `objects_done` is the same `N` and this cannot report a completion the
1353/// store did not make.
1354///
1355/// A fetch that wanted nothing — the ordinary answer for a poll of an idle
1356/// upstream — has no header, no `N`, and therefore **no progress at all**: the
1357/// pair stays `None`, and a caller reads `has_progress = false` rather than a
1358/// fabricated `0/0`.
1359fn finish(
1360 outcome: &FetchReport,
1361 progress: &QueueProgress<'_>,
1362 updates: Vec<(String, String, String)>,
1363) {
1364 if outcome.receipt.is_some() {
1365 let total = u64::from(outcome.objects_total);
1366 progress.publish(u64::from(outcome.objects_done), total);
1367 }
1368 progress.report.report(|mirror| {
1369 mirror.objects = u64::from(outcome.objects_done);
1370 mirror.bytes = outcome.pack_bytes;
1371 mirror.updates = updates;
1372 });
1373}
1374
1375/// Lower-case hex, the spelling `gunnar.v1.RefUpdate` uses.
1376fn hex(raw: &[u8]) -> String {
1377 let mut out = String::with_capacity(raw.len() * 2);
1378 for byte in raw {
1379 use std::fmt::Write as _;
1380 let _ = write!(out, "{byte:02x}");
1381 }
1382 out
1383}
1384
1385/// `base64(user:secret)`, the Basic-auth pair.
1386///
1387/// Written out rather than pulled in: the whole alphabet is 64 characters and
1388/// the encoder is nine lines, against a dependency that would be in the
1389/// appliance's graph for this one call. It is not a general base64 — it does
1390/// exactly the one thing, and `mirror_http`'s tests assert on the bytes it
1391/// produces.
1392fn base64_basic(user: &str, secret: &str) -> String {
1393 const ALPHABET: &[u8; 64] =
1394 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1395 let raw = format!("{user}:{secret}");
1396 let raw = raw.as_bytes();
1397 let mut out = String::with_capacity(raw.len().div_ceil(3) * 4);
1398 for chunk in raw.chunks(3) {
1399 let b = [
1400 chunk[0],
1401 chunk.get(1).copied().unwrap_or(0),
1402 chunk.get(2).copied().unwrap_or(0),
1403 ];
1404 let packed = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
1405 out.push(ALPHABET[(packed >> 18) as usize & 63] as char);
1406 out.push(ALPHABET[(packed >> 12) as usize & 63] as char);
1407 out.push(if chunk.len() > 1 {
1408 ALPHABET[(packed >> 6) as usize & 63] as char
1409 } else {
1410 '='
1411 });
1412 out.push(if chunk.len() > 2 {
1413 ALPHABET[packed as usize & 63] as char
1414 } else {
1415 '='
1416 });
1417 }
1418 out
1419}
1420
1421// ── the ssh half ─────────────────────────────────────────────────────────────
1422
1423/// **Dial `git-upload-pack` over SSH and hand the channel's two halves to
1424/// `run`.**
1425///
1426/// `gunnar_client::connect_upload_pack` is the fetch-side twin of the
1427/// `connect_receive_pack` replication already pushes with, over
1428/// `nornir-git`'s russh transport. There is no second dialler here and there
1429/// must not be: that transport owns the connect, the publickey exchange, the
1430/// `known_hosts` policy, the blocking bridge and — load-bearing — the `Drop`
1431/// that tears the channel down *inside* the driving runtime.
1432///
1433/// # The key is a file for the length of one dial, and then it is not
1434///
1435/// `nornir_git::ssh::SshAuth` authenticates from a **path**. gunnar's mirror
1436/// credential is sealed bytes in the control database, because a private key
1437/// sitting in the clear on `/data` is the thing that store exists to prevent.
1438/// The two are bridged by a [`tempfile::NamedTempFile`] created `0600` and
1439/// dropped — and therefore unlinked — before this function returns, on every
1440/// path out of it including a panic.
1441///
1442/// The alternative was an in-memory key API on `nornir-git`, which is the right
1443/// long-term shape, and it is a change to a crate outside this repository. It
1444/// is named here rather than left as a silent compromise.
1445#[cfg(feature = "replication")]
1446fn dial_ssh<T>(
1447 url: &str,
1448 credential: &Credential,
1449 known_hosts: &std::path::Path,
1450 strict: bool,
1451 run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
1452) -> Result<T, String> {
1453 use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
1454 use std::io::BufReader;
1455
1456 let mut options = SshOptions::new();
1457 // A server has no ssh-agent and no interactive user. Offering the agent
1458 // would be offering identities nobody configured for this mirror — the
1459 // silent substitution `gunnar_client::resolve_identity` exists to forbid.
1460 options.use_agent = false;
1461
1462 // Held for the whole of `run`: dropping it unlinks the file, and the dial
1463 // reads it during `connect_upload_pack`.
1464 let key_file = match credential {
1465 Credential::SshKey(bytes) => {
1466 let mut file = tempfile::Builder::new()
1467 .prefix("gunnar-mirror-key-")
1468 .tempfile()
1469 .map_err(|err| format!("no room to materialise the mirror's ssh key: {err}"))?;
1470 #[cfg(unix)]
1471 {
1472 use std::os::unix::fs::PermissionsExt as _;
1473 std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o600))
1474 .map_err(|err| {
1475 format!("the mirror's ssh key could not be made private: {err}")
1476 })?;
1477 }
1478 file.write_all(bytes)
1479 .and_then(|()| file.flush())
1480 .map_err(|err| format!("writing the mirror's ssh key: {err}"))?;
1481 options.key_path = Some(file.path().to_path_buf());
1482 Some(file)
1483 }
1484 // No key named means the ladder, which on a server finds nothing; that
1485 // is the honest behaviour for a public upstream over ssh and it is what
1486 // `gunnar-client` warns about at start-up.
1487 _ => None,
1488 };
1489 // Trust-on-first-use against a `known_hosts` under the data root is what
1490 // replication does when an operator has not said otherwise, and a
1491 // mirror is dialling the same kind of box. See [`MIRROR_KNOWN_HOSTS`] for
1492 // why the file is NOT `~/.ssh/known_hosts`.
1493 //
1494 // **Unless the box says otherwise**, which it could not before: the policy
1495 // was this literal and nothing else, so a first sync trusted whatever
1496 // answered on the upstream's address, on a path that is about to offer it a
1497 // private key. See [`MIRROR_STRICT_HOST_KEY`].
1498 options.host_key = if strict {
1499 HostKeyPolicy::StrictKnownHosts
1500 } else {
1501 HostKeyPolicy::TofuKnownHosts
1502 };
1503 options.known_hosts = Some(known_hosts.to_path_buf());
1504
1505 let location = nornir_git::ssh::parse_ssh_url(url)
1506 .map_err(|err| format!("{} is not an ssh url: {err}", crate::mirror_http::scrub(url)))?;
1507 let mut channel = gunnar_client::connect_upload_pack(&location, &options).map_err(|err| {
1508 // `{:#}` over an `anyhow::Error`, and not `{err}`: `gunnar_client::Error`
1509 // is a thiserror enum whose `Ssh` variant Displays as the bare word
1510 // "ssh" and carries the real sentence — which host key, which policy —
1511 // as its `source`. A FAILED job reading `opening git-upload-pack on …:
1512 // ssh` tells an operator nothing they can act on, and the reason was
1513 // one formatting flag away the whole time.
1514 format!(
1515 "opening git-upload-pack on {}: {:#}",
1516 crate::mirror_http::scrub(url),
1517 anyhow::Error::new(err)
1518 )
1519 })?;
1520 let out = {
1521 let (reader, writer) = channel.io();
1522 let mut reader = BufReader::new(reader);
1523 run(&mut reader, writer)
1524 };
1525 drop(key_file);
1526 Ok(out)
1527}
1528
1529/// **What a build with no ssh dialler says**, rather than what it does.
1530///
1531/// `replication` is the feature that carries `gunnar-client`'s wire half and
1532/// `nornir-git`'s russh transport; without it there is no dialler in the
1533/// binary. A mirror registered against an `ssh://` remote on such a build is a
1534/// registration that can never run, and it says so at the one place it can be
1535/// acted on — a FAILED job naming the flag — rather than failing as a
1536/// connection error that sends an operator to look at the network.
1537///
1538/// The smart-HTTP half is **not** behind a feature: `ureq` is a plain
1539/// dependency, so every build of this crate can mirror from an `https://`
1540/// upstream.
1541#[cfg(not(feature = "replication"))]
1542fn dial_ssh<T>(
1543 url: &str,
1544 _credential: &Credential,
1545 _known_hosts: &std::path::Path,
1546 _strict: bool,
1547 _run: impl FnOnce(&mut dyn BufRead, &mut dyn Write) -> T,
1548) -> Result<T, String> {
1549 Err(format!(
1550 "{} is an ssh remote and this build carries no ssh dialler: it was compiled without \
1551 `--features replication`, which is what links `gunnar-client`'s russh transport. \
1552 Mirror from the upstream's `https://` URL, or use a build that has the feature.",
1553 crate::mirror_http::scrub(url)
1554 ))
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559 use super::*;
1560
1561 /// **Both transports are recognised from the URL alone**, and the scp-like
1562 /// form is ssh — the shape every forge prints on its clone button.
1563 #[test]
1564 fn a_remote_url_names_its_transport() {
1565 assert_eq!(classify("ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
1566 assert_eq!(classify("git+ssh://git@host/ns/repo.git"), Ok(Wire::Ssh));
1567 assert_eq!(classify("git@host:ns/repo.git"), Ok(Wire::Ssh));
1568 assert_eq!(classify("https://host/ns/repo.git"), Ok(Wire::Http));
1569 assert_eq!(classify("http://127.0.0.1:9418/ns/repo.git"), Ok(Wire::Http));
1570 }
1571
1572 /// A remote gunnar cannot dial is refused when it is REGISTERED, not on the
1573 /// first sync — and the refusal names the two forms that work.
1574 #[test]
1575 fn a_remote_that_is_neither_is_refused_by_name() {
1576 let err = classify("/srv/git/ns/repo.git").expect_err("a local path is not a remote");
1577 assert!(err.contains("ssh://") && err.contains("https://"), "{err}");
1578 let err = classify("git://host/ns/repo.git").expect_err("the dumb git protocol");
1579 assert!(err.contains("scheme gunnar cannot mirror from"), "{err}");
1580 }
1581
1582 /// **The mismatch is refused by name, in both directions.**
1583 ///
1584 /// The failure being prevented is not an error — it is a 401 (or an SSH
1585 /// authentication failure) against an operator who can see that a
1586 /// credential IS stored, and who will therefore rotate a key that was never
1587 /// the problem.
1588 #[test]
1589 fn a_credential_of_the_wrong_kind_is_refused_by_name() {
1590 let key = Credential::SshKey(b"-----BEGIN OPENSSH PRIVATE KEY-----".to_vec());
1591 let token = Credential::Token {
1592 username: String::new(),
1593 secret: "ghp_SECRET".to_owned(),
1594 };
1595
1596 let err = check_credential(Wire::Http, &key).expect_err("a key cannot ride on http");
1597 assert!(err.contains("smart http") && err.contains("ssh private key"), "{err}");
1598 assert!(!err.contains("BEGIN OPENSSH"), "the refusal quoted the key: {err}");
1599
1600 let err = check_credential(Wire::Ssh, &token).expect_err("a token cannot ride on ssh");
1601 assert!(err.contains("ssh") && err.contains("a token"), "{err}");
1602 assert!(!err.contains("ghp_SECRET"), "the refusal quoted the token: {err}");
1603
1604 // …and the matching pairs, plus the public case, are all fine.
1605 check_credential(Wire::Ssh, &key).expect("a key over ssh");
1606 check_credential(Wire::Http, &token).expect("a token over http");
1607 check_credential(Wire::Ssh, &Credential::None).expect("a public ssh upstream");
1608 check_credential(Wire::Http, &Credential::None).expect("a public http upstream");
1609 }
1610
1611 /// **The authority, out of each of the three shapes** — and the default
1612 /// port with it, because the port is half of what gets dialled.
1613 #[test]
1614 fn a_remote_url_names_the_machine_it_would_dial() {
1615 for (url, want) in [
1616 ("https://host/ns/repo.git", ("host".to_owned(), 443)),
1617 ("http://host/ns/repo.git", ("host".to_owned(), 80)),
1618 ("http://host:9418/ns/repo.git", ("host".to_owned(), 9418)),
1619 ("ssh://git@host/ns/repo.git", ("host".to_owned(), 22)),
1620 ("ssh://git@host:2222/ns/repo.git", ("host".to_owned(), 2222)),
1621 ("git@host:ns/repo.git", ("host".to_owned(), 22)),
1622 // The userinfo is not the host, and a `@` inside it must not become
1623 // one either.
1624 ("https://user:pass@host/ns/repo.git", ("host".to_owned(), 443)),
1625 // An IPv6 literal is all colons; the brackets are what separates the
1626 // address from the port, and splitting on the first colon would
1627 // yield `[`.
1628 ("ssh://git@[::1]:2222/ns/repo.git", ("::1".to_owned(), 2222)),
1629 (
1630 "https://[2606:4700::1111]/ns/repo.git",
1631 ("2606:4700::1111".to_owned(), 443),
1632 ),
1633 ("git@[::1]:ns/repo.git", ("::1".to_owned(), 22)),
1634 ] {
1635 assert_eq!(authority(url), Ok(want.clone()), "{url}");
1636 }
1637 assert!(authority("https:///ns/repo.git").is_err(), "no host at all");
1638 }
1639
1640 /// **Every range a caller must not be able to point this server at**, and
1641 /// the two that must still work.
1642 ///
1643 /// Asserted on [`address_verdict`] rather than on [`check_destination`]
1644 /// because the addresses are handed in: no resolver runs, so this is the
1645 /// same answer on a laptop, on a build box with a corporate DNS and in a
1646 /// container with none.
1647 #[test]
1648 fn a_mirror_refuses_every_address_only_the_server_can_reach() {
1649 for addr in [
1650 "127.0.0.1",
1651 "0.0.0.0",
1652 "10.0.0.5",
1653 "172.16.0.1",
1654 "192.168.1.1",
1655 // The one the finding names: cloud instance metadata.
1656 "169.254.169.254",
1657 "100.64.0.1",
1658 "192.0.0.1",
1659 "198.18.0.1",
1660 "240.0.0.1",
1661 "255.255.255.255",
1662 "224.0.0.1",
1663 "::1",
1664 "::",
1665 "fc00::1",
1666 "fe80::1",
1667 "ff02::1",
1668 // …written the long way. Without the mapped-address arm this one
1669 // walks past every v6 test and is allowed.
1670 "::ffff:127.0.0.1",
1671 "::ffff:169.254.169.254",
1672 ] {
1673 let ip: std::net::IpAddr = addr.parse().expect("a literal");
1674 let err = address_verdict("upstream.invalid", &[ip])
1675 .expect_err("{addr} must not be dialled on a caller's say-so");
1676 assert!(
1677 err.contains(addr) || err.contains(&ip.to_string()),
1678 "{addr}: {err}"
1679 );
1680 assert!(err.contains(MIRROR_ALLOW_PRIVATE), "{addr}: {err}");
1681 }
1682
1683 for addr in ["93.184.216.34", "8.8.8.8", "2606:4700:4700::1111"] {
1684 let ip: std::net::IpAddr = addr.parse().expect("a literal");
1685 address_verdict("upstream.invalid", &[ip])
1686 .unwrap_or_else(|err| panic!("{addr} is an ordinary public address: {err}"));
1687 }
1688 }
1689
1690 /// **One bad answer out of several is a refusal.** A name that resolves to a
1691 /// routable address and a loopback address is the rebinding shape, and a
1692 /// check that took the first answer, or the best one, would pass it.
1693 #[test]
1694 fn one_private_answer_among_several_refuses_the_whole_name() {
1695 let addrs: Vec<std::net::IpAddr> = ["93.184.216.34", "127.0.0.1"]
1696 .iter()
1697 .map(|a| a.parse().expect("a literal"))
1698 .collect();
1699 let err = address_verdict("both.invalid", &addrs).expect_err("one of them is loopback");
1700 assert!(err.contains("127.0.0.1"), "{err}");
1701
1702 // And nothing at all is not "fine": a name with no address is refused
1703 // rather than allowed through to a dial that would fail anyway.
1704 assert!(address_verdict("nowhere.invalid", &[]).is_err());
1705 }
1706
1707 /// The Basic pair is encoded the way every HTTP client encodes it. Checked
1708 /// against a value anybody can verify by hand rather than against this
1709 /// function's own output.
1710 #[test]
1711 fn basic_auth_is_ordinary_base64() {
1712 assert_eq!(base64_basic("Aladdin", "open sesame"), "QWxhZGRpbjpvcGVuIHNlc2FtZQ==");
1713 assert_eq!(base64_basic("a", "b"), "YTpi");
1714 assert_eq!(base64_basic("ab", "b"), "YWI6Yg==");
1715 }
1716}