Skip to main content

dynamic_config_git/
lib.rs

1//! Read [`dynamic-config`] configuration from a git repository.
2//!
3//! Configuration in git is how a great many teams already work: review,
4//! history, blame and rollback come free, and nobody runs etcd for a file that
5//! changes twice a month. This crate reads a file — or a set of them, out of
6//! one commit — at one ref, from one repository, and hands it to
7//! `dynamic-config` the way every other store crate does.
8//!
9//! ```no_run
10//! use dynamic_config_git::{Credential, GitSource};
11//!
12//! # struct AppConfigBuilder;
13//! # impl AppConfigBuilder { fn init(&self) -> Result<(), dynamic_config::Error> { Ok(()) } }
14//! # struct AppConfig;
15//! # impl AppConfig {
16//! #     fn set_remote(_: GitSource) {}
17//! #     fn refresh_remote() -> Result<(), dynamic_config::Error> { Ok(()) }
18//! #     fn builder(_: &str) -> AppConfigBuilder { AppConfigBuilder }
19//! # }
20//! let source = GitSource::builder("https://github.com/acme/config.git")
21//!     .branch("main")
22//!     .path("services/api/config.yaml")
23//!     .credential(Credential::token(std::env::var("GITHUB_TOKEN")?))
24//!     .build()?;
25//!
26//! AppConfig::set_remote(source);
27//!
28//! // Fetching is explicit; the load that follows touches no network.
29//! AppConfig::refresh_remote()?;
30//! AppConfig::builder("app").init()?;
31//! # Ok::<(), Box<dyn std::error::Error>>(())
32//! ```
33//!
34//! # Why git rather than four REST APIs
35//!
36//! GitHub, GitLab, Azure DevOps, Gitea, Bitbucket and a bare
37//! `git@host:repo.git` **all speak git**. Their file APIs are five clients,
38//! five auth models, five pagination stories and five ways of spelling *this
39//! ref*. "Compatible with all of them" is only reachable through the protocol
40//! they share, and the extra round trip that protocol costs is irrelevant at
41//! configuration cadence.
42//!
43//! The implementation is [`gix`] — pure Rust, no `libgit2`, no C toolchain and
44//! no OpenSSL question. The exception is SSH, which `gix` carries by spawning
45//! the system `ssh` exactly as `git` does; see [`SshAuth`].
46//!
47//! # What a fetch actually does
48//!
49//! **A shallow, single-ref fetch into a bare object database — never a
50//! clone.** In order:
51//!
52//! 1. Connect, shake hands, and read the ref advertisement. This is what
53//!    `git ls-remote` costs: a few hundred bytes, and no objects.
54//! 2. If the commit the ref names is already in the object database, stop.
55//!    **An unchanged ref transfers nothing.** That is what makes polling a git
56//!    host reasonable.
57//! 3. Otherwise ask for that one commit at depth 1 — the commit and its trees
58//!    and blobs, and none of the history behind it.
59//! 4. Read one blob out of the tree, in memory. Nothing is ever checked out.
60//!
61//! What it costs: the first fetch transfers the repository's current tree —
62//! every file at that commit, not just the one asked for, because a commit's
63//! tree is what the protocol delivers. A monorepo whose tree is a gigabyte
64//! will transfer a gigabyte once. Subsequent fetches transfer one commit's
65//! worth of changes.
66//!
67//! Filtering by path would cut that first transfer to the files actually read,
68//! and **it is not implemented because nothing below this crate can express
69//! it**, which is worth being precise about rather than calling it a
70//! to-do. `gix` 0.86 exposes no filter on a fetch: the protocol argument
71//! exists one layer down in `gix-protocol`, on a type only `gix`'s own fetch
72//! ever holds. And the filter the large hosts actually serve is `blob:none`,
73//! which answers with a tree whose blobs are *absent* — reading one then means
74//! a lazy fetch from a promisor remote, which nothing in this dependency graph
75//! implements. A path filter is therefore two upstream features away, not one
76//! call. The honest summary is that this crate is comfortable with a
77//! configuration repository and will be slow to start against a monorepo.
78//!
79//! There is no working tree, and that is the security decision as much as the
80//! performance one: a repository whose tree contains a symlink to
81//! `/etc/shadow`, or an entry named `../../etc/shadow`, cannot make a checkout
82//! that never happens write anywhere. See [`Builder::path`].
83//!
84//! # Which ref, and why a branch is the default
85//!
86//! [`Reference`] is a branch, a tag or a commit SHA, and all three are
87//! legitimate:
88//!
89//! | | Moves | Reproducible | For |
90//! |---|---|---|---|
91//! | [`branch`](Builder::branch) — the default, `main` | yes | no | hot reload: a merge to `main` *is* the deployment |
92//! | [`tag`](Builder::tag) | only if force-pushed | nearly | a release train |
93//! | [`commit`](Builder::commit) | never | yes | pinning a fleet to a known configuration |
94//!
95//! A branch is the default because a configuration store's reason to exist is
96//! that the configuration changes: pinning a SHA and then starting a watcher
97//! is asking a loop to wait for something that cannot happen. Pin the SHA when
98//! reproducibility matters more than reload, and say so by writing it down.
99//!
100//! A SHA is fetched by asking the host for that object directly. Hosts that
101//! allow it — GitHub, GitLab and Azure DevOps do — answer; one that has
102//! `uploadpack.allowReachableSHA1InWant` off will refuse, and the error says
103//! so.
104//!
105//! # Where the objects live
106//!
107//! A private directory, `0700` from the moment it exists. By default a
108//! temporary one, removed with the source; name your own with
109//! [`cache_dir`](Builder::cache_dir) to survive restarts. The trade-offs, and
110//! why two sources may not share one, are in [`working`](mod@working).
111//!
112//! # When a fetch fails
113//!
114//! It does not take the program down. A failed [`RemoteSource::fetch`] leaves
115//! the previously fetched document installed and the previously loaded
116//! configuration serving — that is `dynamic-config`'s last-known-good
117//! machinery, and this crate's only job is to report accurately enough for it
118//! to work:
119//!
120//! - a host that refuses the credential is
121//!   [`ErrorKind::Auth`](dynamic_config::ErrorKind::Auth), because waiting will
122//!   not fix a wrong token and a watch loop should stop rather than hammer;
123//! - everything else — an unreachable host, a ref that does not exist, a
124//!   document that is not UTF-8 — is
125//!   [`ErrorKind::Remote`](dynamic_config::ErrorKind::Remote), which a watch
126//!   loop waits out.
127//!
128//! # Credentials never appear in a diagnostic
129//!
130//! A git remote URL routinely embeds one. Every error message, every `Debug`
131//! and every string this crate produces puts the URL through
132//! [`dynamic_config_store_core::redacted`] first, and the tests plant a token
133//! and assert it is absent. An SSH key's contents are never read by this crate
134//! at all, and a passphrase is never accepted — see [`auth`](mod@auth) for why.
135//!
136//! # Watching
137//!
138//! git has no watch, so [`GitSource::watch`] polls — and says so. Each tick is
139//! one ref advertisement; only a ref that moved costs a transfer. The push
140//! half needs nothing from this crate: whoever terminates a GitHub or GitLab
141//! webhook calls the generated `remote_sink().apply(..)`.
142//!
143//! ```no_run
144//! # use dynamic_config::RemoteWatch;
145//! # use dynamic_config_git::GitSource;
146//! # use std::time::Duration;
147//! # struct Sink;
148//! # impl Sink {
149//! #     fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
150//! # }
151//! # fn example(source: GitSource) {
152//! # let sink = Sink;
153//! let watch = RemoteWatch::new();
154//! let watching = watch.watching();
155//!
156//! std::thread::spawn(move || {
157//!     source.watch(&watching, Duration::from_secs(60), move |document| sink.apply(document))
158//! });
159//!
160//! // Dropping `watch` — or calling `watch.stop()` — ends the loop.
161//! # }
162//! ```
163//!
164//! # Several files as one document
165//!
166//! One repository, one ref, and either one path, a list of them or a directory
167//! — see [`Keys`]. A fetch resolves **one commit**, and a commit has **one
168//! tree**, so a set of files is read as of one instant with nothing arranged
169//! for it: no transaction, no listing race, no second round trip. That is why
170//! this is the only store in the family whose multi-file sources can also be
171//! [watched](GitSource::watch); the others refuse, and say why.
172//!
173//! # A host this machine does not already trust
174//!
175//! An enterprise GitLab behind a private certificate authority, or a host that
176//! wants a client certificate first, is [`Builder::tls`]. It is the `https://`
177//! knob and only that one — an `ssh://` remote's trust lives in `known_hosts`
178//! and its client identity in a key, which is [`Credential::ssh_agent`],
179//! [`Credential::ssh_key`] or [`Credential::ssh_command`], and asking for both
180//! is refused rather than half-applied. There is no way to turn verification
181//! off; [`tls`](mod@tls) has the measurement and the argument.
182//!
183//! # What this crate deliberately does not do
184//!
185//! **An async implementation.** A git fetch is blocking work — negotiation,
186//! decompression, index writing — so this implements the blocking
187//! [`RemoteSource`]. An async program loses nothing: `refresh_remote_async()`
188//! puts a blocking source on `dynamic_config::off_thread`, so the executor's
189//! worker never sits inside it.
190//!
191//! **Shelling out to the system `git`.** It would reach every credential
192//! helper on the host for free, and it would also be a second implementation
193//! of every decision on this page — the fetch shape, the ref pinning, the
194//! error classification, and the redaction of a URL that `git` prints into its
195//! own stderr. [`SshAuth::Command`] reaches the one method the pure-Rust path
196//! cannot, and does it without a second code path.
197//!
198//! [`dynamic-config`]: https://docs.rs/dynamic-config
199//! [`gix`]: https://docs.rs/gix
200
201#![forbid(unsafe_code)]
202#![deny(missing_docs)]
203
204use std::path::PathBuf;
205use std::sync::Mutex;
206use std::time::Duration;
207
208use dynamic_config::{Error, Fetched, Format, RemoteSource, WatchCapability, Watching};
209use dynamic_config_store_core::documents::{self, Overlap};
210use dynamic_config_store_core::guarded;
211
212pub mod auth;
213mod fetch;
214pub mod tls;
215mod url;
216pub mod working;
217
218pub use auth::{Auth, Credential, SshAuth};
219// Re-exported rather than mirrored: it is one TLS vocabulary for every store
220// crate in this family, and a caller configuring two stores should write the
221// same three calls for both.
222pub use dynamic_config_store_core::tls::TlsConfig;
223
224use auth::Session;
225use fetch::Failure;
226use url::redacted;
227use working::Working;
228
229/// How long one fetch may take. Thirty seconds, because a git fetch is a
230/// negotiation and a decompression rather than one HTTP GET.
231const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
232
233/// How large a configuration file may be before it is refused, in bytes.
234///
235/// A megabyte is enormous for configuration and small enough that a hostile or
236/// mistaken repository cannot make this process allocate its way out of
237/// memory. Raise it with [`Builder::max_bytes`] if a real file needs it.
238const DEFAULT_MAX_BYTES: u64 = 1024 * 1024;
239
240/// The default branch, when none is named.
241const DEFAULT_BRANCH: &str = "main";
242
243/// Where the fetched ref is written locally.
244///
245/// Under `refs/` but outside `refs/heads` and `refs/remotes`, so nothing
246/// mistakes this working directory for a checkout somebody might want to use.
247const LOCAL_REF: &str = "refs/dynamic-config/head";
248
249/// Which commit to read.
250///
251/// See the [crate documentation](crate#which-ref-and-why-a-branch-is-the-default)
252/// for which to choose.
253#[derive(Clone, Debug, PartialEq, Eq)]
254#[non_exhaustive]
255pub enum Reference {
256    /// A branch, by short name — `main`, not `refs/heads/main`.
257    Branch(String),
258    /// A tag, by short name.
259    Tag(String),
260    /// A commit, by full hexadecimal object id.
261    Commit(String),
262}
263
264impl Reference {
265    /// The refspec that fetches this reference into [`LOCAL_REF`].
266    fn refspec(&self) -> String {
267        let source = match self {
268            Self::Branch(name) => format!("refs/heads/{name}"),
269            Self::Tag(name) => format!("refs/tags/{name}"),
270            Self::Commit(sha) => sha.clone(),
271        };
272
273        format!("+{source}:{LOCAL_REF}")
274    }
275
276    /// The full ref name a host would advertise for this reference.
277    fn advertised(&self) -> Option<String> {
278        match self {
279            Self::Branch(name) => Some(format!("refs/heads/{name}")),
280            Self::Tag(name) => Some(format!("refs/tags/{name}")),
281            Self::Commit(_) => None,
282        }
283    }
284
285    /// Picks this reference's commit out of what the host advertised.
286    ///
287    /// By name rather than by position: a host advertises everything the
288    /// refspec matched, and taking "the first one" would silently read a
289    /// different branch the day a refspec grows a wildcard.
290    fn resolve(
291        &self,
292        ref_map: &gix::remote::fetch::RefMap,
293        url: &str,
294    ) -> Result<gix::ObjectId, Failure> {
295        let wanted = self.advertised();
296
297        let found = ref_map
298            .mappings
299            .iter()
300            .find(|mapping| match &mapping.remote {
301                gix::remote::fetch::refmap::Source::ObjectId(_) => wanted.is_none(),
302                gix::remote::fetch::refmap::Source::Ref(remote) => wanted
303                    .as_deref()
304                    .is_some_and(|wanted| remote.unpack().0 == wanted),
305            });
306
307        found
308            .and_then(|mapping| mapping.remote.as_id())
309            .map(gix::hash::oid::to_owned)
310            .ok_or_else(|| {
311                Failure::Other(Error::remote(format!(
312                    "git {}: there is no {self} on that remote",
313                    redacted(url)
314                )))
315            })
316    }
317}
318
319impl std::fmt::Display for Reference {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            Self::Branch(name) => write!(f, "branch {name}"),
323            Self::Tag(name) => write!(f, "tag {name}"),
324            Self::Commit(sha) => write!(f, "commit {sha}"),
325        }
326    }
327}
328
329/// What a source reads: one file, several named ones, or a directory.
330///
331/// [`Builder::path`] takes one, and a bare `&str` or `String` is
332/// [`Keys::one`] — so the single-file spelling every caller already wrote keeps
333/// working unchanged.
334///
335/// # Why this store can do it and the key-value ones cannot
336///
337/// Every store in this family folds several keys into one document, and every
338/// other one has to say out loud that the set is **not** read at one instant:
339/// S3 issues one `GetObject` per key, Vault one read per path, Consul one
340/// request per key. A deployment writing two of them is a document that never
341/// existed.
342///
343/// A git fetch resolves **one commit**, and a commit has **one tree**. Every
344/// path below is read out of that tree, so the set is atomic with no
345/// transaction, no listing race and no second round trip — the guarantee comes
346/// from git's object model rather than from anything this crate arranges. That
347/// is also why a multi-file git source **can be watched**, which no other store
348/// here allows: see [`GitSource::watch`].
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub enum Keys {
351    /// One file, whose contents are the whole document.
352    ///
353    /// Handed to the loader byte for byte — never parsed and re-rendered, so
354    /// comments and key order survive and no format feature is needed that was
355    /// not needed before.
356    One(String),
357
358    /// Several named files, merged **in the order given — later wins**.
359    ///
360    /// The rule a list of `.file(..)` calls already teaches: the caller wrote
361    /// the list, so the list is the precedence. Tables merge deeply and arrays
362    /// are replaced whole.
363    Several(Vec<String>),
364
365    /// Every file under a directory, merged as **disjoint sections**.
366    ///
367    /// A caller naming a directory is not expressing an order — a tree lists
368    /// its entries in the order git sorted them, which is nobody's precedence
369    /// — so two files under it supplying the same path is a deployment bug,
370    /// and reported as one rather than resolved.
371    ///
372    /// A **directory**, not a string prefix, because a git tree has
373    /// directories: `Keys::prefix("services/api")` reads `services/api/db.yaml`
374    /// and does not read `services/api-old.yaml`. The walk is recursive, an
375    /// empty string is the repository root, and every file found has to parse —
376    /// so point it at a directory that holds configuration and nothing else.
377    Prefix(String),
378}
379
380impl Keys {
381    /// One file, whose contents are the whole document.
382    #[must_use]
383    pub fn one(path: impl Into<String>) -> Self {
384        Self::One(path.into())
385    }
386
387    /// Several named files, merged in the order given — later wins.
388    #[must_use]
389    pub fn several<I, S>(paths: I) -> Self
390    where
391        I: IntoIterator<Item = S>,
392        S: Into<String>,
393    {
394        Self::Several(paths.into_iter().map(Into::into).collect())
395    }
396
397    /// Every file under `directory`, merged as disjoint sections.
398    #[must_use]
399    pub fn prefix(directory: impl Into<String>) -> Self {
400        Self::Prefix(directory.into())
401    }
402
403    /// The paths as a slice, for the checks and the format inference.
404    ///
405    /// A directory has none to list — the set is not known until a commit has
406    /// been read.
407    fn named(&self) -> &[String] {
408        match self {
409            Self::One(path) => std::slice::from_ref(path),
410            Self::Several(paths) => paths,
411            Self::Prefix(_) => &[],
412        }
413    }
414
415    /// How a diagnostic names what this source reads.
416    ///
417    /// One path renders as the path itself, so every message a single-file
418    /// source has ever produced is unchanged.
419    pub(crate) fn describe(&self) -> String {
420        match self {
421            Self::One(path) => path.clone(),
422            Self::Several(paths) => format!("paths {}", paths.join(", ")),
423            Self::Prefix(directory) => format!("everything under {directory:?}"),
424        }
425    }
426
427    /// What two of this source's files supplying one path means.
428    ///
429    /// The distinction the whole feature turns on: a caller who wrote the list
430    /// wrote the precedence with it, and a caller who named a directory wrote
431    /// no order at all — so the first merges and the second refuses.
432    fn overlap(&self) -> Overlap {
433        match self {
434            Self::One(_) | Self::Several(_) => Overlap::LaterWins,
435            Self::Prefix(_) => Overlap::Refused,
436        }
437    }
438}
439
440impl From<&str> for Keys {
441    fn from(path: &str) -> Self {
442        Self::one(path)
443    }
444}
445
446impl From<String> for Keys {
447    fn from(path: String) -> Self {
448        Self::One(path)
449    }
450}
451
452impl From<&String> for Keys {
453    fn from(path: &String) -> Self {
454        Self::one(path)
455    }
456}
457
458/// A file in a git repository, as a configuration source.
459///
460/// Built with [`GitSource::builder`]. Not `Clone`: the working directory is
461/// claimed by one source, and two clones fetching into it would interleave
462/// their ref updates. Wrap it in an `Arc` if two places need one.
463pub struct GitSource {
464    url: String,
465    reference: Reference,
466    keys: Keys,
467    format: Format,
468    credential: Session,
469    tls: TlsConfig,
470    working: Working,
471    timeout: Duration,
472    max_bytes: u64,
473    /// Transfers a working directory may accumulate before it is emptied;
474    /// `0` never empties it. Only ever a directory this crate created.
475    compact_after: u32,
476    /// The commit last read, for provenance in [`RemoteSource::describe`].
477    last: Mutex<Option<gix::ObjectId>>,
478    /// Held across a fetch. `gix::Repository` is opened per fetch rather than
479    /// kept, so this is what stops two threads writing one object database —
480    /// and it is on the fetch path, never on `load()`'s.
481    fetching: Mutex<()>,
482}
483
484impl GitSource {
485    /// A source reading from the repository at `url`.
486    ///
487    /// `url` is anything git understands: `https://…`, `ssh://…`,
488    /// `git@host:org/repo.git`, or a local path.
489    pub fn builder(url: impl Into<String>) -> Builder {
490        Builder {
491            url: url.into(),
492            reference: Reference::Branch(DEFAULT_BRANCH.to_owned()),
493            path: None,
494            format: None,
495            credential: Credential::anonymous(),
496            tls: TlsConfig::new(),
497            cache_dir: None,
498            timeout: DEFAULT_TIMEOUT,
499            max_bytes: DEFAULT_MAX_BYTES,
500            compact_after: working::AFTER,
501        }
502    }
503
504    /// Calls `on_change` when the ref moves, checking every `interval`.
505    ///
506    /// Polling, because git offers nothing better — and *advertisement*
507    /// polling, because transferring a commit every tick to discover it has
508    /// not changed would be a poor thing to do to a git host. Each tick is one
509    /// handshake and one ref advertisement; only a ref that moved costs a
510    /// transfer.
511    ///
512    /// The current value is **not** delivered at startup, for the same reason
513    /// a file watcher does not report an edit when it starts. Fetch first if
514    /// the starting value matters, which it usually does:
515    ///
516    /// ```no_run
517    /// # use dynamic_config::{RemoteSource, RemoteWatch};
518    /// # use dynamic_config_git::GitSource;
519    /// # use std::time::Duration;
520    /// # struct Sink;
521    /// # impl Sink {
522    /// #     fn apply(&self, _: dynamic_config::Fetched) -> Result<(), dynamic_config::Error> { Ok(()) }
523    /// # }
524    /// # fn example(source: GitSource, watching: dynamic_config::Watching) -> Result<(), dynamic_config::Error> {
525    /// # let sink = Sink;
526    /// sink.apply(source.fetch()?)?;
527    /// source.watch(&watching, Duration::from_secs(60), move |document| sink.apply(document))
528    /// # }
529    /// ```
530    ///
531    /// A host that is away, a ref that has been deleted, a document that does
532    /// not parse — none of those end the watch. It waits out the interval and
533    /// tries again. `stop` is noticed within a quarter second regardless of how
534    /// long `interval` is.
535    ///
536    /// # A source reading several files can be watched
537    ///
538    /// It is the only one in this family that can. Every other store refuses a
539    /// watch on a set, and the reason is written down: waking on a change to
540    /// one key and then re-reading key by key collects the new value of that
541    /// key and whatever the others happen to be halfway through a deployment —
542    /// a document that never existed at any instant, installed and then served
543    /// until the next change.
544    ///
545    /// Neither half of that applies here. What moves is a **ref**, and what a
546    /// ref names is a **commit** — so the watch does not wake on one file, it
547    /// wakes on the repository, and the re-read that follows takes every file
548    /// out of that one commit's tree. A deployment that writes four files in one
549    /// commit is delivered as one document; a deployment that writes them in
550    /// four commits is delivered as up to four documents, each of which is a
551    /// state the repository really was in. There is no interleaving to be had.
552    ///
553    /// The cost is the other direction, and it is the same cost a single-file
554    /// watch has always had: a commit that touches nothing this source reads
555    /// still moves the ref, so `on_change` is called with a document identical
556    /// to the last one. A spurious delivery, never a torn one — `dynamic-config`
557    /// diffs it and reports no changes.
558    ///
559    /// # Errors
560    ///
561    /// If the host refuses a credential that cannot be replaced — a token
562    /// handed in as a constant is the same token next tick, so retrying it
563    /// forever would be a hot loop against a host that may well start locking
564    /// the account. A credential that came from a closure is refreshed and
565    /// retried instead, and only ends the watch if the fresh one is refused
566    /// too. Or if `on_change` returns an error, which ends the watch — so a
567    /// caller that wants to survive a bad document should log it and return
568    /// `Ok`.
569    pub fn watch<F>(
570        &self,
571        watching: &Watching,
572        interval: Duration,
573        mut on_change: F,
574    ) -> Result<(), Error>
575    where
576        F: FnMut(Fetched) -> Result<(), Error>,
577    {
578        let mut seen: Option<gix::ObjectId> = None;
579
580        while watching.keep_going() {
581            match self.attempt(false) {
582                // The first tick records where the ref is without firing: the
583                // commit it names is the one the caller already has.
584                Ok(current) if seen.is_none() => seen = Some(current.commit),
585
586                Ok(current) if seen != Some(current.commit) => {
587                    // Read through `attempt` again rather than reusing the
588                    // advertisement: the commit is taken from the read itself,
589                    // so a push landing between the check and the read is
590                    // delivered once rather than now and again next tick.
591                    if let Ok((document, commit)) = self.read() {
592                        seen = Some(commit);
593
594                        guarded(&mut on_change, document, &self.describe())?;
595                    }
596                }
597
598                // A credential nothing can replace: the next tick would present
599                // the identical string and be refused identically.
600                Err(error)
601                    if error.kind() == dynamic_config::ErrorKind::Auth
602                        && !self.credential.is_replaceable() =>
603                {
604                    return Err(error)
605                }
606
607                // Unchanged, or a failure the next tick may not have.
608                _ => {}
609            }
610
611            watching.sleep_for(interval);
612        }
613
614        Ok(())
615    }
616
617    /// The document, and the commit it was read at.
618    ///
619    /// The fold from several files into one document happens here rather than
620    /// in [`fetch`](mod@fetch), because it is the same fold every store crate
621    /// in this family performs and it lives in `dynamic-config-store-core`.
622    fn read(&self) -> Result<(Fetched, gix::ObjectId), Error> {
623        let found = self.attempt(true)?;
624
625        // Before `describe()`, so a diagnostic from the merge names the commit
626        // the documents actually came from rather than the ref they were asked
627        // for.
628        *self.last() = Some(found.commit);
629
630        let document = documents::merged(
631            &found.documents,
632            self.format,
633            self.keys.overlap(),
634            &self.describe(),
635        )?;
636
637        Ok((document, found.commit))
638    }
639
640    /// One fetch, with one retry if the credential turned out to be dead.
641    fn attempt(&self, want_document: bool) -> Result<Found, Error> {
642        match self.once(want_document) {
643            Err(Failure::Refused(_)) if self.credential.is_replaceable() => {
644                // The proactive refresh should have caught an expiring token,
645                // but clocks skew and an installation can be revoked. One
646                // fresh credential and one retry — not a loop: if the new one
647                // is refused too, the grant is wrong and retrying would turn a
648                // clear failure into a hang.
649                self.credential.invalidate();
650
651                self.once(want_document).map_err(Failure::into_error)
652            }
653            outcome => outcome.map_err(Failure::into_error),
654        }
655    }
656
657    fn once(&self, want_document: bool) -> Result<Found, Failure> {
658        // `Other`, not `Refused`: a closure that could not produce a
659        // credential has nothing to be replaced by, and the retry `Refused`
660        // triggers would call the same closure again in the same breath. The
661        // error keeps whatever kind the closure gave it.
662        let auth = self.credential.current().map_err(Failure::Other)?;
663
664        let _fetching = self
665            .fetching
666            .lock()
667            .unwrap_or_else(std::sync::PoisonError::into_inner);
668
669        let directory = self.working.path().map_err(Failure::Other)?;
670
671        // Before opening, not after: `compact` empties the directory, and
672        // what it held is rebuilt by the `init_bare` inside `open`. A shallow
673        // fetch of a moving branch adds a pack every time the branch moves and
674        // removes nothing, so without this a long-lived watcher grows without
675        // bound. It only ever touches a directory this crate created — see
676        // `working`'s module documentation for the rule.
677        working::compact(directory, self.compact_after).map_err(Failure::Other)?;
678
679        let repository = fetch::open(directory, auth.ssh_command())?;
680
681        let commit = fetch::fetch(
682            &repository,
683            &fetch::Plan {
684                url: &self.url,
685                reference: &self.reference,
686                auth: &auth,
687                tls: &self.tls,
688                timeout: self.timeout,
689                described: &self.describe(),
690            },
691            want_document,
692        )?;
693
694        let documents = if want_document {
695            fetch::read_documents(&repository, commit, &self.keys, self.max_bytes, &self.url)?
696        } else {
697            // The watch's idle check only needs to know whether the ref moved.
698            Vec::new()
699        };
700
701        Ok(Found { commit, documents })
702    }
703
704    fn last(&self) -> std::sync::MutexGuard<'_, Option<gix::ObjectId>> {
705        self.last
706            .lock()
707            .unwrap_or_else(std::sync::PoisonError::into_inner)
708    }
709}
710
711/// What one attempt produced: the commit, and every document read out of it.
712struct Found {
713    commit: gix::ObjectId,
714    /// `(path, contents)` in merge order. Empty when only the commit was
715    /// wanted.
716    documents: Vec<(String, String)>,
717}
718
719impl RemoteSource for GitSource {
720    fn fetch(&self) -> Result<Fetched, Error> {
721        self.read().map(|(document, _commit)| document)
722    }
723
724    fn describe(&self) -> String {
725        // The commit too, once one has been read: "which commit is this
726        // program actually serving" is the first question of every
727        // configuration-in-git incident, and a branch name does not answer it.
728        match *self.last() {
729            Some(commit) => format!(
730                "git {}@{}:{}",
731                redacted(&self.url),
732                commit.to_hex_with_len(12),
733                self.keys.describe()
734            ),
735            None => format!(
736                "git {} {}:{}",
737                redacted(&self.url),
738                self.reference,
739                self.keys.describe()
740            ),
741        }
742    }
743
744    /// Conditional: a ref advertisement says where the branch points
745    /// without fetching the objects behind it.
746    fn watch_capability(&self) -> WatchCapability {
747        WatchCapability::Conditional
748    }
749
750    fn watch(
751        &self,
752        watching: &Watching,
753        interval: Duration,
754        on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
755    ) -> Result<(), Error> {
756        GitSource::watch(self, watching, interval, on_change)
757    }
758}
759
760// Hand-written, never derived: a derive would print every field, and the URL
761// is a field that routinely carries a token. `{:?}` reaching a log is an
762// ordinary accident — a `dbg!`, a `tracing::debug!(?source)` — and an accident
763// must not disclose a secret. The other store crates follow the same rule.
764impl std::fmt::Debug for GitSource {
765    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
766        f.debug_struct("GitSource")
767            .field("url", &redacted(&self.url))
768            .field("reference", &self.reference)
769            .field("keys", &self.keys)
770            .field("format", &self.format)
771            .field("credential", &self.credential)
772            .field("tls", &self.tls)
773            .field("working", &self.working)
774            .field("timeout", &self.timeout)
775            .finish_non_exhaustive()
776    }
777}
778
779/// Collects what a [`GitSource`] needs, and refuses what it cannot use.
780///
781/// Everything that can be wrong about a source — a path that escapes the
782/// repository, a format nothing can infer, a commit id that is not one, a
783/// working directory another source already holds — is decided here, at
784/// [`build`](Self::build), rather than at the first fetch. A configuration
785/// mistake should fail where it was made.
786#[must_use]
787pub struct Builder {
788    url: String,
789    reference: Reference,
790    path: Option<Keys>,
791    format: Option<Format>,
792    credential: Credential,
793    tls: TlsConfig,
794    cache_dir: Option<PathBuf>,
795    timeout: Duration,
796    max_bytes: u64,
797    compact_after: u32,
798}
799
800// Hand-written for the same reason [`GitSource`]'s is, and it is not
801// redundant with it: a builder holds the URL from the moment it is created
802// until `build` consumes it, so a `dbg!` or a `tracing::debug!(?builder)`
803// during construction — the place a configuration is being got right, which
804// is where people print things — would disclose exactly the credential the
805// source is careful never to print.
806impl std::fmt::Debug for Builder {
807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808        f.debug_struct("Builder")
809            .field("url", &redacted(&self.url))
810            .field("reference", &self.reference)
811            .field("path", &self.path)
812            .field("format", &self.format)
813            .field("credential", &self.credential)
814            .field("tls", &self.tls)
815            .field("cache_dir", &self.cache_dir)
816            .field("timeout", &self.timeout)
817            .field("max_bytes", &self.max_bytes)
818            .field("compact_after", &self.compact_after)
819            .finish()
820    }
821}
822
823impl Builder {
824    /// Reads from a branch, by short name. `main` unless this is called.
825    pub fn branch(mut self, name: impl Into<String>) -> Self {
826        self.reference = Reference::Branch(name.into());
827        self
828    }
829
830    /// Reads from a tag, by short name.
831    pub fn tag(mut self, name: impl Into<String>) -> Self {
832        self.reference = Reference::Tag(name.into());
833        self
834    }
835
836    /// Reads from one commit, by full hexadecimal object id.
837    ///
838    /// Reproducible, and static: nothing will ever move, so a watch on a
839    /// pinned commit will never fire.
840    pub fn commit(mut self, sha: impl Into<String>) -> Self {
841        self.reference = Reference::Commit(sha.into());
842        self
843    }
844
845    /// Reads from a [`Reference`] built elsewhere.
846    pub fn reference(mut self, reference: Reference) -> Self {
847        self.reference = reference;
848        self
849    }
850
851    /// What to read: a file, or a [`Keys`] for several of them.
852    ///
853    /// A path is `/`-separated and relative to the repository root. Required.
854    ///
855    /// ```no_run
856    /// # use dynamic_config_git::{GitSource, Keys};
857    /// # fn example() -> Result<(), dynamic_config::Error> {
858    /// // One file — what a bare string has always meant.
859    /// GitSource::builder("https://github.com/acme/config.git")
860    ///     .path("services/api/config.yaml")
861    ///     .build()?;
862    ///
863    /// // Several, merged in this order: `local` wins where they overlap.
864    /// GitSource::builder("https://github.com/acme/config.git")
865    ///     .path(Keys::several([
866    ///         "services/api/base.yaml",
867    ///         "services/api/local.yaml",
868    ///     ]))
869    ///     .build()?;
870    ///
871    /// // A directory of disjoint sections, where an overlap is a mistake.
872    /// GitSource::builder("https://github.com/acme/config.git")
873    ///     .path(Keys::prefix("services/api"))
874    ///     .format(dynamic_config::Format::Yaml)
875    ///     .build()?;
876    /// # Ok(())
877    /// # }
878    /// ```
879    pub fn path(mut self, path: impl Into<Keys>) -> Self {
880        self.path = Some(path.into());
881        self
882    }
883
884    /// The format to parse the files as.
885    ///
886    /// Inferred from the extension when this is not called, so `config.yaml`
887    /// needs nothing. Call it for a file whose name does not say — `.config`,
888    /// or no extension at all — for a [`Keys::Several`] whose members name two
889    /// different formats, and always for [`Keys::Prefix`], because a directory
890    /// has no extension to read.
891    ///
892    /// One source reads one format. A caller who wants a JSON file and a TOML
893    /// file has two sources, which already works.
894    pub fn format(mut self, format: Format) -> Self {
895        self.format = Some(format);
896        self
897    }
898
899    /// How to authenticate. Anonymous — a public repository — by default.
900    ///
901    /// See [`Credential`]; the short version is that anything that expires
902    /// should come from [`Credential::expiring`] rather than be pasted in as a
903    /// string.
904    pub fn credential(mut self, credential: Credential) -> Self {
905        self.credential = credential;
906        self
907    }
908
909    /// How to trust an `https://` host this machine does not already trust,
910    /// and how to prove who is asking.
911    ///
912    /// For an enterprise GitLab behind a private certificate authority, and for
913    /// a host that wants a client certificate before it will say hello. The
914    /// platform's own trust store still applies, so one source configuration
915    /// reaches both a private host and github.com.
916    ///
917    /// ```no_run
918    /// # use dynamic_config_git::{GitSource, TlsConfig};
919    /// # fn example() -> Result<(), dynamic_config::Error> {
920    /// GitSource::builder("https://gitlab.internal/acme/config.git")
921    ///     .path("services/api/config.yaml")
922    ///     .tls(
923    ///         TlsConfig::new()
924    ///             .with_ca_certificate_file("/etc/ssl/certs/acme-root.pem")
925    ///             .with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
926    ///     )
927    ///     .build()?;
928    /// # Ok(())
929    /// # }
930    /// ```
931    ///
932    /// **This is the `https://` knob and only that one.** An `ssh://` remote
933    /// authenticates its host through `known_hosts` and its client through a
934    /// key, which is [`Credential::ssh_agent`], [`Credential::ssh_key`] or
935    /// [`Credential::ssh_command`]; asking for both is refused at
936    /// [`build`](Self::build) rather than half-applied.
937    ///
938    /// There is no way to turn verification off, and [`tls`](mod@tls) argues
939    /// why at length — the short version being that a fetch presents its
940    /// credential before it has received anything, so an unverified connection
941    /// is one that hands a token to whoever is on the path.
942    ///
943    /// Configuring this replaces `gix`'s HTTP transport with one this crate
944    /// builds, because `gix`'s ignores every TLS option it is given; see
945    /// [`tls`](mod@tls) for what was measured. Leaving it alone changes
946    /// nothing.
947    pub fn tls(mut self, tls: TlsConfig) -> Self {
948        self.tls = tls;
949        self
950    }
951
952    /// Keeps the object database here, instead of in a temporary directory.
953    ///
954    /// It survives restarts, so a restarted process transfers almost nothing.
955    /// In exchange the caller owns its size; see [`working`](mod@working).
956    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
957        self.cache_dir = Some(path.into());
958        self
959    }
960
961    /// How long one fetch may take before it is given up on. Thirty seconds by
962    /// default.
963    ///
964    /// The deadline for **one fetch attempt**, excluding retries the underlying
965    /// client performs — the same sentence every store in this family answers
966    /// to. What it reaches depends on which transport the source uses, and this
967    /// crate owes the reader the table rather than the sentence:
968    ///
969    /// | Phase | With [`tls`](Self::tls) — this crate's transport | Without — `gix`'s own |
970    /// |---|---|---|
971    /// | connecting | this number | twenty seconds, `gix`'s, not configurable |
972    /// | the handshake and the ref advertisement | this number, per read | **unbounded** |
973    /// | negotiation and the pack | this number, per read | this number |
974    ///
975    /// `gix` takes an interrupt flag and checks it between packets, which is a
976    /// real deadline for the part that transfers data and no deadline at all
977    /// for a host that accepts the connection and then sends nothing — there
978    /// are no packets for the check to be between. Its `reqwest` transport
979    /// reads none of the timeouts its own options type carries, so that column
980    /// is not this crate's to fix from outside; [`tls`](mod@tls) records what
981    /// was measured and what closing it would have cost everybody else.
982    pub fn with_timeout(mut self, timeout: Duration) -> Self {
983        self.timeout = timeout;
984        self
985    }
986
987    /// The largest **single file** this source will read, in bytes. A megabyte
988    /// by default.
989    ///
990    /// Checked against the object header before any of the file is loaded, so
991    /// a repository offering a two-gigabyte blob costs an error rather than the
992    /// memory. Per file rather than per document: a [`Keys::Prefix`] read is
993    /// bounded by this and by the five-hundred-and-twelve-file budget together.
994    pub fn max_bytes(mut self, bytes: u64) -> Self {
995        self.max_bytes = bytes;
996        self
997    }
998
999    /// How many transfers a working directory may accumulate before it is
1000    /// emptied and refilled by the next fetch. Thirty-two by default; `0`
1001    /// turns it off.
1002    ///
1003    /// A shallow fetch of a moving branch adds a pack every time the branch
1004    /// moves and removes nothing, so a watcher left running grows without
1005    /// bound. Compaction is how that is answered, and it is a *visible*
1006    /// trigger on purpose: a store that deletes things should be a store the
1007    /// caller can see deleting them, and can stop.
1008    ///
1009    /// Only ever a directory this crate created. A [`cache_dir`](Self::cache_dir)
1010    /// pointing at a repository that already existed is never touched,
1011    /// whatever this is set to — see [`working`](mod@working) for the rule.
1012    ///
1013    /// Turn it off for a deployment that would rather run `git gc
1014    /// --prune=now` on its own cadence.
1015    pub fn compact_after(mut self, transfers: u32) -> Self {
1016        self.compact_after = transfers;
1017        self
1018    }
1019
1020    /// Builds the source.
1021    ///
1022    /// # Errors
1023    ///
1024    /// If no path was given, or a [`Keys::Several`] with nothing in it; if any
1025    /// path is not a path inside the repository — absolute, or with a `.` or
1026    /// `..` component, or with an empty one; if the format was neither given
1027    /// nor inferable, or if two paths name two different formats; if a commit
1028    /// was named that is not a hexadecimal object id; or if the named working
1029    /// directory already belongs to another source in this program.
1030    pub fn build(self) -> Result<GitSource, Error> {
1031        let keys = self
1032            .path
1033            .ok_or_else(|| Error::remote("git: no path; call `path` with the file to read"))?;
1034
1035        check_keys(&keys)?;
1036        check_reference(&self.reference)?;
1037        tls::check_scheme(&self.url, &self.tls)?;
1038
1039        let format = match self.format {
1040            Some(format) => format,
1041            None => infer_format(&keys)?,
1042        };
1043
1044        let working = match self.cache_dir {
1045            Some(directory) => Working::Named(working::Claimed::new(directory)?),
1046            None => Working::Temporary(working::Temporary::new()?),
1047        };
1048
1049        Ok(GitSource {
1050            url: self.url,
1051            reference: self.reference,
1052            keys,
1053            format,
1054            credential: Session::new(self.credential),
1055            tls: self.tls,
1056            working,
1057            timeout: self.timeout,
1058            max_bytes: self.max_bytes,
1059            compact_after: self.compact_after,
1060            last: Mutex::new(None),
1061            fetching: Mutex::new(()),
1062        })
1063    }
1064}
1065
1066/// Refuses anything in `keys` that is not a place inside the repository.
1067///
1068/// # Errors
1069///
1070/// If a named list is empty, or any path fails [`check_path`].
1071fn check_keys(keys: &Keys) -> Result<(), Error> {
1072    if let Keys::Several(paths) = keys {
1073        if paths.is_empty() {
1074            return Err(Error::remote(
1075                "git: `Keys::several` with no paths in it; name at least one \
1076                 file, or use `Keys::prefix` for a directory",
1077            ));
1078        }
1079    }
1080
1081    if let Keys::Prefix(directory) = keys {
1082        // The repository root, which is what an empty directory name means,
1083        // needs no checking and has no components to check.
1084        let root = directory.trim_end_matches('/');
1085
1086        return if root.is_empty() {
1087            Ok(())
1088        } else {
1089            check_path(root)
1090        };
1091    }
1092
1093    keys.named().iter().try_for_each(|path| check_path(path))
1094}
1095
1096/// The format the paths agree on, or an error naming the call that settles it.
1097///
1098/// [`documents::agreed_format`] is the family's rule and the family's wording:
1099/// two paths naming two formats is a mistake worth catching by name, because
1100/// parsing `server.toml` as JSON produces a syntax error about a file that has
1101/// no syntax error in it.
1102fn infer_format(keys: &Keys) -> Result<Format, Error> {
1103    match documents::agreed_format(keys.named()) {
1104        Err(complaint) => Err(Error::remote(format!("git: {complaint}"))),
1105        Ok(Some(format)) => Ok(format),
1106        Ok(None) => Err(Error::remote(format!(
1107            "git: cannot tell what format {} is; call `format`",
1108            keys.describe()
1109        ))),
1110    }
1111}
1112
1113/// Refuses a path that is not a path inside the repository.
1114///
1115/// Nothing is ever written to the filesystem from the tree, so none of these
1116/// could escape a directory even if they were accepted — but a `..` in a
1117/// configured path means the caller believes something this crate does not do,
1118/// and a belief like that is worth failing on rather than silently reading
1119/// nothing.
1120///
1121/// It is also run over every path a [`Keys::Prefix`] walk *discovers*, where
1122/// the belief in question is the remote repository's: a tree entry's name is
1123/// bytes the host chose, and `git mktree` will write one called `..` without
1124/// complaint.
1125pub(crate) fn check_path(path: &str) -> Result<(), Error> {
1126    let refuse = |why: &str| {
1127        Err(Error::remote(format!(
1128            "git: {path:?} is not a file inside the repository: {why}"
1129        )))
1130    };
1131
1132    if path.is_empty() {
1133        return refuse("it is empty");
1134    }
1135
1136    if path.starts_with('/') {
1137        return refuse("it is absolute; paths are relative to the repository root");
1138    }
1139
1140    for component in path.split('/') {
1141        match component {
1142            "" => return refuse("it has an empty component"),
1143            "." | ".." => {
1144                return refuse(
1145                    "it has a `.` or `..` component; this source reads one blob out of \
1146                     one tree and cannot leave the repository",
1147                )
1148            }
1149            _ => {}
1150        }
1151    }
1152
1153    Ok(())
1154}
1155
1156/// Refuses a commit id that is not one.
1157fn check_reference(reference: &Reference) -> Result<(), Error> {
1158    let Reference::Commit(sha) = reference else {
1159        return Ok(());
1160    };
1161
1162    // 40 for SHA-1, 64 for SHA-256. An abbreviation is refused rather than
1163    // resolved: the protocol cannot ask for one, so accepting it here would
1164    // only move the failure to the first fetch.
1165    let usable = matches!(sha.len(), 40 | 64) && sha.bytes().all(|byte| byte.is_ascii_hexdigit());
1166
1167    if usable {
1168        Ok(())
1169    } else {
1170        Err(Error::remote(format!(
1171            "git: {sha:?} is not a full commit id; give all {} characters, or \
1172             name a branch or a tag",
1173            if sha.len() > 40 { 64 } else { 40 }
1174        )))
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    #[test]
1183    fn a_path_that_tries_to_leave_the_repository_is_refused() {
1184        for path in [
1185            "../../etc/shadow",
1186            "services/../../../etc/shadow",
1187            "/etc/shadow",
1188            "services//config.yaml",
1189            "./config.yaml",
1190            "",
1191        ] {
1192            let error = GitSource::builder("https://github.com/acme/config.git")
1193                .path(path)
1194                .format(Format::Yaml)
1195                .build()
1196                .expect_err("{path} must not be accepted");
1197
1198            assert!(
1199                error
1200                    .to_string()
1201                    .contains("not a file inside the repository")
1202                    || error.to_string().contains("no path"),
1203                "{path}: {error}"
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn an_ordinary_path_is_accepted_and_its_format_inferred() {
1210        let source = GitSource::builder("https://github.com/acme/config.git")
1211            .path("services/api/config.yaml")
1212            .build()
1213            .expect("a yaml file needs no format");
1214
1215        assert_eq!(source.format, Format::Yaml);
1216        assert_eq!(source.reference, Reference::Branch("main".to_owned()));
1217    }
1218
1219    #[test]
1220    fn a_file_whose_name_says_nothing_needs_a_format() {
1221        let error = GitSource::builder("https://github.com/acme/config.git")
1222            .path("services/api/settings")
1223            .build()
1224            .expect_err("nothing can infer a format from that");
1225
1226        assert!(
1227            error.to_string().contains("cannot tell what format"),
1228            "{error}"
1229        );
1230
1231        GitSource::builder("https://github.com/acme/config.git")
1232            .path("services/api/settings")
1233            .format(Format::Toml)
1234            .build()
1235            .expect("saying so is all it takes");
1236    }
1237
1238    #[test]
1239    fn an_abbreviated_commit_is_refused_where_it_was_written() {
1240        let error = GitSource::builder("https://github.com/acme/config.git")
1241            .path("config.yaml")
1242            .commit("deadbee")
1243            .build()
1244            .expect_err("the protocol cannot ask for an abbreviation");
1245
1246        assert!(
1247            error.to_string().contains("not a full commit id"),
1248            "{error}"
1249        );
1250
1251        GitSource::builder("https://github.com/acme/config.git")
1252            .path("config.yaml")
1253            .commit("da39a3ee5e6b4b0d3255bfef95601890afd80709")
1254            .build()
1255            .expect("a full sha is fine");
1256    }
1257
1258    #[test]
1259    fn each_reference_asks_for_the_ref_it_names() {
1260        assert_eq!(
1261            Reference::Branch("main".to_owned()).refspec(),
1262            "+refs/heads/main:refs/dynamic-config/head"
1263        );
1264        assert_eq!(
1265            Reference::Tag("v1.2.0".to_owned()).refspec(),
1266            "+refs/tags/v1.2.0:refs/dynamic-config/head"
1267        );
1268        assert_eq!(
1269            Reference::Commit("da39a3ee5e6b4b0d3255bfef95601890afd80709".to_owned()).refspec(),
1270            "+da39a3ee5e6b4b0d3255bfef95601890afd80709:refs/dynamic-config/head"
1271        );
1272    }
1273
1274    /// The planted-credential test every store in this family carries: a token
1275    /// in the URL must not reach `Debug` or `describe()`, which are the two
1276    /// strings that end up in logs.
1277    #[test]
1278    fn a_token_in_the_url_reaches_neither_debug_nor_describe() {
1279        let source =
1280            GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
1281                .path("config.yaml")
1282                .credential(Credential::token("ghs_hunter2-as-well"))
1283                .build()
1284                .unwrap();
1285
1286        let printed = format!("{source:?} {}", source.describe());
1287
1288        assert!(!printed.contains("hunter2"), "{printed}");
1289        assert!(printed.contains("github.com/acme/config.git"), "{printed}");
1290        // The user half survives, which is what makes the redaction usable
1291        // rather than a black hole.
1292        assert!(printed.contains("x-access-token"), "{printed}");
1293    }
1294
1295    /// The same planted credential, one step earlier. A builder holds the
1296    /// URL from `builder()` to `build()`, and construction is exactly where
1297    /// somebody prints things to see what they have configured.
1298    #[test]
1299    fn a_token_in_the_url_does_not_reach_the_builders_debug_either() {
1300        let builder =
1301            GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
1302                .path("config.yaml")
1303                .credential(Credential::token("ghs_hunter2-as-well"));
1304
1305        let printed = format!("{builder:?}");
1306
1307        assert!(!printed.contains("hunter2"), "{printed}");
1308        assert!(printed.contains("github.com/acme/config.git"), "{printed}");
1309    }
1310}