git_lfs_creds/chain.rs
1//! Run a sequence of helpers, passing successes/failures back to all of them.
2//!
3//! Mirrors upstream's `CredentialHelpers`: the first helper to return
4//! creds wins for `fill`; `approve`/`reject` are broadcast to every
5//! helper so caches stay in sync with the upstream source of truth
6//! (`git credential`).
7
8use std::io::Write as _;
9
10use crate::helper::{Credentials, Helper, HelperError};
11use crate::query::Query;
12
13/// Try each helper in order on `fill`, broadcast `approve` / `reject`.
14///
15/// Typical wiring puts a [`CachingHelper`] before a
16/// [`GitCredentialHelper`]: the cache short-circuits the slow
17/// shell-out path once a working pair has resolved, and approvals
18/// propagate so subsequent calls hit the cache.
19///
20/// [`CachingHelper`]: crate::CachingHelper
21/// [`GitCredentialHelper`]: crate::GitCredentialHelper
22pub struct HelperChain {
23 helpers: Vec<Box<dyn Helper>>,
24}
25
26impl HelperChain {
27 /// Build a chain from a list of boxed helpers, applied in order.
28 pub fn new(helpers: Vec<Box<dyn Helper>>) -> Self {
29 Self { helpers }
30 }
31
32 /// `true` if no helpers are configured.
33 ///
34 /// Calls into [`Helper::fill`] will always return `Ok(None)` for
35 /// an empty chain.
36 pub fn is_empty(&self) -> bool {
37 self.helpers.is_empty()
38 }
39}
40
41impl Helper for HelperChain {
42 /// Walk helpers in order. The first to return creds wins; helpers
43 /// that error out are logged and skipped so a busted askpass program
44 /// can't lock the user out of `git credential` further down the
45 /// chain. Mirrors upstream's `CredentialHelpers.Fill` at
46 /// `creds/creds.go:502`. If nothing returned creds and at least one
47 /// helper errored, surface the last error so callers see *why*
48 /// nothing worked rather than a bare "credentials not found".
49 fn fill(&self, query: &Query) -> Result<Option<Credentials>, HelperError> {
50 let mut last_err: Option<HelperError> = None;
51 for h in &self.helpers {
52 match h.fill(query) {
53 Ok(Some(c)) => return Ok(Some(c)),
54 Ok(None) => continue,
55 Err(e) => {
56 // Upstream's `credential fill error: <err>` trace
57 // at `creds/creds.go:513`. Always-on; `GIT_TRACE`
58 // gating isn't worth the extra branch for a path
59 // that already only fires when something failed.
60 let mut err = std::io::stderr().lock();
61 let _ = writeln!(err, "credential fill error: {e}");
62 last_err = Some(e);
63 continue;
64 }
65 }
66 }
67 match last_err {
68 Some(e) => Err(e),
69 None => Ok(None),
70 }
71 }
72
73 /// Broadcast to every helper. Errors from individual helpers are
74 /// surfaced (first wins) — a failed approve generally means we
75 /// couldn't write to the keystore, which is worth knowing about.
76 fn approve(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
77 let mut first_err = None;
78 for h in &self.helpers {
79 if let Err(e) = h.approve(query, creds) {
80 first_err.get_or_insert(e);
81 }
82 }
83 match first_err {
84 Some(e) => Err(e),
85 None => Ok(()),
86 }
87 }
88
89 fn reject(&self, query: &Query, creds: &Credentials) -> Result<(), HelperError> {
90 let mut first_err = None;
91 for h in &self.helpers {
92 if let Err(e) = h.reject(query, creds) {
93 first_err.get_or_insert(e);
94 }
95 }
96 match first_err {
97 Some(e) => Err(e),
98 None => Ok(()),
99 }
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use std::sync::Mutex;
107
108 #[derive(Default)]
109 struct StaticHelper {
110 answer: Option<Credentials>,
111 approves: Mutex<Vec<(Query, Credentials)>>,
112 rejects: Mutex<Vec<(Query, Credentials)>>,
113 }
114
115 impl Helper for StaticHelper {
116 fn fill(&self, _q: &Query) -> Result<Option<Credentials>, HelperError> {
117 Ok(self.answer.clone())
118 }
119 fn approve(&self, q: &Query, c: &Credentials) -> Result<(), HelperError> {
120 self.approves.lock().unwrap().push((q.clone(), c.clone()));
121 Ok(())
122 }
123 fn reject(&self, q: &Query, c: &Credentials) -> Result<(), HelperError> {
124 self.rejects.lock().unwrap().push((q.clone(), c.clone()));
125 Ok(())
126 }
127 }
128
129 fn q() -> Query {
130 Query {
131 protocol: "https".into(),
132 host: "h".into(),
133 path: String::new(),
134 }
135 }
136
137 #[test]
138 fn fill_returns_first_match() {
139 let chain = HelperChain::new(vec![
140 Box::new(StaticHelper {
141 answer: None,
142 ..Default::default()
143 }),
144 Box::new(StaticHelper {
145 answer: Some(Credentials::new("u", "p")),
146 ..Default::default()
147 }),
148 ]);
149 assert_eq!(chain.fill(&q()).unwrap(), Some(Credentials::new("u", "p")));
150 }
151
152 #[test]
153 fn approve_broadcasts_to_all_helpers() {
154 let chain = crate::CachingHelper::new();
155 let outer = HelperChain::new(vec![
156 Box::new(StaticHelper::default()),
157 Box::new(crate::CachingHelper::new()),
158 ]);
159 let c = Credentials::new("u", "p");
160 outer.approve(&q(), &c).unwrap();
161 // First helper recorded the approve; can't peek at the inner cache
162 // through the trait, but the broadcast itself completing without
163 // error is what we're checking.
164 let _ = chain;
165 }
166}