Skip to main content

acme_proxy_core/
script_hook.rs

1//! The contract every `custom` hook in this server runs under: one script, a
2//! cleared environment, JSON on stdin, an exit code for the verdict.
3//!
4//! Three subsystems delegate to an operator-supplied script —
5//! `signer::custom` (issue/revoke/crl/renewal_info),
6//! `filter::custom` (connection/identifiers) and
7//! `notify::custom` (one event). They differ in what
8//! they put in the environment, what they do with stdout, and how they read the
9//! exit code. They differ in nothing else.
10//!
11//! What they shared was a *security* contract — clear the environment so a
12//! script cannot read the RFC 2136 TSIG secret or the SMTP password, restore a
13//! minimal `PATH`, kill the child when its deadline passes, and bound how much
14//! it may write ([`MAX_SCRIPT_OUTPUT_BYTES`]) — written out three times, token
15//! for token. That is exactly the kind of thing that has to exist once: a
16//! hardening applied to one copy is silently absent from the other two, and
17//! nobody reviewing one of them can tell.
18
19use std::path::{Path, PathBuf};
20use std::process::{Output, Stdio};
21use std::time::Duration;
22
23use tokio::io::AsyncWriteExt;
24use tokio::process::Command;
25use tracing::debug;
26
27/// The `PATH` given to the script, since the server environment is cleared
28/// before each execution. Without it, a script starting with
29/// `#!/usr/bin/env …` would not find its interpreter.
30pub(crate) const DEFAULT_PATH: &str =
31    "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
32
33/// Most a script may write to one stream before it is refused.
34///
35/// Per stream, not combined, so a script that logs to stderr does not spend the
36/// budget its answer needs on stdout.
37///
38/// The value is generous on purpose — every legitimate answer is far below it. A
39/// signer's PEM chain is kilobytes; a megabyte of IPAM names is on the order of
40/// twenty thousand of them. What the ceiling exists for is the runaway case: a
41/// script in a loop, or one that `cat`s something it should not, whose output
42/// this process would otherwise buffer whole. The hook timeout bounds how *long*
43/// that goes on and says nothing about how large it gets, which on the `ipam`
44/// and `filter` hooks is a per-request cost.
45///
46/// Deliberately its own constant rather than `http_client::MAX_RESPONSE_BYTES`,
47/// which happens to carry the same number: that one is a ceiling on a remote
48/// party's HTTP body and this is a ceiling on a local child's pipe, and a future
49/// reason to move one is not a reason to move the other.
50pub(crate) const MAX_SCRIPT_OUTPUT_BYTES: usize = 1024 * 1024;
51
52/// An operator-supplied script, and the budget it runs under.
53#[derive(Debug, Clone)]
54pub struct ScriptHook {
55    path: PathBuf,
56    args: Vec<String>,
57    timeout: Duration,
58}
59
60/// What to hand the script on stdin.
61pub enum ScriptStdin<'a> {
62    /// `/dev/null`. The script gets no payload and cannot block on a read.
63    Null,
64    /// A JSON object, written and then closed.
65    Json(&'a serde_json::Value),
66}
67
68/// Why a script produced no verdict at all — as opposed to producing one this
69/// caller did not like, which is [`ScriptOutcome`]'s business.
70#[derive(Debug, thiserror::Error)]
71pub enum ScriptError {
72    #[error("failed to spawn script {}: {detail}", path.display())]
73    Spawn { path: PathBuf, detail: String },
74    #[error("failed to serialize JSON stdin: {0}")]
75    Serialize(String),
76    #[error("script failed: {0}")]
77    Wait(String),
78    #[error("script timed out after {} ms", .0.as_millis())]
79    Timeout(Duration),
80    /// The script wrote more than [`MAX_SCRIPT_OUTPUT_BYTES`] to one stream.
81    ///
82    /// An error rather than a silent truncation, and the `signer` hook is why:
83    /// a PEM chain cut off in the middle would arrive as an unparsable
84    /// certificate, and the operator would go looking at their CA instead of at
85    /// the script. A named refusal says which it was.
86    #[error("script wrote more than {limit} bytes to {stream}")]
87    OutputTooLarge { limit: usize, stream: &'static str },
88}
89
90/// What a script answered, plus whether it ever read the question.
91#[derive(Debug)]
92pub struct ScriptOutcome {
93    pub output: Output,
94    /// Set when writing the JSON payload to the child's stdin failed — in
95    /// practice `EPIPE`, a script that exited without reading it.
96    ///
97    /// Deliberately not an error on its own. A script whose payload is
98    /// `{"hook":"crl"}` and which exits 0 without reading stdin is behaving
99    /// perfectly reasonably, and turning that into a failure would break
100    /// working deployments. It only matters when the script *also* failed, and
101    /// then it matters a great deal: for the signer's `issue` hook, "the script
102    /// never saw the CSR" reads nothing like "the script rejected the CSR".
103    pub stdin_error: Option<String>,
104}
105
106impl ScriptHook {
107    /// Builds a hook, or `None` when no script is configured.
108    ///
109    /// Each subsystem words its own "you enabled this but gave no path" startup
110    /// error, because only it knows which configuration key to name and what to
111    /// tell the operator to remove.
112    pub fn new(script_path: &str, args: &[String], timeout_ms: u64) -> Option<Self> {
113        if script_path.trim().is_empty() {
114            return None;
115        }
116        Some(Self {
117            path: PathBuf::from(script_path),
118            args: args.to_vec(),
119            timeout: Duration::from_millis(timeout_ms),
120        })
121    }
122
123    /// The script's path, as configured.
124    pub fn path(&self) -> &Path {
125        &self.path
126    }
127
128    /// Runs the script with `envs` in an otherwise empty environment.
129    pub async fn run(
130        &self,
131        envs: &[(&str, &str)],
132        stdin: ScriptStdin<'_>,
133    ) -> Result<ScriptOutcome, ScriptError> {
134        let mut cmd = Command::new(&self.path);
135        cmd.args(&self.args);
136
137        // The script would otherwise inherit the server's entire environment,
138        // which legitimately holds secrets: every `ACME_PROXY_*` configuration
139        // overlay, including the DNS update TSIG key
140        // (`…SIGNER__RELAY__DNS01__RFC2136__TSIG_KEY_SECRET`) and
141        // `notify.email.smtp_password`. An operator-supplied script has no
142        // business receiving those, so it starts from nothing and is given only
143        // the documented variables plus a `PATH` without which a
144        // `#!/usr/bin/env bash` script would not start at all.
145        cmd.env_clear();
146        cmd.env("PATH", DEFAULT_PATH);
147        for (key, value) in envs {
148            cmd.env(key, value);
149        }
150
151        // `tokio::time::timeout` below only abandons the future. Without this,
152        // a child that ignores its deadline outlives it — and since these hooks
153        // run once per request or per event, a blocked script would leak one
154        // process per call.
155        cmd.kill_on_drop(true);
156
157        let piped_stdin = matches!(stdin, ScriptStdin::Json(_));
158        cmd.stdin(if piped_stdin {
159            Stdio::piped()
160        } else {
161            Stdio::null()
162        });
163        cmd.stdout(Stdio::piped());
164        cmd.stderr(Stdio::piped());
165
166        let mut child = cmd.spawn().map_err(|error| ScriptError::Spawn {
167            path: self.path.clone(),
168            detail: error.to_string(),
169        })?;
170
171        // Taken out of the child before anything awaits on it: `wait()` needs
172        // `&mut child`, and the reads below have to run *concurrently* with it
173        // rather than after. A script that fills a pipe buffer nobody is
174        // draining blocks in `write` and never exits, so reading only once the
175        // child had exited would deadlock until the timeout on exactly the
176        // output this function exists to collect.
177        let stdout_pipe = child.stdout.take();
178        let stderr_pipe = child.stderr.take();
179
180        let payload = match stdin {
181            ScriptStdin::Json(payload) => Some(
182                serde_json::to_vec(payload).map_err(|e| ScriptError::Serialize(e.to_string()))?,
183            ),
184            ScriptStdin::Null => None,
185        };
186        let stdin_pipe = child.stdin.take();
187
188        // Writing the payload is one of the joined futures, under the same
189        // timeout, not a step before them. Sequenced first, a script that never
190        // reads stdin — sleeping, or blocked writing a stdout nobody drains yet
191        // — held a payload larger than the pipe buffer in `write_all` with no
192        // deadline at all, since the timeout had not started.
193        let feed = async move {
194            let (Some(bytes), Some(mut pipe)) = (payload, stdin_pipe) else {
195                return Ok::<_, ScriptError>(None);
196            };
197            // Recorded rather than propagated; see `ScriptOutcome::stdin_error`.
198            let mut stdin_error = None;
199            if let Err(error) = pipe.write_all(&bytes).await {
200                stdin_error = Some(error.to_string());
201            } else if let Err(error) = pipe.flush().await {
202                stdin_error = Some(error.to_string());
203            }
204            if let Some(detail) = &stdin_error {
205                debug!(
206                    event = "script_stdin_write_failed",
207                    outcome = "failure",
208                    script_path = %self.path.display(),
209                    error = %detail,
210                );
211            }
212            // `pipe` drops here, closing the write end.
213            Ok(stdin_error)
214        };
215
216        // `wait_with_output()`'s job, minus its unbounded appetite: it collects
217        // both pipes with no ceiling, which on the `filter` and `ipam` hooks is
218        // a per-request allocation an operator script gets to choose the size
219        // of. The four futures are joined rather than sequenced for the reason
220        // given at the `take()` above.
221        let collect = async {
222            let (status, stdout, stderr, stdin_error) = tokio::try_join!(
223                async {
224                    child
225                        .wait()
226                        .await
227                        .map_err(|e| ScriptError::Wait(e.to_string()))
228                },
229                read_capped(stdout_pipe, "stdout"),
230                read_capped(stderr_pipe, "stderr"),
231                feed,
232            )?;
233            Ok(ScriptOutcome {
234                output: Output {
235                    status,
236                    stdout,
237                    stderr,
238                },
239                stdin_error,
240            })
241        };
242
243        match tokio::time::timeout(self.timeout, collect).await {
244            Ok(result) => result,
245            // The child is killed here rather than left running: `kill_on_drop`
246            // is set above and `child` is dropped as this future is. That covers
247            // the `OutputTooLarge` arm too, where the script is very likely
248            // still writing into a pipe this side has stopped reading.
249            //
250            // Only the child: a process the script started itself (a `sleep`
251            // under `sh`, say) is not in reach of `kill_on_drop` and finishes
252            // on its own. A script that forks long-lived work should `exec` it
253            // or reap it.
254            Err(_) => Err(ScriptError::Timeout(self.timeout)),
255        }
256    }
257
258    /// A one-line reason a script's non-zero exit should be reported as.
259    ///
260    /// First non-empty line of stdout, else of stderr, else the exit status —
261    /// prefixed with the stdin failure when there was one, since a script that
262    /// never received its payload failed for a completely different reason than
263    /// one that read it and objected.
264    pub fn detail(outcome: &ScriptOutcome, noun: &str) -> String {
265        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
266        let stderr = String::from_utf8_lossy(&outcome.output.stderr);
267        let first_line = stdout
268            .lines()
269            .find(|line| !line.trim().is_empty())
270            .or_else(|| stderr.lines().find(|line| !line.trim().is_empty()))
271            .unwrap_or("")
272            .trim();
273
274        let base = if first_line.is_empty() {
275            format!("{noun} exited with status {}", outcome.output.status)
276        } else {
277            first_line.to_string()
278        };
279
280        match &outcome.stdin_error {
281            Some(error) => format!("{base} (the script did not read its input: {error})"),
282            None => base,
283        }
284    }
285}
286
287/// Reads one of the child's pipes to EOF, refusing it past
288/// [`MAX_SCRIPT_OUTPUT_BYTES`].
289///
290/// `take(limit + 1)` rather than `take(limit)` is what makes "exactly at the
291/// limit" distinguishable from "over it": a reader capped at the limit hands
292/// back a full buffer in both cases and cannot tell whether more was waiting.
293///
294/// `None` — a pipe already taken, which cannot happen from [`ScriptHook::run`]
295/// since both are `Stdio::piped()` — reads as empty rather than as an error,
296/// matching what `wait_with_output` does with an absent pipe.
297async fn read_capped(
298    pipe: Option<impl tokio::io::AsyncRead + Unpin>,
299    stream: &'static str,
300) -> Result<Vec<u8>, ScriptError> {
301    use tokio::io::AsyncReadExt;
302
303    let Some(pipe) = pipe else {
304        return Ok(Vec::new());
305    };
306
307    let limit = MAX_SCRIPT_OUTPUT_BYTES;
308    let mut buffer = Vec::new();
309    pipe.take(limit as u64 + 1)
310        .read_to_end(&mut buffer)
311        .await
312        .map_err(|error| ScriptError::Wait(error.to_string()))?;
313
314    if buffer.len() > limit {
315        return Err(ScriptError::OutputTooLarge { limit, stream });
316    }
317    Ok(buffer)
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::testutil::{TempDir, write_script};
324
325    fn hook(path: &Path, timeout_ms: u64) -> ScriptHook {
326        ScriptHook::new(&path.display().to_string(), &[], timeout_ms).unwrap()
327    }
328
329    #[test]
330    fn a_blank_script_path_builds_no_hook() {
331        assert!(ScriptHook::new("", &[], 1000).is_none());
332        assert!(ScriptHook::new("   ", &[], 1000).is_none());
333        assert!(ScriptHook::new("/bin/true", &[], 1000).is_some());
334    }
335
336    #[tokio::test]
337    async fn a_missing_script_is_a_spawn_error() {
338        let hook = ScriptHook::new("/nonexistent/script", &[], 1000).unwrap();
339        let error = hook.run(&[], ScriptStdin::Null).await.unwrap_err();
340        assert!(matches!(error, ScriptError::Spawn { .. }), "got {error:?}");
341        assert!(error.to_string().contains("/nonexistent/script"));
342    }
343
344    /// The hardening this module exists to hold in one place: the script must
345    /// not inherit the server's environment, which carries the RFC 2136 TSIG
346    /// key and the SMTP password among other things.
347    #[tokio::test]
348    async fn the_script_does_not_inherit_the_server_environment() {
349        let dir = TempDir::new("script-hook");
350        let script = write_script(
351            &dir,
352            "env.sh",
353            "#!/bin/sh\necho \"MANIFEST=${CARGO_MANIFEST_DIR:-unset}\"\necho \"GIVEN=${ACME_TEST_VAR:-unset}\"\nexit 0\n",
354        );
355
356        let outcome = hook(&script, 5_000)
357            .run(&[("ACME_TEST_VAR", "provided")], ScriptStdin::Null)
358            .await
359            .unwrap();
360        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
361
362        assert!(
363            stdout.contains("MANIFEST=unset"),
364            "the server's own environment must not leak: {stdout}"
365        );
366        assert!(
367            stdout.contains("GIVEN=provided"),
368            "the documented variables must be passed: {stdout}"
369        );
370    }
371
372    #[tokio::test]
373    async fn the_script_receives_its_json_payload_on_stdin() {
374        let dir = TempDir::new("script-hook");
375        let script = write_script(&dir, "cat.sh", "#!/bin/sh\ncat\nexit 0\n");
376
377        let payload = serde_json::json!({ "hook": "issue", "order_id": "abc" });
378        let outcome = hook(&script, 5_000)
379            .run(&[], ScriptStdin::Json(&payload))
380            .await
381            .unwrap();
382
383        let stdout = String::from_utf8_lossy(&outcome.output.stdout);
384        assert!(stdout.contains("\"order_id\":\"abc\""), "{stdout}");
385        assert!(outcome.stdin_error.is_none());
386    }
387
388    /// A script that exits without reading stdin is not a failure — the `crl`
389    /// hook's payload is `{"hook":"crl"}` and ignoring it is reasonable.
390    #[tokio::test]
391    async fn a_script_that_ignores_its_stdin_still_succeeds() {
392        let dir = TempDir::new("script-hook");
393        // Large enough that the write cannot all fit in the pipe buffer, so the
394        // failure is actually observable rather than silently absorbed.
395        let script = write_script(&dir, "ignore.sh", "#!/bin/sh\nexit 0\n");
396
397        let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
398        let outcome = hook(&script, 5_000)
399            .run(&[], ScriptStdin::Json(&payload))
400            .await
401            .unwrap();
402
403        assert!(outcome.output.status.success());
404    }
405
406    /// The payload write is under the timeout too. It used to run before the
407    /// timeout started, so a script that never read its stdin held a payload
408    /// larger than the pipe buffer in `write_all` until the script exited on
409    /// its own — here five seconds, against a 300 ms deadline.
410    #[tokio::test]
411    async fn a_script_that_never_reads_a_large_payload_still_times_out() {
412        let dir = TempDir::new("script-hook");
413        let script = write_script(
414            &dir,
415            "deaf.sh",
416            "#!/bin/sh
417exec sleep 5
418",
419        );
420
421        let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
422        let started = std::time::Instant::now();
423        let error = hook(&script, 300)
424            .run(&[], ScriptStdin::Json(&payload))
425            .await
426            .unwrap_err();
427
428        assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
429        assert!(
430            started.elapsed() < Duration::from_secs(3),
431            "the deadline did not bound the write: {:?}",
432            started.elapsed()
433        );
434    }
435
436    #[tokio::test]
437    async fn a_timed_out_script_is_killed_rather_than_left_running() {
438        let dir = TempDir::new("script-hook");
439        let marker = dir.path().join("still-running");
440        let script = write_script(
441            &dir,
442            "slow.sh",
443            &format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
444        );
445
446        let error = hook(&script, 100)
447            .run(&[], ScriptStdin::Null)
448            .await
449            .unwrap_err();
450        assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
451
452        // `kill_on_drop` must have taken the child with the abandoned future;
453        // without it the script would go on to create this file.
454        tokio::time::sleep(Duration::from_millis(1_500)).await;
455        assert!(
456            !marker.exists(),
457            "the script outlived its deadline and kept running"
458        );
459    }
460
461    #[tokio::test]
462    async fn detail_prefers_stdout_then_stderr_then_the_status() {
463        let dir = TempDir::new("script-hook");
464
465        let both = write_script(
466            &dir,
467            "both.sh",
468            "#!/bin/sh\necho 'from stdout'\necho 'from stderr' >&2\nexit 1\n",
469        );
470        let outcome = hook(&both, 5_000)
471            .run(&[], ScriptStdin::Null)
472            .await
473            .unwrap();
474        assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stdout");
475
476        let stderr_only = write_script(
477            &dir,
478            "stderr.sh",
479            "#!/bin/sh\necho 'from stderr' >&2\nexit 1\n",
480        );
481        let outcome = hook(&stderr_only, 5_000)
482            .run(&[], ScriptStdin::Null)
483            .await
484            .unwrap();
485        assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stderr");
486
487        let silent = write_script(&dir, "silent.sh", "#!/bin/sh\nexit 3\n");
488        let outcome = hook(&silent, 5_000)
489            .run(&[], ScriptStdin::Null)
490            .await
491            .unwrap();
492        let detail = ScriptHook::detail(&outcome, "test script");
493        assert!(
494            detail.starts_with("test script exited with status"),
495            "{detail}"
496        );
497    }
498
499    #[tokio::test]
500    async fn detail_says_when_the_script_never_read_its_input() {
501        let outcome = ScriptOutcome {
502            output: std::process::Output {
503                status: Default::default(),
504                stdout: b"bad CSR\n".to_vec(),
505                stderr: Vec::new(),
506            },
507            stdin_error: Some("Broken pipe (os error 32)".to_string()),
508        };
509        let detail = ScriptHook::detail(&outcome, "custom signer script");
510        assert!(detail.contains("bad CSR"), "{detail}");
511        assert!(
512            detail.contains("did not read its input"),
513            "a script that never saw the CSR must not read as one that rejected it: {detail}"
514        );
515    }
516
517    #[tokio::test]
518    async fn the_configured_arguments_are_passed() {
519        let dir = TempDir::new("script-hook");
520        let script = write_script(&dir, "args.sh", "#!/bin/sh\necho \"$1|$2\"\nexit 0\n");
521
522        let hook = ScriptHook::new(
523            &script.display().to_string(),
524            &["first".to_string(), "second".to_string()],
525            5_000,
526        )
527        .unwrap();
528        let outcome = hook.run(&[], ScriptStdin::Null).await.unwrap();
529        assert_eq!(
530            String::from_utf8_lossy(&outcome.output.stdout).trim(),
531            "first|second"
532        );
533    }
534
535    // ------------------------------------------------------- the output cap
536
537    /// A script that floods a stream is refused rather than buffered whole.
538    ///
539    /// Both streams, because they are read by two separate futures and a cap
540    /// applied to only one of them is exactly the shape this would regress into.
541    /// The generous timeout is deliberate: it must be the *size* that refuses
542    /// this, not the clock, or the test would pass against no cap at all.
543    #[tokio::test]
544    async fn a_script_that_floods_a_stream_is_refused_rather_than_buffered() {
545        let dir = TempDir::new("script-hook");
546        // `yes` is a tight loop with no sleep in it, so this reaches the cap in
547        // well under the timeout on any machine that can run the suite.
548        let cases = [
549            ("stdout", "#!/bin/sh\nyes 0123456789abcdef\n"),
550            ("stderr", "#!/bin/sh\nyes 0123456789abcdef >&2\n"),
551        ];
552
553        for (stream, body) in cases {
554            let script = write_script(&dir, &format!("flood-{stream}.sh"), body);
555            let error = hook(&script, 30_000)
556                .run(&[], ScriptStdin::Null)
557                .await
558                .unwrap_err();
559
560            match error {
561                ScriptError::OutputTooLarge { limit, stream: got } => {
562                    assert_eq!(limit, MAX_SCRIPT_OUTPUT_BYTES);
563                    assert_eq!(got, stream, "the wrong stream was named");
564                }
565                other => panic!("expected OutputTooLarge for {stream}, got {other:?}"),
566            }
567        }
568    }
569
570    /// The other side of the boundary, and the one that decides whether the cap
571    /// is usable: output an honest script produces must still arrive whole.
572    ///
573    /// A quarter of the ceiling is far more than any real hook writes — the
574    /// largest is a signer's PEM chain — so a cap that started truncating
575    /// legitimate answers would show up here.
576    #[tokio::test]
577    async fn output_under_the_cap_arrives_intact() {
578        let dir = TempDir::new("script-hook");
579        let count = MAX_SCRIPT_OUTPUT_BYTES / 4 / 16;
580        let script = write_script(
581            &dir,
582            "bulk.sh",
583            &format!("#!/bin/sh\nyes 0123456789abcde | head -n {count}\nexit 0\n"),
584        );
585
586        let outcome = hook(&script, 30_000).run(&[], ScriptStdin::Null).await;
587        let outcome = outcome.expect("output under the cap must not be refused");
588
589        assert!(outcome.output.status.success());
590        // 15 payload bytes plus a newline, per line.
591        assert_eq!(outcome.output.stdout.len(), count * 16);
592        assert!(outcome.output.stderr.is_empty());
593    }
594
595    /// The pipes are read *while* the child runs, not after it exits.
596    ///
597    /// This is what `wait_with_output` did for free and what taking the pipes
598    /// out by hand can quietly lose: a script writing more than one pipe buffer
599    /// (64 KiB on Linux) blocks in `write` until somebody drains it, so a
600    /// `wait()` that ran to completion first would deadlock here until the
601    /// timeout — and report `Timeout`, not this output.
602    #[tokio::test]
603    async fn a_script_writing_more_than_one_pipe_buffer_does_not_deadlock() {
604        let dir = TempDir::new("script-hook");
605        // 512 KiB, comfortably past any platform's pipe buffer and comfortably
606        // under the cap.
607        let count = 32_768;
608        let script = write_script(
609            &dir,
610            "chatty.sh",
611            &format!("#!/bin/sh\nyes 0123456789abcde | head -n {count}\nexit 0\n"),
612        );
613
614        let outcome = hook(&script, 10_000)
615            .run(&[], ScriptStdin::Null)
616            .await
617            .expect("a script filling the pipe buffer must not time out");
618        assert_eq!(outcome.output.stdout.len(), count * 16);
619    }
620}