From 28c5cbf13927666a98c4ec2efecca4ecd4acdd44 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 8 Nov 2026 16:08:46 +0000 Subject: [PATCH] #6005: escape file-derived errors where they become a Failure, not where they print main() prints Failure::Error raw again, so the CLI's own multi-line messages (usage after "missing argument" / "unknown command") keep their line breaks. A new engine_error helper escapes control, bidi and invisible characters in text that comes from the engine or a file, and every such conversion goes through it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01FypgCX8G9hbs6XYZFsgjbL --- apps/wordcraft-cli/src/main.rs | 59 ++++++++++++++++----------- apps/wordcraft-cli/tests/it/strict.rs | 17 ++++++++ 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index c8c869846a..963d275481 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -83,6 +83,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool { SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h") } +/// A failure whose message may carry text from a file or from the engine's reading of one (a +/// picture's part name, a parse error quoting the input): control, bidi and invisible characters +/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks +/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths. +fn engine_error(e: impl fmt::Display) -> Failure { + Failure::Error(escape_text(&e.to_string(), false)) +} + /// The error every subcommand gives when a required path is not there. fn missing_argument() -> Failure { format!("missing argument\n\n{USAGE}").into() @@ -91,11 +99,11 @@ fn missing_argument() -> Failure { /// Open a document for a subcommand. A password-protected Word file needs its password /// ([`password_from`]). As in the app, a document opened with one is saved with it again, so /// `convert` does not quietly write a copy the password no longer protects. -fn open(path: &Path, password: Option<&str>) -> Result { +fn open(path: &Path, password: Option<&str>) -> Result { // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's // paper, as in the app: A4 in Italy, Letter in the US (#1381). let region = system_region(); - let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?; + let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion).map_err(engine_error)?; let mut s = session(doc); s.path = Some(path.into()); if encrypted { @@ -204,7 +212,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result { /// The command also checks whether the open document is protected: this name determines which /// named editing exceptions the session can use, so the flag must not bypass that check. fn set_author(s: &mut Session, name: &str) -> Result<(), String> { - s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}")) + s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false)) } fn arg_value(args: &[String], name: &str) -> Option { @@ -420,7 +428,7 @@ fn valid_connect_addr(addr: &str) -> bool { /// failure. fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option) -> Failure { if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) { - return e.to_string().into(); + return engine_error(e); } let mut params = json!({"markup": markup}); if let Some(page) = page { @@ -434,7 +442,7 @@ fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: O } text + &json_line_safe(&json.to_string()) } - Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {e}"), + Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)), }) } @@ -444,7 +452,7 @@ fn report_lines(json: &Value) -> Vec { } /// `convert `: `rest` is the command line after `convert`, `open` opens the input. -fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result) -> Result<(), Failure> { +fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result) -> Result<(), Failure> { validate_options("convert", rest, CONVERT_OPTIONS)?; // `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path. let format = match find_option_arg(rest, "--format") { @@ -465,7 +473,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result) -> Result) -> Result