diff --git a/src/commands/builtins.rs b/src/commands/builtins.rs index 6b3b188..96221f4 100644 --- a/src/commands/builtins.rs +++ b/src/commands/builtins.rs @@ -180,9 +180,37 @@ pub fn pwd(_args: &[&str]) -> Result<()> { Ok(()) } -pub fn exit(_args: &[&str]) -> Result<()> { +pub fn exit(args: &[&str]) -> Result<()> { + // Reached only from a pipeline (`true | exit`); a plain `exit` step is + // reported by the REPL/`-c` layer so the shell can unwind cleanly. + let status = exit_status(args, 0); registry::print_colored("Exiting Ash...", "warning"); - std::process::exit(0); + std::process::exit(status); +} + +// The status `exit`/`quit` ends the shell with, given the arguments after the +// command name and the status of the last command that ran. POSIX: no +// argument keeps that last status, `exit n` exits with n — only the low eight +// bits reach the caller, so `exit 300` is `exit 44`. A non-numeric argument is +// a usage error, reported with status 2. +// +// The code has to reach the process exit status: Cockpit checks whether a +// login shell works by running ` -c "exit 71;"` and accepting only 71, +// otherwise it refuses the login as an "unsupported shell". +pub fn exit_status(args: &[&str], last_status: i32) -> i32 { + let Some(arg) = args.first() else { + return last_status; + }; + match arg.parse::() { + Ok(code) => code.rem_euclid(256), + Err(_) => { + registry::print_colored( + &format!("ash: exit: {arg}: numeric argument required"), + "error", + ); + 2 + } + } } pub fn clear(_args: &[&str]) -> Result<()> { diff --git a/src/main.rs b/src/main.rs index b8ea8e3..16abb00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -427,11 +427,12 @@ fn main() -> Result<()> { } if let Some(cmd) = shell_args.command { - // `-c` exits with the command's status, like other shells. exit() - // skips destructors, so flush first in case output is still buffered. - let status = run_command_line(&cmd)?.unwrap_or(0); + // `-c` exits with the command's status, like other shells — including + // a status given to `exit`, which Cockpit's login probe depends on. + // exit() skips destructors, so flush first in case output is buffered. + let outcome = run_command_line(&cmd)?; std::io::stdout().flush().ok(); - std::process::exit(status); + std::process::exit(outcome.status); } registry::print_colored("Type help for commands.", "success"); @@ -549,8 +550,11 @@ fn main() -> Result<()> { continue; } - if run_command_line(trimmed)?.is_none() { - break; // exit/quit + let outcome = run_command_line(trimmed)?; + if outcome.exiting { + // exit/quit: hand back the requested status, like other shells. + std::io::stdout().flush().ok(); + std::process::exit(outcome.status); } } @@ -620,12 +624,36 @@ fn split_at_cursor(text: String, pos: usize) -> (String, String) { (left, right) } +// What running one command line did: the status it finished with, and whether +// it ended the session with `exit`/`quit`. `-c` exits with the status either +// way; the REPL only needs to know when to stop reading lines. +struct RunOutcome { + status: i32, + exiting: bool, +} + +impl RunOutcome { + fn finished(status: i32) -> Self { + RunOutcome { + status, + exiting: false, + } + } + + fn exiting(status: i32) -> Self { + RunOutcome { + status, + exiting: true, + } + } +} + // Run one command line: pipelines joined by `&&`, `||` and `;`, with each // stage's redirections applied. Heredoc bodies are read from the lines that // follow the command, and anything after the last delimiter runs in turn. -// Returns Ok(None) when the line was exit/quit, otherwise the status of the -// last command that ran. -fn run_command_line(line: &str) -> Result> { +// Returns Ok with `exiting` set when the line was exit/quit, otherwise the +// status of the last command that ran. +fn run_command_line(line: &str) -> Result { let mut text = line.to_string(); loop { let (command_line, bodies, leftover) = match split_heredocs(&text) { @@ -635,31 +663,31 @@ fn run_command_line(line: &str) -> Result> { &format!("ash: syntax error: unterminated heredoc '{delimiter}'"), "error", ); - return Ok(Some(2)); + return Ok(RunOutcome::finished(2)); } Err(HereDocError::Syntax(message)) => { registry::print_colored(&message, "error"); - return Ok(Some(2)); + return Ok(RunOutcome::finished(2)); } }; - let status = match run_single_command_line(&command_line, bodies)? { - Some(code) => code, - None => return Ok(None), // exit/quit - }; + let outcome = run_single_command_line(&command_line, bodies)?; + if outcome.exiting { + return Ok(outcome); // exit/quit + } match leftover { Some(rest) if !rest.trim().is_empty() => text = rest, - _ => return Ok(Some(status)), + _ => return Ok(outcome), } } } -fn run_single_command_line(line: &str, bodies: Vec) -> Result> { +fn run_single_command_line(line: &str, bodies: Vec) -> Result { let trimmed = line.trim(); if trimmed.is_empty() { // Blank lines are no-ops. `scan` ends a `#` comment at its own // newline, so a multi-line block that starts with a comment still // runs the commands after it. - return Ok(Some(0)); + return Ok(RunOutcome::finished(0)); } // Expand aliases on the raw line so that operators (`&&`, `||`, `|`, @@ -675,11 +703,11 @@ fn run_single_command_line(line: &str, bodies: Vec) -> Result steps, Err(message) => { registry::print_colored(&message, "error"); - return Ok(Some(2)); + return Ok(RunOutcome::finished(2)); } }; if steps.is_empty() { - return Ok(Some(0)); + return Ok(RunOutcome::finished(0)); } // Run the steps left to right: `&&` needs the previous status to be 0, @@ -691,20 +719,23 @@ fn run_single_command_line(line: &str, bodies: Vec) -> Result = stage.words[1..].iter().map(String::as_str).collect(); + let status = builtins::exit_status(&args, status); registry::print_colored("Exiting Ash...", "warning"); - return Ok(None); + return Ok(RunOutcome::exiting(status)); } status = run_pipeline(&step.stages)?; } - Ok(Some(status)) + Ok(RunOutcome::finished(status)) } // Whether the step after `previous` runs, given the status before it. @@ -2307,6 +2338,44 @@ mod tests { assert!(step_runs_after(&with_join(None), 1)); } + // Cockpit decides whether a login shell is usable by running + // ` -c "exit 71;"` and accepting nothing but status 71 (issue #30). + #[test] + fn exit_carries_its_status_out_of_the_shell() { + let probe = run_command_line("exit 71;").unwrap(); + assert!(probe.exiting); + assert_eq!(probe.status, 71); + + let no_arg = run_command_line("exit;").unwrap(); + assert!(no_arg.exiting); + assert_eq!(no_arg.status, 0); + + let bad = run_command_line("exit nope;").unwrap(); + assert!(bad.exiting); + assert_eq!(bad.status, 2); + + // Only the low eight bits reach the caller, like other shells. + let wrapped = run_command_line("exit 300;").unwrap(); + assert!(wrapped.exiting); + assert_eq!(wrapped.status, 44); + } + + #[test] + fn a_line_without_exit_keeps_the_shell_running() { + let blank = run_command_line("").unwrap(); + assert!(!blank.exiting); + assert_eq!(blank.status, 0); + } + + #[test] + fn exit_status_follows_posix_argument_rules() { + // No argument keeps the status of the last command that ran. + assert_eq!(builtins::exit_status(&[], 7), 7); + assert_eq!(builtins::exit_status(&["71"], 7), 71); + assert_eq!(builtins::exit_status(&["-1"], 0), 255); + assert_eq!(builtins::exit_status(&["nope"], 0), 2); + } + fn hint_for(line: &str, pos: usize, entries: &[&str]) -> Option { let mut history = rustyline::history::MemHistory::new(); for entry in entries {