From 0c8399896f0b3abea5892b68a6a8d07d33b657b3 Mon Sep 17 00:00:00 2001 From: Bra1nZ <95569966+Bra1nZ@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:26 +0300 Subject: [PATCH] cli: open a password-protected Word file with --password (#1770) info, text, convert, render and inspect took no password, though file.open understood one, so an agent driving the CLI could not open a protected document at all. --password, --password-file and WORDCRAFT_PASSWORD, in that order. The file form exists because a password on a command line is readable by every other process on the machine; a trailing newline is dropped so a file written by echo works. Never logged, never named in an error. Rebased onto current main, which moved `open` from &str to &Path so that a file name that isn't valid Unicode no longer crashes the CLI (#1440). Both changes touch the same function, so the resolution keeps their shape: the closure that substitutes the real path still stands, and it takes the password as a second argument. Co-authored-by: Bra1nZ Co-authored-by: Brandon Thomas --- apps/wordcraft-cli/src/main.rs | 167 +++++++++++++++++++++++++++------ 1 file changed, 137 insertions(+), 30 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 891aa4ccb..39508a8bf 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -48,6 +48,11 @@ USAGE: wordcraft-cli [] -h | --help this help wordcraft-cli --version +A password-protected Word file is opened with --password PASS, --password-file PATH or the +WORDCRAFT_PASSWORD environment variable; a password given on the command line is readable by +every other process, so the other two are better. A file opened with a password is written with +it again, as the app does. Exports that cannot carry a password (pdf, html, txt) stay plain. + Paper: documents created here, and HTML, Markdown and text files (which give no page size), use the system region's paper (A4, or Letter in the US and other Letter regions), read from LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the @@ -61,13 +66,24 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool { SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h") } -fn open(path: &Path) -> Result { +/// The error every subcommand gives when a required path is not there. +fn missing_argument() -> Failure { + format!("missing argument\n\n{USAGE}").into() +} + +/// 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 { // 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, _) = wordcraft_engine::io::open_path_in(path, None, ®ion)?; + let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?; let mut s = session(doc); s.path = Some(path.into()); + if encrypted { + s.password = password.map(wordcraft_engine::io::Password::new); + } Ok(s) } @@ -98,6 +114,41 @@ fn text_args>(raw: &[S]) -> Result<(Vec, Option Ok((args, file)) } +/// The password for a protected Word file: `--password`, `--password-file`, or +/// `WORDCRAFT_PASSWORD`, in that order. The file form exists because a password on a command line +/// is readable by every other process on the machine; a trailing newline is dropped so a file +/// written by `echo` works. Never logged, and never named in an error. +fn password_from(args: &[String]) -> Result, String> { + if let Some(password) = arg_value(args, "--password") { + return Ok(Some(password)); + } + if let Some(path) = arg_value(args, "--password-file") { + let raw = std::fs::read_to_string(&path).map_err(|e| format!("--password-file {path}: {e}"))?; + return Ok(Some(raw.trim_end_matches(['\r', '\n']).to_string())); + } + Ok(std::env::var("WORDCRAFT_PASSWORD").ok().filter(|p| !p.is_empty())) +} + +/// The positional arguments, skipping every option's value so an option value is never read as a +/// path. Mirrors [`validate_options`], which has already checked the options are known. +fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> { + let mut out = Vec::new(); + let mut i = 0; + while let Some(arg) = args.get(i) { + let takes_value = + arg.starts_with("--") && specs.iter().any(|s| s.name == arg && s.takes_value) && args.get(i + 1).is_some_and(|v| !v.starts_with("--")); + if takes_value { + i += 2; + } else { + if !arg.starts_with("--") { + out.push(arg); + } + i += 1; + } + } + out +} + /// A headless session in the system region: every way the CLI starts one gives `file.new` the /// same paper (#1381). fn session(doc: wordcraft_doc::Document) -> Session { @@ -223,15 +274,27 @@ fn automation_roots( Ok((roots, left)) } -const NO_OPTIONS: &[OptionSpec] = &[]; +/// The password a protected Word file is opened with: `--password` (which any other process on +/// the machine can read from the command line), `--password-file`, or `WORDCRAFT_PASSWORD`. +const PASSWORD_OPTIONS: &[OptionSpec] = + &[OptionSpec { name: "--password", takes_value: true }, OptionSpec { name: "--password-file", takes_value: true }]; const CONVERT_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }, OptionSpec { name: "--update-fields", takes_value: false }, + OptionSpec { name: "--password", takes_value: true }, + OptionSpec { name: "--password-file", takes_value: true }, +]; +const RENDER_OPTIONS: &[OptionSpec] = &[ + OptionSpec { name: "--page", takes_value: true }, + OptionSpec { name: "--scale", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, + OptionSpec { name: "--password-file", takes_value: true }, ]; -const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }]; const RUN_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--file", takes_value: true }, + OptionSpec { name: "--password", takes_value: true }, + OptionSpec { name: "--password-file", takes_value: true }, OptionSpec { name: "--template", takes_value: true }, OptionSpec { name: "--cmd", takes_value: true }, OptionSpec { name: "--save", takes_value: true }, @@ -298,30 +361,25 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io fn run>(args: &[S]) -> Result<(), Failure> { let (args, file) = text_args(args)?; - // Opens the file by its real name, which its text in `args` may have lost. - let open = |text: &str| open(file.as_deref().unwrap_or(Path::new(text))); + // Opens the file by its real name, which its text in `args` may have lost, and with the + // password of a protected document (`password_from`). + let open = |text: &str, password: Option<&str>| open(file.as_deref().unwrap_or(Path::new(text)), password); let cmd = args.first().map(String::as_str).unwrap_or(""); let rest: Vec = args.iter().skip(1).cloned().collect(); // Before options are checked or files and folders opened. if wants_help(cmd, &rest) { return write_stdout(|o| write!(o, "{USAGE}")); } - let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}")); match cmd { "convert" => { validate_options("convert", &rest, CONVERT_OPTIONS)?; // `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path. let format = arg_value(&rest, "--format"); - let paths: Vec<&String> = rest - .iter() - .enumerate() - .filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format"))) - .map(|(_, a)| a) - .collect(); - let missing = || format!("missing argument\n\n{USAGE}"); - let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string()); + let paths = positionals(&rest, CONVERT_OPTIONS); + let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string()); let markup = rest.iter().any(|a| a == "--markup"); - let mut s = open(&i)?; + let password = password_from(&rest)?; + let mut s = open(&i, password.as_deref())?; if rest.iter().any(|a| a == "--update-fields") { s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?; } @@ -339,29 +397,40 @@ fn run>(args: &[S]) -> Result<(), Failure> { Ok(()) } "info" => { - validate_options("info", &rest, NO_OPTIONS)?; - let mut s = open(&pos(0)?)?; + validate_options("info", &rest, PASSWORD_OPTIONS)?; + let paths = positionals(&rest, PASSWORD_OPTIONS); + let password = password_from(&rest)?; + let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?; let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default(); write_stdout(|o| writeln!(o, "{info}")) } "text" => { - validate_options("text", &rest, NO_OPTIONS)?; - let s = open(&pos(0)?)?; + validate_options("text", &rest, PASSWORD_OPTIONS)?; + let paths = positionals(&rest, PASSWORD_OPTIONS); + let password = password_from(&rest)?; + let s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?; let text = listed_text(&s.doc)?; write_stdout(|o| writeln!(o, "{text}")) } "inspect" => { - validate_options("inspect", &rest, NO_OPTIONS)?; - let mut s = open(&pos(0)?)?; + validate_options("inspect", &rest, PASSWORD_OPTIONS)?; + let paths = positionals(&rest, PASSWORD_OPTIONS); + let password = password_from(&rest)?; + let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?; let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default(); write_stdout(|o| writeln!(o, "{tree}")) } "render" => { validate_options("render", &rest, RENDER_OPTIONS)?; - let (i, o) = (pos(0)?, pos(1)?); + let paths = positionals(&rest, RENDER_OPTIONS); + let password = password_from(&rest)?; + let (Some(i), Some(o)) = (paths.first(), paths.get(1)) else { + return Err(missing_argument()); + }; + let (i, o) = (i.to_string(), o.to_string()); let page = parse_page_arg("render", &rest)?.unwrap_or(1); let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0); - let mut s = open(&i)?; + let mut s = open(&i, password.as_deref())?; let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?; write_stdout(|o| writeln!(o, "{r}")) } @@ -369,8 +438,9 @@ fn run>(args: &[S]) -> Result<(), Failure> { validate_options("run", &rest, RUN_OPTIONS)?; let page = parse_page_arg("run", &rest)?; let scale = parse_scale_arg("run", &rest)?; + let password = password_from(&rest)?; let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) { - (Some(f), _) => open(&f)?, + (Some(f), _) => open(&f, password.as_deref())?, (None, Some(t)) => { let mut s = session(wordcraft_doc::Document::new()); s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?; @@ -486,10 +556,12 @@ fn run>(args: &[S]) -> Result<(), Failure> { } "zotero" => { validate_options("zotero", &rest, ZOTERO_OPTIONS)?; - let name = pos(0)?; - let command = wordcraft_zotero::Command::from_name(&name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?; - let file = pos(1)?; - let mut s = open(&file)?; + let paths = positionals(&rest, ZOTERO_OPTIONS); + let (Some(name), Some(file)) = (paths.first(), paths.get(1)) else { + return Err(missing_argument()); + }; + let command = wordcraft_zotero::Command::from_name(name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?; + let mut s = open(file, None)?; for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) { let (id, params) = match spec.split_once('=') { Some((id, p)) => (id.to_string(), serde_json::from_str::(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?), @@ -584,7 +656,7 @@ mod tests { #[test] fn validators_accept_only_the_documented_options() { - assert!(validate_options("info", &args(&["file.docx"]), NO_OPTIONS).is_ok()); + assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok()); assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok()); assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok()); assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok()); @@ -601,6 +673,41 @@ mod tests { ); } + #[test] + fn an_option_value_is_never_read_as_a_path() { + // `--password`'s value sits between the paths and would be taken for one without + // `positionals`, so `render` would write its picture over a file called after the password. + assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]); + assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]); + // A bare flag takes no value, so the next argument stays a path. + assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]); + // `convert` is the one with a value-taking flag between the two paths. + assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]); + } + + #[test] + fn the_password_comes_from_an_option_a_file_or_the_environment() { + assert_eq!(password_from(&args(&["f.docx"])), Ok(None)); + assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into()))); + // `--password` wins over the other two sources. + assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into()))); + } + + #[test] + fn a_password_file_loses_only_its_trailing_newline() { + let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt"); + std::fs::write(&path, "s3cret\r\n").unwrap(); + let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()])); + std::fs::remove_file(&path).ok(); + assert_eq!(got, Ok(Some("s3cret".into()))); + } + + #[test] + fn a_missing_password_file_is_named_without_the_password() { + let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err(); + assert!(err.contains("--password-file"), "{err}"); + } + #[test] fn unknown_options_are_rejected_before_file_access() { assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));