Skip to main content

aion_worker/shell/
action.rs

1//! A declared command executed as an activity.
2//!
3//! [`ShellAction`] turns a command declared in an `.awl` worker block into a
4//! runnable activity, so an action can do real work without a hand-written
5//! Rust crate. The command is parsed once at construction and executed as a
6//! direct `execve` with no shell interposed — see [`super::template`] for why
7//! that is the security property rather than a convenience.
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12use tokio::process::Command;
13
14use super::template::{CommandTemplate, SubstitutionError, TemplateError};
15use crate::activity::ActivityFailure;
16use crate::command_transcript::CommandTranscript;
17use crate::context::ActivityContext;
18use crate::process::{CancellableCommandOutput, ProcessGroupError, run_cancellable_command};
19
20/// What a declared action produces when its command succeeds.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
22pub struct ShellOutcome {
23    /// The command's exit status code.
24    pub exit_code: i32,
25    /// Everything the command wrote to standard output, trailing newline
26    /// trimmed so a one-line result is usable directly.
27    pub stdout: String,
28    /// Everything the command wrote to standard error, trailing newline
29    /// trimmed. Captured even on success, because a command that succeeds
30    /// while warning is a thing an operator needs to see.
31    pub stderr: String,
32}
33
34/// How a declared action treats a non-zero exit.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum FailureMode {
37    /// A non-zero exit is retryable: the engine may run the command again
38    /// under the action's retry policy. This is the default because the
39    /// common causes of a failing command — a busy resource, an unreachable
40    /// host, a transient permission state — are the ones that succeed on a
41    /// second attempt.
42    #[default]
43    Retryable,
44    /// A non-zero exit is terminal: the command will never be retried. Use
45    /// when re-running the command could not possibly change the outcome, or
46    /// when running it twice would itself be harmful.
47    Terminal,
48}
49
50/// The one environment variable a declared command inherits from its host.
51///
52/// A declared command runs with the host's environment CLEARED. That is a
53/// security boundary, not tidiness: the host process legitimately holds
54/// credentials — store URLs, signing keys, cloud tokens — and a deployed
55/// package must never be able to read them by declaring `run "env"`. Nothing
56/// crosses unless an operator names it.
57///
58/// `PATH` is the sole exception, and only because resolving a bare program
59/// name requires it: `run "echo hi"` must find `echo`. Its value is the
60/// host's, unmodified. An action that will not tolerate even that names its
61/// program by absolute path and passes an environment that overrides `PATH`
62/// to whatever it wants.
63const INHERITED_VARIABLE: &str = "PATH";
64
65/// A declared command, ready to run as an activity.
66#[derive(Debug, Clone)]
67pub struct ShellAction {
68    template: CommandTemplate,
69    failure_mode: FailureMode,
70    environment: BTreeMap<String, String>,
71    working_directory: Option<std::path::PathBuf>,
72}
73
74impl ShellAction {
75    /// Parse `command` into a runnable action.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`TemplateError`] when the command is empty, has an unterminated
80    /// quote or `${`, contains a `$` naming no parameter, or puts a parameter
81    /// in program position.
82    pub fn new(command: &str) -> Result<Self, TemplateError> {
83        Ok(Self {
84            template: CommandTemplate::parse(command)?,
85            failure_mode: FailureMode::default(),
86            environment: BTreeMap::new(),
87            working_directory: None,
88        })
89    }
90
91    /// Set how a non-zero exit is classified.
92    #[must_use]
93    pub const fn with_failure_mode(mut self, failure_mode: FailureMode) -> Self {
94        self.failure_mode = failure_mode;
95        self
96    }
97
98    /// Supply the environment the command runs with.
99    ///
100    /// Additive to nothing: the host's environment is cleared regardless, so
101    /// these entries plus [`INHERITED_VARIABLE`] are the command's whole
102    /// environment. An entry named `PATH` here replaces the inherited one.
103    #[must_use]
104    pub fn with_environment(mut self, environment: BTreeMap<String, String>) -> Self {
105        self.environment = environment;
106        self
107    }
108
109    /// Run the command in `directory` instead of wherever the host happens to
110    /// be.
111    ///
112    /// Worth setting whenever a command names a relative path. Absent, the
113    /// command inherits the host process's working directory, which is a
114    /// property of how the host was launched and not something a package
115    /// author can see — so a relative path in a declared command means
116    /// something the author cannot predict.
117    #[must_use]
118    pub fn with_working_directory(mut self, directory: impl Into<std::path::PathBuf>) -> Self {
119        self.working_directory = Some(directory.into());
120        self
121    }
122
123    /// The parameter names this command references, sorted.
124    ///
125    /// A caller can compare these against an action's declared parameters to
126    /// catch a reference to a parameter that does not exist.
127    #[must_use]
128    pub fn referenced_parameters(&self) -> Vec<String> {
129        self.template.referenced_parameters()
130    }
131
132    /// Run the command with `arguments` bound to its parameter references.
133    ///
134    /// Cancellation is tree-wide: the command runs in its own process group,
135    /// and cancelling the activity terminates that whole group rather than
136    /// only the direct child, so a command that spawned children does not
137    /// leave them running.
138    ///
139    /// Output is streamed onto `context`'s transcript seam line by line as the
140    /// command writes it (see [`CommandTranscript`]), so an operator can read
141    /// what a command step is printing mid-run. The returned [`ShellOutcome`]
142    /// is unaffected: it still carries the command's complete stdout and stderr.
143    ///
144    /// # Errors
145    ///
146    /// Returns an [`ActivityFailure`] when a referenced parameter is missing or
147    /// unrepresentable (terminal — re-running cannot supply it), when the
148    /// command cannot be spawned or observed (terminal — the program is absent
149    /// or unrunnable), when the command is cancelled (terminal), or when it
150    /// exits non-zero (classified by the action's [`FailureMode`]).
151    pub async fn run(
152        &self,
153        arguments: &BTreeMap<String, serde_json::Value>,
154        context: &ActivityContext,
155    ) -> Result<ShellOutcome, ActivityFailure> {
156        let argv = self
157            .template
158            .render(arguments)
159            .map_err(|error| substitution_failure(&error))?;
160        let (program, rest) = argv
161            .split_first()
162            // `CommandTemplate::parse` refuses an empty command, so a rendered
163            // argv always has a program. Handled rather than indexed so a
164            // future change to the parser cannot turn this into a panic.
165            .ok_or_else(|| ActivityFailure::terminal("the declared command rendered no program"))?;
166
167        let mut command = Command::new(program);
168        command.args(rest);
169        // stdin is CLOSED, never inherited. A declared command that reads
170        // standard input would otherwise be reading the host process's — a
171        // terminal on an interactive boot, or another activity's leftovers —
172        // and would block the activity forever waiting for a line nobody is
173        // going to type.
174        command.stdin(std::process::Stdio::null());
175        // The host's environment does not cross into a declared command. See
176        // `INHERITED_VARIABLE`: only PATH does, and only so a bare program
177        // name resolves.
178        command.env_clear();
179        if let Some(path) = std::env::var_os(INHERITED_VARIABLE) {
180            command.env(INHERITED_VARIABLE, path);
181        }
182        for (name, value) in &self.environment {
183            command.env(name, value);
184        }
185        if let Some(directory) = &self.working_directory {
186            command.current_dir(directory);
187        }
188
189        let transcript = CommandTranscript::new(context);
190        match run_cancellable_command(command, context.cancelled(), &transcript).await {
191            Ok(CancellableCommandOutput::Completed(output)) => {
192                let outcome = ShellOutcome {
193                    exit_code: output.status.code().unwrap_or(EXIT_CODE_SIGNALLED),
194                    stdout: trim_trailing_newline(&String::from_utf8_lossy(&output.stdout)),
195                    stderr: trim_trailing_newline(&String::from_utf8_lossy(&output.stderr)),
196                };
197                if output.status.success() {
198                    Ok(outcome)
199                } else {
200                    Err(self.exit_failure(program, &outcome))
201                }
202            }
203            Ok(CancellableCommandOutput::Cancelled) => Err(ActivityFailure::terminal(format!(
204                "the declared command `{program}` was cancelled and its process group was terminated"
205            ))),
206            Err(error) => Err(spawn_failure(program, &error)),
207        }
208    }
209
210    /// Build the failure for a command that ran and exited non-zero.
211    fn exit_failure(&self, program: &str, outcome: &ShellOutcome) -> ActivityFailure {
212        // The message carries stderr because a failing command's own words are
213        // the most useful thing an operator can be handed; without it the
214        // failure says only that something exited non-zero.
215        let message = if outcome.stderr.is_empty() {
216            format!(
217                "the declared command `{program}` exited {} with no standard error output",
218                outcome.exit_code
219            )
220        } else {
221            format!(
222                "the declared command `{program}` exited {}: {}",
223                outcome.exit_code, outcome.stderr
224            )
225        };
226        match self.failure_mode {
227            FailureMode::Retryable => ActivityFailure::retryable(message),
228            FailureMode::Terminal => ActivityFailure::terminal(message),
229        }
230    }
231}
232
233/// Exit code reported when a command was ended by a signal and so has none.
234///
235/// Matches the long-standing shell convention of `128 + SIGKILL`, which is the
236/// number an operator reading the result will already recognise.
237const EXIT_CODE_SIGNALLED: i32 = 137;
238
239/// Classify a substitution failure. All are terminal: the same call with the
240/// same arguments would fail identically however many times it is retried.
241fn substitution_failure(error: &SubstitutionError) -> ActivityFailure {
242    ActivityFailure::terminal(error.to_string())
243}
244
245/// Classify a spawn or observation failure.
246fn spawn_failure(program: &str, error: &ProcessGroupError) -> ActivityFailure {
247    // Terminal in every arm: a program that is absent, unrunnable, or whose
248    // containment could not be established will be exactly as absent on the
249    // next attempt, and retrying a command whose termination could NOT be
250    // confirmed risks running it twice concurrently.
251    ActivityFailure::terminal(format!(
252        "the declared command `{program}` could not be run to completion: {error}"
253    ))
254}
255
256/// Trim exactly one trailing newline, and its carriage return if present.
257///
258/// A command's output almost always ends in a newline that belongs to the
259/// terminal rather than to the value, so `echo hello` yields `hello` and not
260/// `hello\n`. Only one is removed: a command that deliberately emits blank
261/// trailing lines keeps them.
262fn trim_trailing_newline(text: &str) -> String {
263    text.strip_suffix('\n')
264        .map_or(text, |trimmed| {
265            trimmed.strip_suffix('\r').unwrap_or(trimmed)
266        })
267        .to_owned()
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{FailureMode, ShellAction, trim_trailing_newline};
273    use crate::activity::Classification;
274    use crate::context::ActivityContext;
275    use aion_core::{ActivityId, RunId, WorkflowId};
276    use std::collections::BTreeMap;
277
278    fn context() -> (ActivityContext, crate::context::ActivityCancellationHandle) {
279        ActivityContext::new(
280            WorkflowId::new_v4(),
281            RunId::new_v4(),
282            ActivityId::from_sequence_position(1),
283            1,
284        )
285    }
286
287    fn arguments(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
288        pairs
289            .iter()
290            .map(|(name, value)| ((*name).to_owned(), value.clone()))
291            .collect()
292    }
293
294    /// What a test returns. Every fallible step is carried rather than
295    /// unwrapped, because the workspace denies panicking accessors in test
296    /// code as firmly as in library code.
297    type TestResult = Result<(), Box<dyn std::error::Error>>;
298
299    #[tokio::test]
300    async fn a_succeeding_command_returns_its_output() -> TestResult {
301        let action = ShellAction::new("echo hello")?;
302        let (context, _handle) = context();
303        let outcome = action.run(&BTreeMap::new(), &context).await?;
304        assert_eq!(outcome.exit_code, 0);
305        assert_eq!(outcome.stdout, "hello");
306        assert_eq!(outcome.stderr, "");
307        Ok(())
308    }
309
310    #[tokio::test]
311    async fn a_parameter_value_reaches_the_program_as_one_argument() -> TestResult {
312        let action = ShellAction::new("echo $greeting")?;
313        let (context, _handle) = context();
314        let outcome = action
315            .run(
316                &arguments(&[("greeting", serde_json::json!("hello there world"))]),
317                &context,
318            )
319            .await?;
320        assert_eq!(outcome.stdout, "hello there world");
321        Ok(())
322    }
323
324    #[tokio::test]
325    async fn a_hostile_value_is_inert_because_no_shell_ever_sees_it() -> TestResult {
326        // If a shell were interposed, this would execute a second command.
327        // Because argv goes straight to execve, it is printed as text.
328        let action = ShellAction::new("echo $value")?;
329        let (context, _handle) = context();
330        let outcome = action
331            .run(
332                &arguments(&[("value", serde_json::json!("hi; echo PWNED"))]),
333                &context,
334            )
335            .await?;
336        assert_eq!(outcome.stdout, "hi; echo PWNED");
337        assert!(
338            !outcome.stdout.contains("PWNED\n"),
339            "the injected command must never have run"
340        );
341        Ok(())
342    }
343
344    #[tokio::test]
345    async fn a_nonzero_exit_is_retryable_by_default_and_carries_stderr() -> TestResult {
346        let action = ShellAction::new("sh -c 'echo trouble >&2; exit 3'")?;
347        let (context, _handle) = context();
348        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
349            return Err("a non-zero exit must fail the activity".into());
350        };
351        assert_eq!(failure.classification(), &Classification::Retryable);
352        assert!(
353            failure.message().contains("trouble"),
354            "the failure must carry the command's own words: {}",
355            failure.message()
356        );
357        assert!(failure.message().contains('3'), "the exit code is reported");
358        Ok(())
359    }
360
361    #[tokio::test]
362    async fn a_nonzero_exit_is_terminal_when_the_action_says_so() -> TestResult {
363        let action = ShellAction::new("sh -c 'exit 1'")?.with_failure_mode(FailureMode::Terminal);
364        let (context, _handle) = context();
365        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
366            return Err("a non-zero exit must fail the activity".into());
367        };
368        assert_eq!(failure.classification(), &Classification::Terminal);
369        Ok(())
370    }
371
372    #[tokio::test]
373    async fn a_missing_parameter_fails_terminally_before_anything_runs() -> TestResult {
374        let action = ShellAction::new("echo $absent")?;
375        let (context, _handle) = context();
376        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
377            return Err("a missing parameter must fail the activity".into());
378        };
379        assert_eq!(failure.classification(), &Classification::Terminal);
380        assert!(failure.message().contains("absent"));
381        Ok(())
382    }
383
384    #[tokio::test]
385    async fn an_absent_program_fails_terminally() -> TestResult {
386        let action = ShellAction::new("aion-no-such-program-exists-anywhere")?;
387        let (context, _handle) = context();
388        let Err(failure) = action.run(&BTreeMap::new(), &context).await else {
389            return Err("an absent program must fail the activity".into());
390        };
391        assert_eq!(failure.classification(), &Classification::Terminal);
392        Ok(())
393    }
394
395    #[tokio::test]
396    async fn cancellation_stops_the_command_and_fails_terminally() -> TestResult {
397        let action = ShellAction::new("sleep 30")?;
398        let (context, handle) = context();
399        let run = tokio::spawn(async move {
400            let (context, _keep) = (context, ());
401            action.run(&BTreeMap::new(), &context).await
402        });
403        // Give the command a moment to actually be running before cancelling,
404        // so this exercises termination rather than a pre-start short circuit.
405        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
406        handle.cancel();
407        let Err(failure) = run.await? else {
408            return Err("a cancelled command must fail the activity".into());
409        };
410        assert_eq!(failure.classification(), &Classification::Terminal);
411        assert!(failure.message().contains("cancelled"));
412        Ok(())
413    }
414
415    #[tokio::test]
416    async fn the_hosts_environment_does_not_cross_into_a_declared_command() -> TestResult {
417        // A host process legitimately holds credentials. A deployed package
418        // must not be able to read them by declaring `run "env"`. This is the
419        // test that catches a regression to inherited environment.
420        //
421        // It reads a variable the host ALREADY has rather than setting one:
422        // `std::env::set_var` is `unsafe` (process-global, racy against every
423        // other thread) and the workspace denies unsafe outright. Any existing
424        // variable other than the deliberately-inherited PATH proves the same
425        // property.
426        let Some(present) = std::env::vars_os()
427            .filter_map(|(name, _)| name.into_string().ok())
428            .find(|name| name != "PATH" && !name.is_empty() && !name.contains('='))
429        else {
430            tracing::info!(
431                "skipping: the host has no environment variable besides PATH to prove \
432                 non-inheritance with"
433            );
434            return Ok(());
435        };
436        let action = ShellAction::new(&format!("sh -c 'echo \"[${{{present}:-absent}}]\"'"))?;
437        let (context, _handle) = context();
438        let outcome = action.run(&BTreeMap::new(), &context).await?;
439        assert_eq!(
440            outcome.stdout, "[absent]",
441            "the host's `{present}` leaked into a declared command"
442        );
443        Ok(())
444    }
445
446    #[tokio::test]
447    async fn an_operator_supplied_variable_does_reach_the_command() -> TestResult {
448        // The other half: clearing must not make the environment unusable.
449        let mut environment = BTreeMap::new();
450        environment.insert("DECLARED_GREETING".to_owned(), "supplied".to_owned());
451        let action = ShellAction::new("sh -c 'echo \"[$DECLARED_GREETING]\"'")?
452            .with_environment(environment);
453        let (context, _handle) = context();
454        let outcome = action.run(&BTreeMap::new(), &context).await?;
455        assert_eq!(outcome.stdout, "[supplied]");
456        Ok(())
457    }
458
459    #[tokio::test]
460    async fn a_command_runs_in_its_declared_working_directory() -> TestResult {
461        let action = ShellAction::new("pwd")?.with_working_directory("/");
462        let (context, _handle) = context();
463        let outcome = action.run(&BTreeMap::new(), &context).await?;
464        assert_eq!(outcome.stdout, "/");
465        Ok(())
466    }
467
468    #[tokio::test]
469    async fn a_command_reading_stdin_gets_end_of_file_rather_than_the_hosts() -> TestResult {
470        // Inherited stdin would hang the activity forever on an interactive
471        // boot. Closed stdin makes `cat` finish immediately with nothing.
472        let action = ShellAction::new("cat")?;
473        let (context, _handle) = context();
474        let outcome = action.run(&BTreeMap::new(), &context).await?;
475        assert_eq!(outcome.exit_code, 0);
476        assert_eq!(outcome.stdout, "");
477        Ok(())
478    }
479
480    #[tokio::test]
481    async fn awkward_values_survive_as_exactly_one_argument_each() -> TestResult {
482        // Cally's misparse question, not the injection one: a value that a
483        // careless argv build would split, truncate, or lose entirely. Each is
484        // printed back with a delimiter so a silent split is visible.
485        for (value, expected) in [
486            ("two\nlines", "[two\nlines]"),
487            ("", "[]"),
488            ("   leading and trailing   ", "[   leading and trailing   ]"),
489            ("tab\there", "[tab\there]"),
490            ("quote\"inside", "[quote\"inside]"),
491            ("single'inside", "[single'inside]"),
492            ("back\\slash", "[back\\slash]"),
493        ] {
494            let action = ShellAction::new("printf [%s] $value")?;
495            let (context, _handle) = context();
496            let outcome = action
497                .run(&arguments(&[("value", serde_json::json!(value))]), &context)
498                .await?;
499            assert_eq!(
500                outcome.stdout, expected,
501                "value {value:?} did not arrive as exactly one argument"
502            );
503        }
504        Ok(())
505    }
506
507    #[tokio::test]
508    async fn a_value_that_looks_like_a_flag_is_still_passed_as_a_value() -> TestResult {
509        // A leading `-` is the misparse Apollo's law points at: the ARGV is
510        // correct (one element), but the PROGRAM may read it as an option.
511        // The executor cannot fix that — only the author can, by writing
512        // `--` in the declaration. Both halves are pinned here so the
513        // behaviour is recorded rather than discovered.
514        let exposed = ShellAction::new("printf %s $value")?;
515        let (exposed_context, _exposed_handle) = context();
516        let outcome = exposed
517            .run(
518                &arguments(&[("value", serde_json::json!("-n"))]),
519                &exposed_context,
520            )
521            .await;
522        // `printf %s -n` — the program's own parse decides; the executor
523        // delivered exactly what was declared either way.
524        assert!(
525            outcome.is_ok(),
526            "the executor must deliver the value and let the program parse it"
527        );
528
529        // With `--` the author has told the program where options end, and the
530        // value is unambiguously a value.
531        let guarded = ShellAction::new("printf -- [%s] $value")?;
532        let (guarded_context, _guarded_handle) = context();
533        let guarded_outcome = guarded
534            .run(
535                &arguments(&[("value", serde_json::json!("-n"))]),
536                &guarded_context,
537            )
538            .await?;
539        assert_eq!(guarded_outcome.stdout, "[-n]");
540        Ok(())
541    }
542
543    /// THE JOIN THIS BUILD MAKES, on the real declared-action path: a command
544    /// writing to BOTH streams has those lines on the activity's transcript seam
545    /// while it is still running — the same seam, envelope and stream key an
546    /// agent step publishes on.
547    ///
548    /// The run future is polled concurrently with the reads, so a capture that
549    /// only materialized at exit would resolve the run arm first and fail the
550    /// test by name. The command then sleeps until the test cancels it, which
551    /// also holds the pre-existing cancellation contract under streaming.
552    #[tokio::test]
553    async fn a_declared_command_streams_both_streams_before_it_exits() -> TestResult {
554        use aion_core::{RunId, WorkflowId};
555
556        let (sender, mut events) = tokio::sync::mpsc::unbounded_channel();
557        let (context, cancellation) = ActivityContext::with_transcript(
558            WorkflowId::new_v4(),
559            RunId::new_v4(),
560            ActivityId::from_sequence_position(9),
561            3,
562            sender,
563        );
564        let action = ShellAction::new("sh -c 'echo working; echo warning >&2; sleep 30'")?;
565        let no_arguments = BTreeMap::new();
566        let run = action.run(&no_arguments, &context);
567        tokio::pin!(run);
568
569        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
570        let mut observed: Vec<(String, String)> = Vec::new();
571        while !observed.iter().any(|(role, _)| role.contains("stdout"))
572            || !observed.iter().any(|(role, _)| role.contains("stderr"))
573        {
574            tokio::select! {
575                biased;
576                outcome = &mut run => {
577                    drop(outcome);
578                    return Err(format!(
579                        "the command completed before its output reached the transcript: \
580                         {observed:?}"
581                    )
582                    .into());
583                }
584                event = events.recv() => {
585                    let event = event.ok_or("the transcript seam closed mid-command")?;
586                    assert_eq!(event.attempt, 3, "the event carries the attempt it belongs to");
587                    assert_eq!(
588                        event.activity_id,
589                        ActivityId::from_sequence_position(9),
590                        "the event is keyed to the activity that is running"
591                    );
592                    let aion_core::ActivityEventKind::Message { text, .. } = event.kind else {
593                        return Err("an output line must be a Message".into());
594                    };
595                    observed.push((event.agent_role, text));
596                }
597                () = tokio::time::sleep_until(deadline) => {
598                    return Err(format!(
599                        "the running command's output never reached the transcript: {observed:?}"
600                    )
601                    .into());
602                }
603            }
604        }
605
606        assert!(
607            observed.contains(&("command stdout".to_owned(), "working".to_owned())),
608            "stdout must arrive labelled by its stream: {observed:?}"
609        );
610        assert!(
611            observed.contains(&("command stderr".to_owned(), "warning".to_owned())),
612            "stderr must arrive labelled by its stream: {observed:?}"
613        );
614
615        cancellation.cancel();
616        let Err(failure) = run.await else {
617            return Err("a cancelled command must fail the activity".into());
618        };
619        assert_eq!(failure.classification(), &Classification::Terminal);
620        Ok(())
621    }
622
623    #[test]
624    fn only_one_trailing_newline_is_trimmed() {
625        assert_eq!(trim_trailing_newline("hello\n"), "hello");
626        assert_eq!(trim_trailing_newline("hello\r\n"), "hello");
627        assert_eq!(trim_trailing_newline("hello\n\n"), "hello\n");
628        assert_eq!(trim_trailing_newline("hello"), "hello");
629        assert_eq!(trim_trailing_newline(""), "");
630    }
631}