Skip to main content

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}