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