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}