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, Revision, 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        // The commit is what "which version is this" means for git, and
638        // an object id is `Opaque`: two commits do not sort, they are the
639        // same one or they are not.
640        let document = document.with_revision(Revision::Opaque(found.commit.to_string()));
641
642        Ok((document, found.commit))
643    }
644
645    /// One fetch, with one retry if the credential turned out to be dead.
646    fn attempt(&self, want_document: bool) -> Result<Found, Error> {
647        match self.once(want_document) {
648            Err(Failure::Refused(_)) if self.credential.is_replaceable() => {
649                // The proactive refresh should have caught an expiring token,
650                // but clocks skew and an installation can be revoked. One
651                // fresh credential and one retry — not a loop: if the new one
652                // is refused too, the grant is wrong and retrying would turn a
653                // clear failure into a hang.
654                self.credential.invalidate();
655
656                self.once(want_document).map_err(Failure::into_error)
657            }
658            outcome => outcome.map_err(Failure::into_error),
659        }
660    }
661
662    fn once(&self, want_document: bool) -> Result<Found, Failure> {
663        // `Other`, not `Refused`: a closure that could not produce a
664        // credential has nothing to be replaced by, and the retry `Refused`
665        // triggers would call the same closure again in the same breath. The
666        // error keeps whatever kind the closure gave it.
667        let auth = self.credential.current().map_err(Failure::Other)?;
668
669        let _fetching = self
670            .fetching
671            .lock()
672            .unwrap_or_else(std::sync::PoisonError::into_inner);
673
674        let directory = self.working.path().map_err(Failure::Other)?;
675
676        // Before opening, not after: `compact` empties the directory, and
677        // what it held is rebuilt by the `init_bare` inside `open`. A shallow
678        // fetch of a moving branch adds a pack every time the branch moves and
679        // removes nothing, so without this a long-lived watcher grows without
680        // bound. It only ever touches a directory this crate created — see
681        // `working`'s module documentation for the rule.
682        working::compact(directory, self.compact_after).map_err(Failure::Other)?;
683
684        let repository = fetch::open(directory, auth.ssh_command())?;
685
686        let commit = fetch::fetch(
687            &repository,
688            &fetch::Plan {
689                url: &self.url,
690                reference: &self.reference,
691                auth: &auth,
692                tls: &self.tls,
693                timeout: self.timeout,
694                described: &self.describe(),
695            },
696            want_document,
697        )?;
698
699        let documents = if want_document {
700            fetch::read_documents(&repository, commit, &self.keys, self.max_bytes, &self.url)?
701        } else {
702            // The watch's idle check only needs to know whether the ref moved.
703            Vec::new()
704        };
705
706        Ok(Found { commit, documents })
707    }
708
709    fn last(&self) -> std::sync::MutexGuard<'_, Option<gix::ObjectId>> {
710        self.last
711            .lock()
712            .unwrap_or_else(std::sync::PoisonError::into_inner)
713    }
714}
715
716/// What one attempt produced: the commit, and every document read out of it.
717struct Found {
718    commit: gix::ObjectId,
719    /// `(path, contents)` in merge order. Empty when only the commit was
720    /// wanted.
721    documents: Vec<(String, String)>,
722}
723
724impl RemoteSource for GitSource {
725    fn fetch(&self) -> Result<Fetched, Error> {
726        self.read().map(|(document, _commit)| document)
727    }
728
729    fn describe(&self) -> String {
730        // The commit too, once one has been read: "which commit is this
731        // program actually serving" is the first question of every
732        // configuration-in-git incident, and a branch name does not answer it.
733        match *self.last() {
734            Some(commit) => format!(
735                "git {}@{}:{}",
736                redacted(&self.url),
737                commit.to_hex_with_len(12),
738                self.keys.describe()
739            ),
740            None => format!(
741                "git {} {}:{}",
742                redacted(&self.url),
743                self.reference,
744                self.keys.describe()
745            ),
746        }
747    }
748
749    /// Conditional: a ref advertisement says where the branch points
750    /// without fetching the objects behind it.
751    fn watch_capability(&self) -> WatchCapability {
752        WatchCapability::Conditional
753    }
754
755    fn watch(
756        &self,
757        watching: &Watching,
758        interval: Duration,
759        on_change: &mut dyn FnMut(Fetched) -> Result<(), Error>,
760    ) -> Result<(), Error> {
761        GitSource::watch(self, watching, interval, on_change)
762    }
763}
764
765// Hand-written, never derived: a derive would print every field, and the URL
766// is a field that routinely carries a token. `{:?}` reaching a log is an
767// ordinary accident — a `dbg!`, a `tracing::debug!(?source)` — and an accident
768// must not disclose a secret. The other store crates follow the same rule.
769impl std::fmt::Debug for GitSource {
770    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        f.debug_struct("GitSource")
772            .field("url", &redacted(&self.url))
773            .field("reference", &self.reference)
774            .field("keys", &self.keys)
775            .field("format", &self.format)
776            .field("credential", &self.credential)
777            .field("tls", &self.tls)
778            .field("working", &self.working)
779            .field("timeout", &self.timeout)
780            .finish_non_exhaustive()
781    }
782}
783
784/// Collects what a [`GitSource`] needs, and refuses what it cannot use.
785///
786/// Everything that can be wrong about a source — a path that escapes the
787/// repository, a format nothing can infer, a commit id that is not one, a
788/// working directory another source already holds — is decided here, at
789/// [`build`](Self::build), rather than at the first fetch. A configuration
790/// mistake should fail where it was made.
791#[must_use]
792pub struct Builder {
793    url: String,
794    reference: Reference,
795    path: Option<Keys>,
796    format: Option<Format>,
797    credential: Credential,
798    tls: TlsConfig,
799    cache_dir: Option<PathBuf>,
800    timeout: Duration,
801    max_bytes: u64,
802    compact_after: u32,
803}
804
805// Hand-written for the same reason [`GitSource`]'s is, and it is not
806// redundant with it: a builder holds the URL from the moment it is created
807// until `build` consumes it, so a `dbg!` or a `tracing::debug!(?builder)`
808// during construction — the place a configuration is being got right, which
809// is where people print things — would disclose exactly the credential the
810// source is careful never to print.
811impl std::fmt::Debug for Builder {
812    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
813        f.debug_struct("Builder")
814            .field("url", &redacted(&self.url))
815            .field("reference", &self.reference)
816            .field("path", &self.path)
817            .field("format", &self.format)
818            .field("credential", &self.credential)
819            .field("tls", &self.tls)
820            .field("cache_dir", &self.cache_dir)
821            .field("timeout", &self.timeout)
822            .field("max_bytes", &self.max_bytes)
823            .field("compact_after", &self.compact_after)
824            .finish()
825    }
826}
827
828impl Builder {
829    /// Reads from a branch, by short name. `main` unless this is called.
830    pub fn branch(mut self, name: impl Into<String>) -> Self {
831        self.reference = Reference::Branch(name.into());
832        self
833    }
834
835    /// Reads from a tag, by short name.
836    pub fn tag(mut self, name: impl Into<String>) -> Self {
837        self.reference = Reference::Tag(name.into());
838        self
839    }
840
841    /// Reads from one commit, by full hexadecimal object id.
842    ///
843    /// Reproducible, and static: nothing will ever move, so a watch on a
844    /// pinned commit will never fire.
845    pub fn commit(mut self, sha: impl Into<String>) -> Self {
846        self.reference = Reference::Commit(sha.into());
847        self
848    }
849
850    /// Reads from a [`Reference`] built elsewhere.
851    pub fn reference(mut self, reference: Reference) -> Self {
852        self.reference = reference;
853        self
854    }
855
856    /// What to read: a file, or a [`Keys`] for several of them.
857    ///
858    /// A path is `/`-separated and relative to the repository root. Required.
859    ///
860    /// ```no_run
861    /// # use dynamic_config_git::{GitSource, Keys};
862    /// # fn example() -> Result<(), dynamic_config::Error> {
863    /// // One file — what a bare string has always meant.
864    /// GitSource::builder("https://github.com/acme/config.git")
865    ///     .path("services/api/config.yaml")
866    ///     .build()?;
867    ///
868    /// // Several, merged in this order: `local` wins where they overlap.
869    /// GitSource::builder("https://github.com/acme/config.git")
870    ///     .path(Keys::several([
871    ///         "services/api/base.yaml",
872    ///         "services/api/local.yaml",
873    ///     ]))
874    ///     .build()?;
875    ///
876    /// // A directory of disjoint sections, where an overlap is a mistake.
877    /// GitSource::builder("https://github.com/acme/config.git")
878    ///     .path(Keys::prefix("services/api"))
879    ///     .format(dynamic_config::Format::Yaml)
880    ///     .build()?;
881    /// # Ok(())
882    /// # }
883    /// ```
884    pub fn path(mut self, path: impl Into<Keys>) -> Self {
885        self.path = Some(path.into());
886        self
887    }
888
889    /// The format to parse the files as.
890    ///
891    /// Inferred from the extension when this is not called, so `config.yaml`
892    /// needs nothing. Call it for a file whose name does not say — `.config`,
893    /// or no extension at all — for a [`Keys::Several`] whose members name two
894    /// different formats, and always for [`Keys::Prefix`], because a directory
895    /// has no extension to read.
896    ///
897    /// One source reads one format. A caller who wants a JSON file and a TOML
898    /// file has two sources, which already works.
899    pub fn format(mut self, format: Format) -> Self {
900        self.format = Some(format);
901        self
902    }
903
904    /// How to authenticate. Anonymous — a public repository — by default.
905    ///
906    /// See [`Credential`]; the short version is that anything that expires
907    /// should come from [`Credential::expiring`] rather than be pasted in as a
908    /// string.
909    pub fn credential(mut self, credential: Credential) -> Self {
910        self.credential = credential;
911        self
912    }
913
914    /// How to trust an `https://` host this machine does not already trust,
915    /// and how to prove who is asking.
916    ///
917    /// For an enterprise GitLab behind a private certificate authority, and for
918    /// a host that wants a client certificate before it will say hello. The
919    /// platform's own trust store still applies, so one source configuration
920    /// reaches both a private host and github.com.
921    ///
922    /// ```no_run
923    /// # use dynamic_config_git::{GitSource, TlsConfig};
924    /// # fn example() -> Result<(), dynamic_config::Error> {
925    /// GitSource::builder("https://gitlab.internal/acme/config.git")
926    ///     .path("services/api/config.yaml")
927    ///     .tls(
928    ///         TlsConfig::new()
929    ///             .with_ca_certificate_file("/etc/ssl/certs/acme-root.pem")
930    ///             .with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key"),
931    ///     )
932    ///     .build()?;
933    /// # Ok(())
934    /// # }
935    /// ```
936    ///
937    /// **This is the `https://` knob and only that one.** An `ssh://` remote
938    /// authenticates its host through `known_hosts` and its client through a
939    /// key, which is [`Credential::ssh_agent`], [`Credential::ssh_key`] or
940    /// [`Credential::ssh_command`]; asking for both is refused at
941    /// [`build`](Self::build) rather than half-applied.
942    ///
943    /// There is no way to turn verification off, and [`tls`](mod@tls) argues
944    /// why at length — the short version being that a fetch presents its
945    /// credential before it has received anything, so an unverified connection
946    /// is one that hands a token to whoever is on the path.
947    ///
948    /// Configuring this replaces `gix`'s HTTP transport with one this crate
949    /// builds, because `gix`'s ignores every TLS option it is given; see
950    /// [`tls`](mod@tls) for what was measured. Leaving it alone changes
951    /// nothing.
952    pub fn tls(mut self, tls: TlsConfig) -> Self {
953        self.tls = tls;
954        self
955    }
956
957    /// Keeps the object database here, instead of in a temporary directory.
958    ///
959    /// It survives restarts, so a restarted process transfers almost nothing.
960    /// In exchange the caller owns its size; see [`working`](mod@working).
961    pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
962        self.cache_dir = Some(path.into());
963        self
964    }
965
966    /// How long one fetch may take before it is given up on. Thirty seconds by
967    /// default.
968    ///
969    /// The deadline for **one fetch attempt**, excluding retries the underlying
970    /// client performs — the same sentence every store in this family answers
971    /// to. What it reaches depends on which transport the source uses, and this
972    /// crate owes the reader the table rather than the sentence:
973    ///
974    /// | Phase | With [`tls`](Self::tls) — this crate's transport | Without — `gix`'s own |
975    /// |---|---|---|
976    /// | connecting | this number | twenty seconds, `gix`'s, not configurable |
977    /// | the handshake and the ref advertisement | this number, per read | **unbounded** |
978    /// | negotiation and the pack | this number, per read | this number |
979    ///
980    /// `gix` takes an interrupt flag and checks it between packets, which is a
981    /// real deadline for the part that transfers data and no deadline at all
982    /// for a host that accepts the connection and then sends nothing — there
983    /// are no packets for the check to be between. Its `reqwest` transport
984    /// reads none of the timeouts its own options type carries, so that column
985    /// is not this crate's to fix from outside; [`tls`](mod@tls) records what
986    /// was measured and what closing it would have cost everybody else.
987    pub fn with_timeout(mut self, timeout: Duration) -> Self {
988        self.timeout = timeout;
989        self
990    }
991
992    /// The largest **single file** this source will read, in bytes. A megabyte
993    /// by default.
994    ///
995    /// Checked against the object header before any of the file is loaded, so
996    /// a repository offering a two-gigabyte blob costs an error rather than the
997    /// memory. Per file rather than per document: a [`Keys::Prefix`] read is
998    /// bounded by this and by the five-hundred-and-twelve-file budget together.
999    pub fn max_bytes(mut self, bytes: u64) -> Self {
1000        self.max_bytes = bytes;
1001        self
1002    }
1003
1004    /// How many transfers a working directory may accumulate before it is
1005    /// emptied and refilled by the next fetch. Thirty-two by default; `0`
1006    /// turns it off.
1007    ///
1008    /// A shallow fetch of a moving branch adds a pack every time the branch
1009    /// moves and removes nothing, so a watcher left running grows without
1010    /// bound. Compaction is how that is answered, and it is a *visible*
1011    /// trigger on purpose: a store that deletes things should be a store the
1012    /// caller can see deleting them, and can stop.
1013    ///
1014    /// Only ever a directory this crate created. A [`cache_dir`](Self::cache_dir)
1015    /// pointing at a repository that already existed is never touched,
1016    /// whatever this is set to — see [`working`](mod@working) for the rule.
1017    ///
1018    /// Turn it off for a deployment that would rather run `git gc
1019    /// --prune=now` on its own cadence.
1020    pub fn compact_after(mut self, transfers: u32) -> Self {
1021        self.compact_after = transfers;
1022        self
1023    }
1024
1025    /// Builds the source.
1026    ///
1027    /// # Errors
1028    ///
1029    /// If no path was given, or a [`Keys::Several`] with nothing in it; if any
1030    /// path is not a path inside the repository — absolute, or with a `.` or
1031    /// `..` component, or with an empty one; if the format was neither given
1032    /// nor inferable, or if two paths name two different formats; if a commit
1033    /// was named that is not a hexadecimal object id; or if the named working
1034    /// directory already belongs to another source in this program.
1035    pub fn build(self) -> Result<GitSource, Error> {
1036        let keys = self
1037            .path
1038            .ok_or_else(|| Error::remote("git: no path; call `path` with the file to read"))?;
1039
1040        check_keys(&keys)?;
1041        check_reference(&self.reference)?;
1042        tls::check_scheme(&self.url, &self.tls)?;
1043
1044        let format = match self.format {
1045            Some(format) => format,
1046            None => infer_format(&keys)?,
1047        };
1048
1049        let working = match self.cache_dir {
1050            Some(directory) => Working::Named(working::Claimed::new(directory)?),
1051            None => Working::Temporary(working::Temporary::new()?),
1052        };
1053
1054        Ok(GitSource {
1055            url: self.url,
1056            reference: self.reference,
1057            keys,
1058            format,
1059            credential: Session::new(self.credential),
1060            tls: self.tls,
1061            working,
1062            timeout: self.timeout,
1063            max_bytes: self.max_bytes,
1064            compact_after: self.compact_after,
1065            last: Mutex::new(None),
1066            fetching: Mutex::new(()),
1067        })
1068    }
1069}
1070
1071/// Refuses anything in `keys` that is not a place inside the repository.
1072///
1073/// # Errors
1074///
1075/// If a named list is empty, or any path fails [`check_path`].
1076fn check_keys(keys: &Keys) -> Result<(), Error> {
1077    if let Keys::Several(paths) = keys {
1078        if paths.is_empty() {
1079            return Err(Error::remote(
1080                "git: `Keys::several` with no paths in it; name at least one \
1081                 file, or use `Keys::prefix` for a directory",
1082            ));
1083        }
1084    }
1085
1086    if let Keys::Prefix(directory) = keys {
1087        // The repository root, which is what an empty directory name means,
1088        // needs no checking and has no components to check.
1089        let root = directory.trim_end_matches('/');
1090
1091        return if root.is_empty() {
1092            Ok(())
1093        } else {
1094            check_path(root)
1095        };
1096    }
1097
1098    keys.named().iter().try_for_each(|path| check_path(path))
1099}
1100
1101/// The format the paths agree on, or an error naming the call that settles it.
1102///
1103/// [`documents::agreed_format`] is the family's rule and the family's wording:
1104/// two paths naming two formats is a mistake worth catching by name, because
1105/// parsing `server.toml` as JSON produces a syntax error about a file that has
1106/// no syntax error in it.
1107fn infer_format(keys: &Keys) -> Result<Format, Error> {
1108    match documents::agreed_format(keys.named()) {
1109        Err(complaint) => Err(Error::remote(format!("git: {complaint}"))),
1110        Ok(Some(format)) => Ok(format),
1111        Ok(None) => Err(Error::remote(format!(
1112            "git: cannot tell what format {} is; call `format`",
1113            keys.describe()
1114        ))),
1115    }
1116}
1117
1118/// Refuses a path that is not a path inside the repository.
1119///
1120/// Nothing is ever written to the filesystem from the tree, so none of these
1121/// could escape a directory even if they were accepted — but a `..` in a
1122/// configured path means the caller believes something this crate does not do,
1123/// and a belief like that is worth failing on rather than silently reading
1124/// nothing.
1125///
1126/// It is also run over every path a [`Keys::Prefix`] walk *discovers*, where
1127/// the belief in question is the remote repository's: a tree entry's name is
1128/// bytes the host chose, and `git mktree` will write one called `..` without
1129/// complaint.
1130pub(crate) fn check_path(path: &str) -> Result<(), Error> {
1131    let refuse = |why: &str| {
1132        Err(Error::remote(format!(
1133            "git: {path:?} is not a file inside the repository: {why}"
1134        )))
1135    };
1136
1137    if path.is_empty() {
1138        return refuse("it is empty");
1139    }
1140
1141    if path.starts_with('/') {
1142        return refuse("it is absolute; paths are relative to the repository root");
1143    }
1144
1145    for component in path.split('/') {
1146        match component {
1147            "" => return refuse("it has an empty component"),
1148            "." | ".." => {
1149                return refuse(
1150                    "it has a `.` or `..` component; this source reads one blob out of \
1151                     one tree and cannot leave the repository",
1152                )
1153            }
1154            _ => {}
1155        }
1156    }
1157
1158    Ok(())
1159}
1160
1161/// Refuses a commit id that is not one.
1162fn check_reference(reference: &Reference) -> Result<(), Error> {
1163    let Reference::Commit(sha) = reference else {
1164        return Ok(());
1165    };
1166
1167    // 40 for SHA-1, 64 for SHA-256. An abbreviation is refused rather than
1168    // resolved: the protocol cannot ask for one, so accepting it here would
1169    // only move the failure to the first fetch.
1170    let usable = matches!(sha.len(), 40 | 64) && sha.bytes().all(|byte| byte.is_ascii_hexdigit());
1171
1172    if usable {
1173        Ok(())
1174    } else {
1175        Err(Error::remote(format!(
1176            "git: {sha:?} is not a full commit id; give all {} characters, or \
1177             name a branch or a tag",
1178            if sha.len() > 40 { 64 } else { 40 }
1179        )))
1180    }
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185    use super::*;
1186
1187    #[test]
1188    fn a_path_that_tries_to_leave_the_repository_is_refused() {
1189        for path in [
1190            "../../etc/shadow",
1191            "services/../../../etc/shadow",
1192            "/etc/shadow",
1193            "services//config.yaml",
1194            "./config.yaml",
1195            "",
1196        ] {
1197            let error = GitSource::builder("https://github.com/acme/config.git")
1198                .path(path)
1199                .format(Format::Yaml)
1200                .build()
1201                .expect_err("{path} must not be accepted");
1202
1203            assert!(
1204                error
1205                    .to_string()
1206                    .contains("not a file inside the repository")
1207                    || error.to_string().contains("no path"),
1208                "{path}: {error}"
1209            );
1210        }
1211    }
1212
1213    #[test]
1214    fn an_ordinary_path_is_accepted_and_its_format_inferred() {
1215        let source = GitSource::builder("https://github.com/acme/config.git")
1216            .path("services/api/config.yaml")
1217            .build()
1218            .expect("a yaml file needs no format");
1219
1220        assert_eq!(source.format, Format::Yaml);
1221        assert_eq!(source.reference, Reference::Branch("main".to_owned()));
1222    }
1223
1224    #[test]
1225    fn a_file_whose_name_says_nothing_needs_a_format() {
1226        let error = GitSource::builder("https://github.com/acme/config.git")
1227            .path("services/api/settings")
1228            .build()
1229            .expect_err("nothing can infer a format from that");
1230
1231        assert!(
1232            error.to_string().contains("cannot tell what format"),
1233            "{error}"
1234        );
1235
1236        GitSource::builder("https://github.com/acme/config.git")
1237            .path("services/api/settings")
1238            .format(Format::Toml)
1239            .build()
1240            .expect("saying so is all it takes");
1241    }
1242
1243    #[test]
1244    fn an_abbreviated_commit_is_refused_where_it_was_written() {
1245        let error = GitSource::builder("https://github.com/acme/config.git")
1246            .path("config.yaml")
1247            .commit("deadbee")
1248            .build()
1249            .expect_err("the protocol cannot ask for an abbreviation");
1250
1251        assert!(
1252            error.to_string().contains("not a full commit id"),
1253            "{error}"
1254        );
1255
1256        GitSource::builder("https://github.com/acme/config.git")
1257            .path("config.yaml")
1258            .commit("da39a3ee5e6b4b0d3255bfef95601890afd80709")
1259            .build()
1260            .expect("a full sha is fine");
1261    }
1262
1263    #[test]
1264    fn each_reference_asks_for_the_ref_it_names() {
1265        assert_eq!(
1266            Reference::Branch("main".to_owned()).refspec(),
1267            "+refs/heads/main:refs/dynamic-config/head"
1268        );
1269        assert_eq!(
1270            Reference::Tag("v1.2.0".to_owned()).refspec(),
1271            "+refs/tags/v1.2.0:refs/dynamic-config/head"
1272        );
1273        assert_eq!(
1274            Reference::Commit("da39a3ee5e6b4b0d3255bfef95601890afd80709".to_owned()).refspec(),
1275            "+da39a3ee5e6b4b0d3255bfef95601890afd80709:refs/dynamic-config/head"
1276        );
1277    }
1278
1279    /// The planted-credential test every store in this family carries: a token
1280    /// in the URL must not reach `Debug` or `describe()`, which are the two
1281    /// strings that end up in logs.
1282    #[test]
1283    fn a_token_in_the_url_reaches_neither_debug_nor_describe() {
1284        let source =
1285            GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
1286                .path("config.yaml")
1287                .credential(Credential::token("ghs_hunter2-as-well"))
1288                .build()
1289                .unwrap();
1290
1291        let printed = format!("{source:?} {}", source.describe());
1292
1293        assert!(!printed.contains("hunter2"), "{printed}");
1294        assert!(printed.contains("github.com/acme/config.git"), "{printed}");
1295        // The user half survives, which is what makes the redaction usable
1296        // rather than a black hole.
1297        assert!(printed.contains("x-access-token"), "{printed}");
1298    }
1299
1300    /// The same planted credential, one step earlier. A builder holds the
1301    /// URL from `builder()` to `build()`, and construction is exactly where
1302    /// somebody prints things to see what they have configured.
1303    #[test]
1304    fn a_token_in_the_url_does_not_reach_the_builders_debug_either() {
1305        let builder =
1306            GitSource::builder("https://x-access-token:ghs_hunter2@github.com/acme/config.git")
1307                .path("config.yaml")
1308                .credential(Credential::token("ghs_hunter2-as-well"));
1309
1310        let printed = format!("{builder:?}");
1311
1312        assert!(!printed.contains("hunter2"), "{printed}");
1313        assert!(printed.contains("github.com/acme/config.git"), "{printed}");
1314    }
1315}