drep/auth/command.rs
1//! Resolving a provider credential by running a configured argv.
2//!
3//! For a gateway whose tokens expire in minutes, a stored key is stale before
4//! the second commit. The recognisable shape is a helper the user already runs
5//! by hand - `gcloud auth print-access-token`, `az account get-access-token`,
6//! `op read`, `vault read` - so `api_key_command` is that argv, run with no
7//! shell, and its trimmed stdout is the credential.
8//!
9//! Three decisions are load-bearing:
10//!
11//! - **The diagnostic is deliberately thin.** A failure reports the program and
12//! the exit status and nothing else. A misconfigured helper can print the
13//! token to either stream - `vault read -field=token` writes the value to
14//! stdout and its usage to stderr, and a wrapper that swaps them is one typo
15//! away - so an error message is the one place a credential would escape into
16//! a terminal, a CI log or a bug report.
17//! - **One variant per distinguishable cause.** "could not be started",
18//! "exited 7", "printed nothing" and "printed something unusable" send the
19//! reader to four different fixes, and collapsing them into one message about
20//! the command failing sends them to none.
21//! - **No disk cache and no TTL.** drep is a short-lived process, so a
22//! credential written to disk buys nothing and adds a file worth stealing.
23//! `auth::resolve` runs each entry's command exactly once per process instead.
24
25use std::process::Stdio;
26use std::time::Duration;
27
28use thiserror::Error;
29
30/// How long a credential helper may take.
31///
32/// Not `timeout_secs`: that field is a model-response budget the presets set to
33/// 1800, and a commit gate must not wait half an hour to learn a helper is
34/// wedged. Thirty seconds covers a network round trip to a token endpoint and an
35/// interactive approval, which is the slowest thing any of the recognisable
36/// helpers does.
37pub(crate) const TIMEOUT_SECS: u64 = 30;
38
39/// The most stdout a credential helper may print.
40///
41/// Generous by three orders of magnitude for the thing being read: the longest
42/// bearer tokens in circulation are a few kilobytes. It is a ceiling on a
43/// misconfiguration, not a budget for a credential, and it exists because the
44/// alternative is allocating whatever the helper printed inside the commit gate.
45const MAX_OUTPUT_BYTES: usize = 64 * 1024;
46
47/// Why a configured `api_key_command` did not produce a usable credential.
48///
49/// `Debug` is derived, which is safe only because no variant carries captured
50/// output. Adding one that did would put a credential into `{:?}`.
51#[derive(Debug, Error)]
52pub enum KeyCommandError {
53 /// Reachable only from a hand-built `LlmConfig`: `config::load` rejects
54 /// `api_key_command = []` for an enabled entry. Checked anyway, because a
55 /// panic inside the commit gate is a worse failure than a message.
56 #[error("api_key_command is empty; it must name a program to run")]
57 NoProgram,
58
59 /// Distinguished from [`Self::Spawn`] because the fix is different: this one
60 /// is a typo in `argv[0]` or a helper that is not installed, and reporting it
61 /// as "could not be started: No such file or directory" sends the reader
62 /// looking at file permissions instead.
63 #[error("api_key_command `{program}` was not found on PATH")]
64 NotFound { program: String },
65
66 #[error("api_key_command `{program}` could not be started: {cause}")]
67 Spawn {
68 program: String,
69 cause: std::io::Error,
70 },
71
72 #[error("api_key_command `{program}` did not finish within {secs} seconds")]
73 Timeout { program: String, secs: u64 },
74
75 /// A helper killed by a signal has no exit code, so it cannot be reported
76 /// through [`Self::Failed`] without inventing one.
77 #[error("api_key_command `{program}` was killed by a signal")]
78 Signal { program: String },
79
80 /// The status and nothing else. See the module doc: captured output is where
81 /// a credential would escape.
82 #[error("api_key_command `{program}` exited {code}")]
83 Failed { program: String, code: i32 },
84
85 #[error(
86 "api_key_command `{program}` printed output that is not valid UTF-8; \
87 it must print the credential as text on stdout"
88 )]
89 NotUtf8 { program: String },
90
91 /// An empty credential satisfies every "is a key present" check downstream
92 /// and then 401s, which reads as a rejected key rather than a helper that
93 /// printed nothing. `AuthStore::set` refuses an empty paste for the same
94 /// reason.
95 #[error(
96 "api_key_command `{program}` printed nothing; the whole trimmed stdout is \
97 the credential, so it must print one"
98 )]
99 Empty { program: String },
100
101 /// The resolved value becomes an HTTP header, which cannot carry a control
102 /// character - so a helper that printed two lines, or a diagnostic banner
103 /// followed by the token, is a guaranteed transport failure. Caught here
104 /// rather than on the first file of the first push, exactly as
105 /// `AuthStore::set` catches it at the prompt.
106 #[error(
107 "api_key_command `{program}` printed a value containing a character that \
108 cannot be sent in a header; it must print the credential alone"
109 )]
110 Unusable { program: String },
111
112 /// The helper printed more than any credential can be.
113 ///
114 /// A ceiling rather than an unbounded read, for the reason `crate::http`'s
115 /// module doc gives: a bound is a safety property, and the failure it names
116 /// there is a second reader next to a bounded one that kept calling `text()`
117 /// with no ceiling at all. This is the third spawn site in the crate and the
118 /// first with a ceiling, so the doctrine is applied here rather than argued
119 /// about: an `api_key_command` pointed at the wrong program - `cat` on a large
120 /// file is one keystroke from `cat` on a token file - would otherwise allocate
121 /// whatever it printed inside the commit gate, then walk all of it twice to
122 /// trim it and scan it for control characters.
123 ///
124 /// Reported rather than truncated. Truncating would hand the endpoint a prefix
125 /// of something that was never a credential and turn a local misconfiguration
126 /// into a 401 per file.
127 #[error(
128 "api_key_command `{program}` printed more than {limit} bytes; the whole \
129 trimmed stdout is the credential, so it must print one and nothing else"
130 )]
131 TooMuchOutput { program: String, limit: usize },
132}
133
134/// [`run`] bounded by drep's own [`TIMEOUT_SECS`].
135///
136/// The one place the constant is applied. `check` and `doctor` both run a
137/// helper, and two call sites each building their own `Duration` could come to
138/// bound the same helper differently - which would have `doctor` report on a run
139/// `check` abandons.
140pub(crate) async fn run_bounded(argv: &[String]) -> Result<String, KeyCommandError> {
141 run(argv, Duration::from_secs(TIMEOUT_SECS)).await
142}
143
144/// Run `argv` and return its trimmed stdout as the credential.
145///
146/// `timeout` is a parameter rather than read from [`TIMEOUT_SECS`] so a test can
147/// bound a wedged helper in milliseconds, the same reason `LlmClient`'s
148/// `retry_config` is reachable.
149///
150/// stdin is closed rather than inherited: a helper that decides to prompt would
151/// otherwise read the terminal drep's caller is using, and a git hook has no
152/// terminal to give it. stderr goes to `null`, so there is no second pipe for a
153/// chatty helper to fill while nobody drains it. drep never reads a byte of stderr
154/// anyway - the diagnostic names the program and the status and nothing else,
155/// because a misconfigured helper can print the token to either stream - so piping
156/// it bought only the obligation to drain it. The direct child's exit ends the
157/// read after a bounded final drain even when a background grandchild inherited
158/// stdout; otherwise that grandchild could keep credential resolution open until
159/// the timeout after the configured helper had already finished. `kill_on_drop`
160/// reaps the child on the timeout and over-limit branches.
161pub(crate) async fn run(argv: &[String], timeout: Duration) -> Result<String, KeyCommandError> {
162 let Some((program, arguments)) = argv.split_first() else {
163 return Err(KeyCommandError::NoProgram);
164 };
165
166 let mut command = tokio::process::Command::new(program);
167 command
168 .args(arguments)
169 .stdin(Stdio::null())
170 .stdout(Stdio::piped())
171 .stderr(Stdio::null())
172 .kill_on_drop(true);
173
174 let mut child = command.spawn().map_err(|cause| match cause.kind() {
175 std::io::ErrorKind::NotFound => KeyCommandError::NotFound {
176 program: program.clone(),
177 },
178 _ => KeyCommandError::Spawn {
179 program: program.clone(),
180 cause,
181 },
182 })?;
183
184 // stdout is read with a ceiling rather than by `wait_with_output`, which
185 // buffers whatever the helper chose to print. The direct child's exit is
186 // polled beside the pipe and is decisive: a background grandchild that
187 // inherited stdout must not hold credential resolution open after the
188 // configured helper already printed its value and exited. `biased` drains
189 // bytes already ready in the pipe before observing that exit. The bounded
190 // final drain covers the remaining scheduler race: the process can become
191 // ready in Tokio just before the pipe readiness notification for bytes the
192 // helper wrote before exiting.
193 let mut stdout = child
194 .stdout
195 .take()
196 .expect("stdout is piped by the builder above");
197 let read_then_wait = async {
198 use tokio::io::AsyncReadExt;
199 let mut captured = Vec::new();
200 let mut buffer = [0u8; 8192];
201 let status = loop {
202 tokio::select! {
203 biased;
204 read = stdout.read(&mut buffer) => {
205 let read = read.map_err(|cause| KeyCommandError::Spawn {
206 program: program.clone(),
207 cause,
208 })?;
209 if read == 0 {
210 break child.wait().await.map_err(|cause| KeyCommandError::Spawn {
211 program: program.clone(),
212 cause,
213 })?;
214 }
215 append_output(program, &mut captured, &buffer[..read])?;
216 }
217 waited = child.wait() => {
218 let status = waited.map_err(|cause| KeyCommandError::Spawn {
219 program: program.clone(),
220 cause,
221 })?;
222 let final_drain = async {
223 loop {
224 let read = stdout.read(&mut buffer).await.map_err(|cause| {
225 KeyCommandError::Spawn {
226 program: program.clone(),
227 cause,
228 }
229 })?;
230 if read == 0 {
231 break;
232 }
233 append_output(program, &mut captured, &buffer[..read])?;
234 }
235 Ok(())
236 };
237 if let Ok(result) = tokio::time::timeout(
238 Duration::from_millis(10),
239 final_drain,
240 )
241 .await
242 {
243 result?;
244 }
245 break status;
246 }
247 }
248 };
249 Ok((captured, status))
250 };
251
252 let (captured, status) = match tokio::time::timeout(timeout, read_then_wait).await {
253 Ok(result) => result?,
254 Err(_) => {
255 return Err(KeyCommandError::Timeout {
256 program: program.clone(),
257 secs: timeout.as_secs(),
258 });
259 }
260 };
261
262 // The status is consulted before the output, so a helper that failed *and*
263 // printed a partial value is reported as failed. `String::from_utf8` on the
264 // stdout of a helper that exited 7 would otherwise decide which of the two
265 // problems the user hears about.
266 if !status.success() {
267 return Err(match status.code() {
268 Some(code) => KeyCommandError::Failed {
269 program: program.clone(),
270 code,
271 },
272 None => KeyCommandError::Signal {
273 program: program.clone(),
274 },
275 });
276 }
277
278 // What a credential may be is `auth::vet`'s rule, shared with a key pasted at
279 // the prompt: the trim of both ends, the empty refusal and the
280 // control-character refusal are one definition, because a safety property
281 // written twice is written once and forgotten once, and this is the path whose
282 // value nobody ever sees. Only the wording is here.
283 //
284 // Deliberately no scan for a line, a prefix or a token-shaped pattern: a
285 // helper's output is what its author chose to emit, and picking a substring
286 // out of it would send a different credential than the one the helper
287 // produced.
288 let printed = String::from_utf8(captured).map_err(|_| KeyCommandError::NotUtf8 {
289 program: program.clone(),
290 })?;
291 crate::auth::vet(&printed).map_err(|defect| match defect {
292 crate::auth::CredentialDefect::Empty => KeyCommandError::Empty {
293 program: program.clone(),
294 },
295 crate::auth::CredentialDefect::Unusable => KeyCommandError::Unusable {
296 program: program.clone(),
297 },
298 })
299}
300
301/// Append one stdout read while enforcing the credential-output ceiling.
302fn append_output(
303 program: &str,
304 captured: &mut Vec<u8>,
305 bytes: &[u8],
306) -> Result<(), KeyCommandError> {
307 if captured.len().saturating_add(bytes.len()) > MAX_OUTPUT_BYTES {
308 return Err(KeyCommandError::TooMuchOutput {
309 program: program.to_owned(),
310 limit: MAX_OUTPUT_BYTES,
311 });
312 }
313 captured.extend_from_slice(bytes);
314 Ok(())
315}