Skip to main content

devboy_executor/
argv_secrets.rs

1//! `@secret:<path>` substitution wrapper for child-process argv per
2//! [ADR-020] §5.
3//!
4//! ADR-020 §5 second bullet:
5//!
6//! > A wrapper rewrites `@secret:<path>` occurrences in argv before
7//! > `exec`. Because argv is visible to other processes through `ps`,
8//! > the wrapper prefers passing the secret through stdin or a file
9//! > descriptor when the target tool supports it (for example,
10//! > `gh auth login --with-token`, `git credential fill`). Direct
11//! > argv substitution is the fallback for tools that accept secrets
12//! > only in argv, and is documented as such.
13//!
14//! [`rewrite_argv`] is the *planning* function: it takes the raw
15//! `argv` plus a [`SecretResolver`], decides for each known tool
16//! whether to redirect through stdin, and returns a [`RewritePlan`]
17//! that captures:
18//!
19//! - the final `argv` (with aliases removed when they go through
20//!   stdin, or with plaintext substituted when they go through
21//!   argv),
22//! - an optional `stdin_payload` ([`SecretString`]) the caller
23//!   writes to the child's stdin,
24//! - a per-substitution audit trail ([`Substitution`]) so callers
25//!   can log which paths were resolved through which strategy,
26//! - an `argv_visible` flag so the caller can warn when at least
27//!   one secret will land in `ps` output.
28//!
29//! [`apply_plan_to_command`] hands the plan to a
30//! [`tokio::process::Command`] — sets stdio, then the caller
31//! `.spawn()`s and writes the payload to the child's stdin.
32//!
33//! ## Known-tool detection
34//!
35//! The first version recognises two FD-friendly invocation
36//! patterns:
37//!
38//! - `gh auth login --with-token <alias>` — `gh` reads the token
39//!   from stdin when `--with-token` is present.
40//! - `git credential fill|approve|reject` — the `git credential`
41//!   protocol is stdin-only.
42//!
43//! Adding more patterns (`vault login -method=token`, `aws ssm
44//! put-parameter --value`, …) is one entry per case in the
45//! `Known` enum + a small predicate. Until they're added, the
46//! wrapper falls back to plaintext-in-argv with a structured
47//! warning surface.
48//!
49//! ## What this module does **not** do
50//!
51//! - Spawn the child. The caller decides how (`tokio::spawn`,
52//!   blocking, supervised), and only this module knows the
53//!   stdio shape.
54//! - Resolve aliases against a router. The
55//!   [`SecretResolver`] trait is the boundary; the storage-layer
56//!   impl that walks the router/credential chain lands separately.
57//!
58//! [ADR-020]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-020-secret-manifest-and-alias-resolution.md
59//! [`SecretResolver`]: devboy_core::alias::SecretResolver
60
61use std::path::Path;
62
63use devboy_core::alias::{AliasResolverError, SecretResolver, parse_alias};
64use devboy_core::secret_approval::ApprovalGatedResolver;
65use secrecy::{ExposeSecret, SecretString};
66use thiserror::Error;
67use tracing::{debug, warn};
68
69// =============================================================================
70// Public types
71// =============================================================================
72
73/// Per-substitution audit entry. Surfaced through
74/// [`RewritePlan::substitutions`] so callers can log routing
75/// decisions without reaching into the secret values.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Substitution {
78    /// Path portion of the original `@secret:<path>` alias.
79    pub path: String,
80    /// Index in the *input* argv where the alias appeared.
81    pub argv_index: usize,
82    /// Strategy chosen for this alias.
83    pub strategy: SubstitutionStrategy,
84}
85
86/// How the wrapper passed a secret to the child.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SubstitutionStrategy {
89    /// Resolved value placed verbatim in the rewritten argv —
90    /// visible to other processes via `ps`. Fallback for tools
91    /// that have no FD/stdin alternative.
92    Argv,
93    /// Resolved value written to the child's stdin. The argv
94    /// position that originally held the alias is removed.
95    Stdin,
96}
97
98/// Outcome of [`rewrite_argv`].
99#[derive(Debug)]
100pub struct RewritePlan {
101    /// Argv as it should be handed to the child. Aliases routed
102    /// through stdin are gone; aliases routed through argv hold
103    /// the plaintext value.
104    pub argv: Vec<String>,
105    /// When `Some`, the caller writes the contained
106    /// [`SecretString`] to the child's stdin. Multiple
107    /// stdin-routed aliases are joined with `\n`; the trailing
108    /// newline lets `gh auth login --with-token` and friends
109    /// terminate the read cleanly.
110    pub stdin_payload: Option<SecretString>,
111    /// Audit trail (one entry per alias seen).
112    pub substitutions: Vec<Substitution>,
113    /// `true` when at least one alias was routed through argv —
114    /// callers who want to fail loud on argv exposure can check
115    /// this.
116    pub argv_visible: bool,
117}
118
119/// Failure modes for [`rewrite_argv`].
120#[derive(Debug, Error)]
121pub enum ArgvRewriteError {
122    /// Resolver could not produce a value for an alias.
123    //
124    // `source_error` (not `source`) — thiserror treats a field
125    // named `source` as the `#[source]` chain root, but our
126    // explicit `#[source]` annotation on this field already
127    // signals that. Renaming sidesteps the name clash and lets
128    // the destructure work cleanly in tests.
129    #[error("failed to resolve alias `@secret:{path}`: {source_error}")]
130    Resolve {
131        /// Path portion of the alias.
132        path: String,
133        /// Underlying resolver error.
134        #[source]
135        source_error: AliasResolverError,
136    },
137}
138
139// =============================================================================
140// Planning
141// =============================================================================
142
143/// Plan a substitution for the given child invocation.
144///
145/// `program` is the path or basename the caller will spawn (`"gh"`,
146/// `"/usr/local/bin/git"`, …). `argv` is the argument vector that
147/// follows it. The wrapper inspects `argv` for `@secret:<path>`
148/// aliases, asks the resolver for each value, and decides per
149/// alias whether to redirect through stdin or substitute in argv.
150///
151/// On `Err(ArgvRewriteError::Resolve)` the partial work is
152/// discarded — the caller cannot end up with half-resolved argv.
153pub fn rewrite_argv<R, F>(
154    program: &str,
155    argv: &[String],
156    resolver: &ApprovalGatedResolver<R, F>,
157) -> Result<RewritePlan, ArgvRewriteError>
158where
159    R: SecretResolver,
160    F: Fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy + Send + Sync,
161{
162    // Step 1: decide the per-tool strategy.
163    let strategy = ToolStrategy::detect(program, argv);
164
165    // Step 2: walk argv, classify each entry, resolve when needed.
166    let mut out_argv = Vec::with_capacity(argv.len());
167    let mut substitutions = Vec::new();
168    let mut stdin_pieces: Vec<SecretString> = Vec::new();
169    let mut argv_visible = false;
170
171    for (idx, raw) in argv.iter().enumerate() {
172        let Some(path) = parse_alias(raw) else {
173            // Not an alias — keep verbatim (per ADR-020 §5,
174            // partial occurrences are NOT rewritten).
175            out_argv.push(raw.clone());
176            continue;
177        };
178
179        let value = resolver
180            .resolve(path)
181            .map_err(|source_error| ArgvRewriteError::Resolve {
182                path: path.to_owned(),
183                source_error,
184            })?;
185
186        if strategy.uses_stdin_for(idx, argv) {
187            // Drop the alias arg — the child reads the value
188            // off stdin. The plan's payload picks it up.
189            stdin_pieces.push(value);
190            substitutions.push(Substitution {
191                path: path.to_owned(),
192                argv_index: idx,
193                strategy: SubstitutionStrategy::Stdin,
194            });
195        } else {
196            // Argv fallback: visible in `ps`.
197            argv_visible = true;
198            out_argv.push(value.expose_secret().to_owned());
199            substitutions.push(Substitution {
200                path: path.to_owned(),
201                argv_index: idx,
202                strategy: SubstitutionStrategy::Argv,
203            });
204            warn!(
205                program,
206                index = idx,
207                path,
208                "argv-substituted secret will be visible to other processes via `ps`; \
209                 prefer a tool that accepts the value via stdin/FD"
210            );
211        }
212    }
213
214    let stdin_payload = if stdin_pieces.is_empty() {
215        None
216    } else {
217        // Join multiple stdin-routed values with `\n` so a tool
218        // expecting one-secret-per-line (rare but it happens)
219        // works. A trailing newline lets `gh auth login` and
220        // `git credential` terminate their reads.
221        let joined = stdin_pieces
222            .iter()
223            .map(|s| s.expose_secret().to_owned())
224            .collect::<Vec<_>>()
225            .join("\n")
226            + "\n";
227        Some(SecretString::from(joined))
228    };
229
230    debug!(
231        program,
232        substitutions = substitutions.len(),
233        argv_visible,
234        "argv-secret rewrite complete"
235    );
236
237    Ok(RewritePlan {
238        argv: out_argv,
239        stdin_payload,
240        substitutions,
241        argv_visible,
242    })
243}
244
245// =============================================================================
246// Apply plan to a tokio::process::Command
247// =============================================================================
248
249/// Wire a [`RewritePlan`] into a [`tokio::process::Command`].
250///
251/// Sets the stdin disposition based on whether the plan has a
252/// payload — `Stdio::piped()` when yes, untouched (inherits) when
253/// no. The argv override replaces whatever args were on the
254/// command. The caller is responsible for `.spawn()`-ing,
255/// writing the payload through `child.stdin`, and waiting on the
256/// child.
257///
258/// Argv override note: this function calls `.args(&plan.argv)`
259/// only if the plan changed the argv (the substitutions list is
260/// non-empty). When the plan is a pass-through (no aliases) we
261/// leave whatever the caller already set in place.
262pub fn apply_plan_to_command(plan: &RewritePlan, cmd: &mut tokio::process::Command) {
263    if !plan.substitutions.is_empty() {
264        // Reset args to the rewritten list. tokio::process::Command
265        // doesn't expose args_mut(), so we can only append. The
266        // contract here is that the caller is using this helper as
267        // *the* way to supply args for a planned invocation —
268        // the input `cmd` should not have args set yet.
269        cmd.args(&plan.argv);
270    }
271    if plan.stdin_payload.is_some() {
272        cmd.stdin(std::process::Stdio::piped());
273    }
274}
275
276// =============================================================================
277// Tool detection
278// =============================================================================
279
280/// Per-invocation strategy that knows when to prefer stdin.
281struct ToolStrategy {
282    kind: Known,
283}
284
285#[derive(Debug, Clone, Copy)]
286enum Known {
287    /// `gh auth login --with-token <ALIAS>` — `gh` reads the
288    /// token from stdin when `--with-token` is present.
289    GhAuthLoginWithToken {
290        /// Index in argv where the alias replaces the token.
291        token_index: usize,
292    },
293    /// `git credential fill|approve|reject` — the credential
294    /// protocol is stdin-only by design; ANY alias arg goes
295    /// through stdin.
296    GitCredential,
297    /// No known FD-friendly pattern matched — fall back to argv
298    /// substitution.
299    Fallback,
300}
301
302impl ToolStrategy {
303    fn detect(program: &str, argv: &[String]) -> Self {
304        let basename = Path::new(program)
305            .file_name()
306            .and_then(|s| s.to_str())
307            .unwrap_or(program);
308        match basename {
309            "gh" => detect_gh(argv),
310            "git" => detect_git(argv),
311            _ => ToolStrategy {
312                kind: Known::Fallback,
313            },
314        }
315    }
316
317    /// Decide whether the alias at `argv_index` should be routed
318    /// through stdin for this tool.
319    fn uses_stdin_for(&self, argv_index: usize, _argv: &[String]) -> bool {
320        match self.kind {
321            Known::GhAuthLoginWithToken { token_index } => argv_index == token_index,
322            Known::GitCredential => true,
323            Known::Fallback => false,
324        }
325    }
326}
327
328fn detect_gh(argv: &[String]) -> ToolStrategy {
329    // We're looking for `auth login --with-token <ALIAS>`.
330    // Anything else falls back to argv.
331    let auth_idx = argv.iter().position(|a| a == "auth");
332    let login_idx = argv.iter().position(|a| a == "login");
333    let with_token_idx = argv.iter().position(|a| a == "--with-token");
334    if let (Some(a), Some(l), Some(t)) = (auth_idx, login_idx, with_token_idx)
335        && a < l
336        && l < t
337    {
338        // The token argument follows `--with-token`.
339        let token_idx = t + 1;
340        if token_idx < argv.len() {
341            let candidate = &argv[token_idx];
342            if parse_alias(candidate).is_some() {
343                return ToolStrategy {
344                    kind: Known::GhAuthLoginWithToken {
345                        token_index: token_idx,
346                    },
347                };
348            }
349        }
350    }
351    ToolStrategy {
352        kind: Known::Fallback,
353    }
354}
355
356fn detect_git(argv: &[String]) -> ToolStrategy {
357    // `git credential <subcommand>` — stdin-only protocol.
358    if argv.first().map(String::as_str) == Some("credential") {
359        return ToolStrategy {
360            kind: Known::GitCredential,
361        };
362    }
363    ToolStrategy {
364        kind: Known::Fallback,
365    }
366}
367
368// =============================================================================
369// Tests
370// =============================================================================
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use std::collections::HashMap;
376    use std::sync::Mutex;
377
378    /// Tiny in-memory resolver for tests.
379    struct MapResolver {
380        entries: Mutex<HashMap<String, String>>,
381    }
382
383    impl MapResolver {
384        fn new(pairs: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
385            Self {
386                entries: Mutex::new(
387                    pairs
388                        .into_iter()
389                        .map(|(k, v)| (k.to_owned(), v.to_owned()))
390                        .collect(),
391                ),
392            }
393        }
394    }
395
396    fn always_never(_: &str) -> devboy_core::secret_approval::ApproveOnUsePolicy {
397        devboy_core::secret_approval::ApproveOnUsePolicy::Never
398    }
399
400    fn wrap_resolver_never(
401        r: MapResolver,
402    ) -> ApprovalGatedResolver<
403        MapResolver,
404        fn(&str) -> devboy_core::secret_approval::ApproveOnUsePolicy,
405    > {
406        ApprovalGatedResolver::new(
407            r,
408            std::sync::Arc::new(devboy_core::secret_approval::SessionApprovalCache::new()),
409            always_never,
410        )
411    }
412
413    impl SecretResolver for MapResolver {
414        fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
415            let map = self.entries.lock().unwrap();
416            match map.get(path) {
417                Some(v) => Ok(SecretString::from(v.clone())),
418                None => Err(AliasResolverError::NotFound {
419                    path: path.to_owned(),
420                }),
421            }
422        }
423    }
424
425    fn argv(parts: &[&str]) -> Vec<String> {
426        parts.iter().map(|s| (*s).to_owned()).collect()
427    }
428
429    // -- Pass-through (no aliases) ------------------------------
430
431    #[test]
432    fn no_alias_in_argv_is_a_passthrough_with_no_substitutions() {
433        let resolver = MapResolver::new([]);
434        let plan = rewrite_argv(
435            "any-tool",
436            &argv(&["--flag", "value"]),
437            &wrap_resolver_never(resolver),
438        )
439        .unwrap();
440        assert_eq!(plan.argv, vec!["--flag".to_owned(), "value".to_owned()]);
441        assert!(plan.stdin_payload.is_none());
442        assert!(plan.substitutions.is_empty());
443        assert!(!plan.argv_visible);
444    }
445
446    // -- Argv fallback -----------------------------------------
447
448    #[test]
449    fn unknown_tool_falls_back_to_argv_substitution() {
450        let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
451        let plan = rewrite_argv(
452            "some-tool",
453            &argv(&["--token", "@secret:personal/github/pat"]),
454            &wrap_resolver_never(resolver),
455        )
456        .unwrap();
457        assert_eq!(
458            plan.argv,
459            vec!["--token".to_owned(), "ghp-fixture".to_owned()]
460        );
461        assert!(plan.stdin_payload.is_none());
462        assert_eq!(plan.substitutions.len(), 1);
463        assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Argv);
464        assert_eq!(plan.substitutions[0].argv_index, 1);
465        assert!(plan.argv_visible);
466    }
467
468    #[test]
469    fn fallback_warns_via_argv_visible_flag() {
470        let resolver = MapResolver::new([("a/b/c", "v")]);
471        let plan = rewrite_argv(
472            "some-tool",
473            &argv(&["@secret:a/b/c"]),
474            &wrap_resolver_never(resolver),
475        )
476        .unwrap();
477        assert!(plan.argv_visible);
478    }
479
480    // -- gh auth login --with-token ----------------------------
481
482    #[test]
483    fn gh_auth_login_with_token_routes_through_stdin() {
484        use secrecy::ExposeSecret;
485        let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
486        let plan = rewrite_argv(
487            "gh",
488            &argv(&[
489                "auth",
490                "login",
491                "--with-token",
492                "@secret:personal/github/pat",
493            ]),
494            &wrap_resolver_never(resolver),
495        )
496        .unwrap();
497        // The alias arg is dropped from the rewritten argv.
498        assert_eq!(
499            plan.argv,
500            vec![
501                "auth".to_owned(),
502                "login".to_owned(),
503                "--with-token".to_owned()
504            ]
505        );
506        // The plaintext is *not* in argv anywhere.
507        assert!(plan.argv.iter().all(|a| !a.contains("ghp-fixture")));
508        // Stdin payload carries the token (with trailing \n).
509        let payload = plan.stdin_payload.unwrap();
510        assert_eq!(payload.expose_secret(), "ghp-fixture\n");
511        assert_eq!(plan.substitutions.len(), 1);
512        assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
513        assert!(!plan.argv_visible, "stdin path must NOT mark argv visible");
514    }
515
516    #[test]
517    fn gh_with_absolute_path_still_recognised() {
518        let resolver = MapResolver::new([("p/g/p", "v")]);
519        let plan = rewrite_argv(
520            "/usr/local/bin/gh",
521            &argv(&["auth", "login", "--with-token", "@secret:p/g/p"]),
522            &wrap_resolver_never(resolver),
523        )
524        .unwrap();
525        // basename match → still gh strategy.
526        assert!(!plan.argv_visible);
527    }
528
529    #[test]
530    fn gh_without_with_token_falls_back_to_argv() {
531        let resolver = MapResolver::new([("p/g/p", "v")]);
532        let plan = rewrite_argv(
533            "gh",
534            &argv(&["repo", "view", "--token", "@secret:p/g/p"]),
535            &wrap_resolver_never(resolver),
536        )
537        .unwrap();
538        // No --with-token flag → fallback strategy.
539        assert!(plan.argv_visible);
540        assert!(plan.argv.contains(&"v".to_owned()));
541    }
542
543    // -- git credential ----------------------------------------
544
545    #[test]
546    fn git_credential_routes_alias_args_through_stdin() {
547        use secrecy::ExposeSecret;
548        let resolver = MapResolver::new([("svc/git/cred", "credential-fixture")]);
549        let plan = rewrite_argv(
550            "git",
551            &argv(&["credential", "fill", "@secret:svc/git/cred"]),
552            &wrap_resolver_never(resolver),
553        )
554        .unwrap();
555        // Alias arg dropped from argv; only literal args remain.
556        assert_eq!(plan.argv, vec!["credential".to_owned(), "fill".to_owned()]);
557        let payload = plan.stdin_payload.unwrap();
558        assert_eq!(payload.expose_secret(), "credential-fixture\n");
559        assert_eq!(plan.substitutions[0].strategy, SubstitutionStrategy::Stdin);
560        assert!(!plan.argv_visible);
561    }
562
563    #[test]
564    fn git_non_credential_uses_argv_fallback() {
565        let resolver = MapResolver::new([("a/b/c", "v")]);
566        let plan = rewrite_argv(
567            "git",
568            &argv(&["push", "--token", "@secret:a/b/c"]),
569            &wrap_resolver_never(resolver),
570        )
571        .unwrap();
572        assert!(plan.argv_visible);
573    }
574
575    // -- Multiple aliases --------------------------------------
576
577    #[test]
578    fn multiple_argv_aliases_each_get_an_audit_entry() {
579        let resolver = MapResolver::new([("a/b/c", "v1"), ("d/e/f", "v2")]);
580        let plan = rewrite_argv(
581            "tool",
582            &argv(&["@secret:a/b/c", "literal", "@secret:d/e/f"]),
583            &wrap_resolver_never(resolver),
584        )
585        .unwrap();
586        assert_eq!(
587            plan.argv,
588            vec!["v1".to_owned(), "literal".to_owned(), "v2".to_owned()]
589        );
590        assert_eq!(plan.substitutions.len(), 2);
591        assert!(plan.argv_visible);
592    }
593
594    // -- Partial-occurrence non-rewriting per ADR-020 §5 -------
595
596    #[test]
597    fn alias_inside_a_longer_arg_is_not_rewritten() {
598        // ADR-020 §5: alias replaces the WHOLE field value;
599        // partial occurrences are NOT aliases.
600        let resolver = MapResolver::new([("a/b/c", "v")]);
601        let plan = rewrite_argv(
602            "tool",
603            &argv(&["Bearer @secret:a/b/c"]),
604            &wrap_resolver_never(resolver),
605        )
606        .unwrap();
607        assert_eq!(plan.argv, vec!["Bearer @secret:a/b/c".to_owned()]);
608        assert!(plan.substitutions.is_empty());
609    }
610
611    // -- Resolver error propagation ----------------------------
612
613    #[test]
614    fn unknown_alias_path_propagates_resolver_error() {
615        let resolver = MapResolver::new([]);
616        let err = rewrite_argv(
617            "tool",
618            &argv(&["--token", "@secret:nope/nope/nope"]),
619            &wrap_resolver_never(resolver),
620        )
621        .unwrap_err();
622        match err {
623            ArgvRewriteError::Resolve { path, .. } => {
624                assert_eq!(path, "nope/nope/nope");
625            }
626        }
627    }
628
629    // -- apply_plan_to_command + real child --------------------
630
631    /// End-to-end sanity check on Unix: plan a substitution that
632    /// goes through stdin, spawn `/bin/cat`, write the payload,
633    /// and assert the child reads the secret value back from
634    /// stdout. Also asserts that the rewritten argv handed to
635    /// the child contains no plaintext — the moral equivalent
636    /// of `ps` invisibility.
637    #[cfg(unix)]
638    #[tokio::test]
639    async fn apply_plan_to_command_pipes_stdin_to_child() {
640        use tokio::io::{AsyncReadExt, AsyncWriteExt};
641
642        let resolver = MapResolver::new([("personal/github/pat", "ghp-fixture")]);
643        let plan = rewrite_argv(
644            "gh",
645            &argv(&[
646                "auth",
647                "login",
648                "--with-token",
649                "@secret:personal/github/pat",
650            ]),
651            &wrap_resolver_never(resolver),
652        )
653        .unwrap();
654
655        // Sanity: rewritten argv must not contain the plaintext
656        // (this is the "ps invisibility" property).
657        for arg in &plan.argv {
658            assert!(!arg.contains("ghp-fixture"));
659        }
660
661        // Spawn /bin/cat as a stand-in for any tool that reads
662        // its single secret from stdin.
663        let mut cmd = tokio::process::Command::new("/bin/cat");
664        // We don't reuse plan.argv here — /bin/cat doesn't take
665        // gh's arguments. Just exercise the stdin-piping path
666        // independently.
667        if plan.stdin_payload.is_some() {
668            cmd.stdin(std::process::Stdio::piped());
669        }
670        cmd.stdout(std::process::Stdio::piped());
671        let mut child = cmd.spawn().expect("spawn /bin/cat");
672
673        // Write the payload.
674        if let Some(secret) = &plan.stdin_payload {
675            let mut stdin = child.stdin.take().unwrap();
676            stdin
677                .write_all(secret.expose_secret().as_bytes())
678                .await
679                .unwrap();
680            // Closing stdin sends EOF so cat exits cleanly.
681            stdin.shutdown().await.unwrap();
682            drop(stdin);
683        }
684
685        // Read the child's stdout.
686        let mut stdout = child.stdout.take().unwrap();
687        let mut buf = String::new();
688        stdout.read_to_string(&mut buf).await.unwrap();
689        let _ = child.wait().await;
690
691        // /bin/cat echoes its stdin verbatim. The secret made it
692        // through.
693        assert_eq!(buf, "ghp-fixture\n");
694    }
695}