Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/bashkit-cli/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn is_incomplete_input(err_msg: &str) -> bool {
lower.contains("unterminated")
|| lower.contains("unexpected end of input")
|| lower.contains("unexpected eof")
|| lower.contains("unexpected end of file")
|| lower.contains("syntax error: empty")
|| lower.contains("expected 'fi'")
|| lower.contains("expected 'done'")
Expand Down Expand Up @@ -627,6 +628,14 @@ mod tests {
assert!(is_incomplete_input("syntax error: empty brace group"));
}

#[test]
fn incomplete_unexpected_end_of_file() {
// The parser's bash wording for input that ran out mid-construct.
assert!(is_incomplete_input(
"parse error at line 2, column 1: syntax error: unexpected end of file"
));
}

#[test]
fn incomplete_missing_closing_keyword() {
assert!(is_incomplete_input("expected 'fi'"));
Expand Down
11 changes: 10 additions & 1 deletion crates/bashkit-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,16 @@ fn run_oneshot(args: Args, mode: CliMode) -> Result<i32> {
// reported on stderr and exits 2 (the partial-run case already
// returns exit 2 from the library).
Err(e @ bashkit::Error::Parse { .. }) => {
eprintln!("bash: syntax error: {e}");
// Named like bash: `bash: -c: line 1: ...` or `script.sh: line 1: ...`.
let arg0 = args.script.as_ref().map(|p| p.display().to_string());
let who = match (&args.command, arg0) {
(None, Some(path)) => path,
(_, arg0) => format!("{}: -c", arg0.as_deref().unwrap_or("bash")),
};
let report = e
.syntax_report(&who, &script)
.unwrap_or_else(|| format!("bash: syntax error: {e}\n"));
eprint!("{report}");
return Ok(2);
}
Err(e) => return Err(anyhow::Error::new(e).context(context)),
Expand Down
31 changes: 18 additions & 13 deletions crates/bashkit-cli/tests/cli_oneshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,31 +214,36 @@ fn parse_error_reports_on_stderr_and_exits_nonzero() {
let out = run(&["-c", "if ["]);
// bash exits 2 on a syntax error.
assert_eq!(code(&out), 2);
let err = stderr(&out);
assert!(
err.starts_with("bash: syntax error: parse error"),
"stderr: {err}"
// Worded and numbered like bash: input ran out, so the line after the last.
assert_eq!(
stderr(&out),
"bash: -c: line 2: syntax error: unexpected end of file\n"
);
assert!(err.ends_with('\n'), "stderr: {err:?}");
assert_eq!(stdout(&out), "", "parse errors must not write to stdout");
}

#[test]
fn script_parse_error_exits_2_with_or_without_earlier_commands() {
let dir = tempfile::tempdir().unwrap();
for (body, want_stdout) in [
("[[ abc =~ ( ]]\n", ""),
("echo ok\n[[ abc =~ ( ]]\n", "ok\n"),
for (body, want_stdout, line) in [
("[[ abc =~ ( ]]\n", "", 1),
("echo ok\n[[ abc =~ ( ]]\n", "ok\n", 2),
] {
let script = dir.path().join("bad.sh");
std::fs::write(&script, body).unwrap();
let out = run(&[script.to_str().unwrap()]);
let path = script.to_str().unwrap();
let out = run(&[path]);
assert_eq!(code(&out), 2, "{body:?}");
assert_eq!(stdout(&out), want_stdout, "{body:?}");
assert!(
stderr(&out).starts_with("bash: syntax error: "),
"{body:?}: {}",
stderr(&out)
// bash's two diagnostics, named by the script path.
assert_eq!(
stderr(&out),
format!(
"{path}: line {line}: unexpected EOF while looking for matching `)'\n\
{path}: line {}: unexpected argument to conditional binary operator\n",
line + 1
),
"{body:?}"
);
}
}
Expand Down
63 changes: 63 additions & 0 deletions crates/bashkit/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ impl Error {
}
}

/// bash's report of this syntax error in `source`, as read by `who`
/// (`bash: -c`, a script path): `who: line N: message`, followed by the
/// offending source line for a `near unexpected token` error. `None` for
/// errors other than [`Error::Parse`].
///
/// ```
/// let err = bashkit::parser::Parser::new("if then fi").parse().unwrap_err();
/// assert_eq!(
/// err.syntax_report("bash: -c", "if then fi").unwrap(),
/// "bash: -c: line 1: syntax error near unexpected token `then'\n\
/// bash: -c: line 1: `if then fi'\n"
/// );
/// ```
pub fn syntax_report(&self, who: &str, source: &str) -> Option<String> {
match self {
Self::Parse { message, line, .. } => {
Some(syntax_report(who, source, message, *line, 0))
}
_ => None,
}
}

/// THREAT[TM-INF-016]: Create an I/O error with sanitized message.
/// Strips host-internal paths from the error message to prevent information
/// leakage to the sandbox guest.
Expand Down Expand Up @@ -251,6 +273,47 @@ fn sanitize_error_message(msg: &str) -> String {
result
}

/// See [`Error::syntax_report`]. `shift` moves the reported line number
/// (eval'd text counts from the eval's own line). A message line after the
/// first that reads `line N: text` is a further diagnostic bash prints for
/// the same error, on its own line number.
pub(crate) fn syntax_report(
who: &str,
source: &str,
message: &str,
line: usize,
shift: usize,
) -> String {
let message = if message.starts_with("syntax error") || message.starts_with("unexpected EOF") {
message.to_string()
} else {
format!("syntax error: {message}")
};
if line == 0 {
return format!("{who}: {message}\n");
}
let at = line + shift;
let mut lines = message.lines();
let first = lines.next().unwrap_or_default();
let mut out = format!("{who}: line {at}: {first}\n");
if first.contains("near unexpected token")
&& let Some(text) = source.lines().nth(line - 1)
{
out.push_str(&format!("{who}: line {at}: `{text}'\n"));
}
for more in lines {
let further = more.strip_prefix("line ").and_then(|rest| {
let (n, text) = rest.split_once(": ")?;
Some((n.parse::<usize>().ok()? + shift, text))
});
match further {
Some((n, text)) => out.push_str(&format!("{who}: line {n}: {text}\n")),
None => out.push_str(&format!("{who}: line {at}: {more}\n")),
}
}
out
}

#[cfg(test)]
mod tests {
/// A VFS message that only restates its errno is swapped for the
Expand Down
Loading
Loading