diff --git a/Cargo.lock b/Cargo.lock index 541e928e..4b7ea9eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,8 +97,9 @@ dependencies = [ "flate2", "insta", "log", + "office-crypto", "pdf-inspector", - "quick-xml", + "quick-xml 0.41.0", "sha2 0.11.0", "zip", ] @@ -139,6 +140,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -151,6 +158,18 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -184,6 +203,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "cbc" version = "0.1.2" @@ -425,6 +450,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "digest" version = "0.10.7" @@ -544,6 +580,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.33" @@ -1000,6 +1042,27 @@ dependencies = [ "autocfg", ] +[[package]] +name = "office-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87c499d4091b8d0d311968583456b45fb127bc30f75ed0170727e6440b8a091" +dependencies = [ + "aes", + "base64", + "bytemuck", + "cbc", + "derivative", + "ecb", + "md-5", + "packed_struct", + "quick-xml 0.38.4", + "rc4", + "sha1", + "sha2 0.10.9", + "thiserror", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1012,6 +1075,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "packed_struct" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36b29691432cc9eff8b282278473b63df73bea49bc3ec5e67f31a3ae9c3ec190" +dependencies = [ + "bitvec", + "packed_struct_codegen", + "serde", +] + +[[package]] +name = "packed_struct_codegen" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd6706dfe50d53e0f6aa09e12c034c44faacd23e966ae5a209e8bdb8f179f98" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pdf-inspector" version = "1.14.2" @@ -1123,6 +1208,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -1147,6 +1241,12 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.10.2" @@ -1190,6 +1290,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc4" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1256e23efe6097f27aa82d6ca6889361c001586ae0f6917cbad072f05eb275" +dependencies = [ + "cipher", +] + [[package]] name = "regex" version = "1.13.1" @@ -1307,6 +1416,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1364,6 +1484,17 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -1386,6 +1517,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -1672,6 +1809,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "zip" version = "8.6.0" diff --git a/Cargo.toml b/Cargo.toml index 5cee4785..90eb1de1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ sha2 = "0.11" [dependencies] cfb = "0.14.0" +office-crypto = "0.3.0" csv = "1.4.0" flate2 = "1" encoding_rs = "0.8.35" diff --git a/examples/convert.rs b/examples/convert.rs index 5277e469..d1d36642 100644 --- a/examples/convert.rs +++ b/examples/convert.rs @@ -6,7 +6,9 @@ use std::process::ExitCode; use anydoc::{ConvertError, Format}; -const USAGE: &str = "usage: convert [-f csv] [-o out.md] [--assets dir]"; +const USAGE: &str = "usage: convert [-f csv] [-o out.md] [--assets dir] [-p PASSWORD]"; +/// Env fallback for `-p/--password`: argv leaks into shell history and `ps`. +const PASSWORD_ENV: &str = "ANYDOC_PASSWORD"; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -14,6 +16,7 @@ fn main() -> ExitCode { let mut output: Option = None; let mut format: Option = None; let mut assets: Option = None; + let mut password: Option = None; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -34,6 +37,16 @@ fn main() -> ExitCode { i += 1; assets = args.get(i).map(PathBuf::from); } + "-p" | "--password" => { + i += 1; + match args.get(i) { + Some(pw) => password = Some(pw.clone()), + None => { + eprintln!("error: --password requires a value"); + return ExitCode::FAILURE; + } + } + } other => input = Some(PathBuf::from(other)), } i += 1; @@ -43,7 +56,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; }; - match run(&input, output.as_deref(), format, assets.as_deref()) { + match run(&input, output.as_deref(), format, assets.as_deref(), password) { Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("error: {e:#}"); @@ -57,12 +70,27 @@ fn run( output: Option<&Path>, format: Option, assets: Option<&Path>, + password: Option, ) -> Result<(), ConvertError> { - let bytes = std::fs::read(input)?; - // Without -f the format comes from the file content, with the extension as - // the fallback. + let password = password.or_else(|| std::env::var(PASSWORD_ENV).ok()); + let raw = std::fs::read(input)?; + + // Decrypt before detection: an encrypted OOXML container has no + // recognizable signature until after decryption, mirroring the library's + // to_markdown_with_password semantics (#130 review). + let decrypted; + let bytes: &[u8] = match password.as_deref() { + Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&raw) => { + decrypted = anydoc::decrypt_ooxml(raw.clone(), pw)?; + &decrypted + } + _ => &raw, + }; + + // Without -f the format comes from the (now plaintext) content, with the + // extension as the fallback. let format = - match format.or_else(|| Format::from_bytes(&bytes)).or_else(|| Format::from_path(input)) { + match format.or_else(|| Format::from_bytes(bytes)).or_else(|| Format::from_path(input)) { Some(format) => format, None => { return Err(ConvertError::Unsupported(format!( @@ -73,7 +101,7 @@ fn run( }; let start = std::time::Instant::now(); - let markdown = anydoc::to_markdown_bytes(&bytes, format)?; + let markdown = anydoc::to_markdown_bytes(bytes, format)?; let elapsed = start.elapsed().as_secs_f64() * 1000.0; eprintln!("converted {} in {}", input.display(), millis(elapsed)); @@ -88,7 +116,7 @@ fn run( // Images and embedded objects live on the document model, not in the // Markdown, so they need a second pass to write out. if let Some(dir) = assets { - let document = anydoc::to_document(&bytes, format)?; + let document = anydoc::to_document(bytes, format)?; std::fs::create_dir_all(dir)?; let stem = input.file_stem().unwrap_or_default().to_string_lossy(); for asset in &document.assets { diff --git a/node/cli.js b/node/cli.js index bfba762a..21a8afa2 100644 --- a/node/cli.js +++ b/node/cli.js @@ -21,6 +21,10 @@ Options: ${FORMATS} (extension aliases like xls, docm, ppsx resolve to these) + -p, --password Decrypt a password-protected OOXML file first. + Falls back to the ANYDOC_PASSWORD environment + variable when omitted (argv leaks into shell + history and ps). -h, --help Print this help and exit -V, --version Print the version and exit @@ -50,7 +54,7 @@ function fail(code, message) { } function parseArgs(argv) { - const args = { input: null, output: null, format: null } + const args = { input: null, output: null, format: null, password: process.env.ANYDOC_PASSWORD || null } let positionalOnly = false for (let i = 0; i < argv.length; i++) { let arg = argv[i] @@ -95,6 +99,10 @@ function parseArgs(argv) { case '--format': args.format = value() break + case '-p': + case '--password': + args.password = value() + break default: fail(USAGE_ERROR, `unknown option '${arg}' (see anydoc --help)`) } @@ -121,7 +129,13 @@ async function main() { // Loaded after argument handling so --help and --version work even where // no native binding is available. - const { formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js') + const { + formatFromBytes, + formatFromExtension, + formatFromPath, + toMarkdown, + toMarkdownBytes, +} = require('./index.js') let format if (args.format !== null) { @@ -133,10 +147,14 @@ async function main() { let markdown try { - if (args.input === '-') { - markdown = await toMarkdownBytes(await readStdin(), format) - } else if (format !== undefined) { - markdown = await toMarkdownBytes(await readFile(args.input), format) + // A password only reaches the byte-level entry points; a path without + // --format would otherwise drop it on the floor (#130 review). + if (args.input === '-' || format !== undefined || args.password !== null) { + const bytes = await (args.input === '-' ? readStdin() : readFile(args.input)) + // CSV has no content signature, so the path extension stays the last + // fallback exactly as the no-password flow treats it (#130 review). + const resolved = format ?? formatFromBytes(bytes) ?? formatFromPath(args.input) + markdown = await toMarkdownBytes(bytes, resolved, args.password) } else { markdown = await toMarkdown(args.input) } diff --git a/node/index.d.ts b/node/index.d.ts index b4ae9378..c71a4939 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -293,6 +293,11 @@ export declare function toMarkdown(path: string): Promise * detected from the content, which signature-less formats (CSV) have to name * explicitly. * + * With a non-null `password`, decrypts a password-protected OOXML package + * (`.docx`/`.xlsx`/`.pptx`) before converting; a wrong password rejects with + * the same encrypted error as no password at all. Legacy binary formats + * (`.doc`, `.ppt`, `.xls`) are not supported encrypted. + * * Rejects with an `Error` carrying a `ConvertErrorCode` on `code`. */ -export declare function toMarkdownBytes(bytes: Uint8Array, format?: Format | undefined | null): Promise +export declare function toMarkdownBytes(bytes: Uint8Array, format?: Format | undefined | null, password?: string | undefined | null): Promise diff --git a/node/src/lib.rs b/node/src/lib.rs index 01df83a7..b6f91867 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -110,10 +110,12 @@ pub fn to_markdown(path: String) -> AsyncTask { pub fn to_markdown_bytes( bytes: Uint8Array, format: Option, + password: Option, ) -> AsyncTask { AsyncTask::new(MarkdownBytesTask { bytes: bytes.to_vec(), format: format.map(Into::into), + password, failure: Failure::default(), }) } @@ -185,6 +187,7 @@ impl Task for MarkdownFileTask { pub struct MarkdownBytesTask { bytes: Vec, format: Option, + password: Option, failure: Failure, } @@ -193,7 +196,8 @@ impl Task for MarkdownBytesTask { type JsValue = String; fn compute(&mut self) -> Result { - anydoc::to_markdown_bytes(&self.bytes, self.format).map_err(|e| self.failure.capture(e)) + anydoc::to_markdown_bytes_with_password(&self.bytes, self.format, self.password.as_deref()) + .map_err(|e| self.failure.capture(e)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc/_anydoc.pyi index 052f51e6..c8758e6d 100644 --- a/python/anydoc/_anydoc.pyi +++ b/python/anydoc/_anydoc.pyi @@ -58,10 +58,15 @@ def to_markdown(path: str | os.PathLike[str]) -> str: file content; the extension is the fallback for signature-less formats (CSV) and unrecognizable containers.""" -def to_markdown_bytes(data: bytes | bytearray, format: Format | None = None) -> str: +def to_markdown_bytes( + data: bytes | bytearray, + format: Format | None = None, + password: str | None = None, +) -> str: """Convert an in-memory document to Markdown. Without a format, it is detected from the content, which signature-less formats (CSV) have to - name explicitly.""" + name explicitly. A non-None `password` decrypts a password-protected + OOXML package first; wrong passwords still raise `EncryptedError`.""" def to_document(data: bytes | bytearray, format: Format | None = None) -> Document: """Parse an in-memory document into the document model, which also diff --git a/python/src/lib.rs b/python/src/lib.rs index abcbbd62..d858faa3 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -157,11 +157,19 @@ fn to_markdown(py: Python<'_>, path: PathBuf) -> PyResult { /// Convert an in-memory document to Markdown. Without a format, it is /// detected from the content, which signature-less formats (CSV) have to name /// explicitly. +/// A non-None `password` decrypts a password-protected OOXML package first; +/// wrong passwords still raise `EncryptedError`. #[pyfunction] -#[pyo3(signature = (data, format=None))] -fn to_markdown_bytes(py: Python<'_>, data: Vec, format: Option<&str>) -> PyResult { +#[pyo3(signature = (data, format=None, password=None))] +fn to_markdown_bytes( + py: Python<'_>, + data: Vec, + format: Option<&str>, + password: Option<&str>, +) -> PyResult { let format = format.map(parse_format).transpose()?; - py.detach(|| anydoc::to_markdown_bytes(&data, format)).map_err(|e| convert_error(py, e)) + py.detach(|| anydoc::to_markdown_bytes_with_password(&data, format, password)) + .map_err(|e| convert_error(py, e)) } /// Parse an in-memory document into the document model, which also carries diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index eee5317e..7911cabf 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -191,6 +191,92 @@ mod tests { format!(r#"{para}{para}"#) } + #[test] + fn instances_sharing_an_abstract_continue_one_sequence() { + // #96: Word treats every w:num that references one w:abstractNum as + // the same logical list; converters emit exactly this shape for a + // single outline split across several numIds. Counters must continue + // across the instance switches, and a startOverride must restart. + let document = r#" + + one + two + three + ten + "#; + let numbering = r#" + + + + + + + + + "#; + let bytes = + docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]); + let doc = parse(&bytes).unwrap(); + let starts: Vec = doc + .blocks + .iter() + .filter_map(|b| match b { + Block::List(list) if list.ordered() => Some(list.start), + _ => None, + }) + .collect(); + assert_eq!( + starts, + [1, 3, 10], + "instance 2 continues at three; the override restarts at ten" + ); + } + + #[test] + fn override_restart_does_not_leak_into_the_next_instance() { + // Entering an overriding instance schedules ITS levels' restarts; a + // following plain instance of the same abstract must keep counting + // instead of consuming a restart it never earned. Also exercises a + // numId that would collide with a u64::MAX sentinel if one existed. + let document = r#" + + one + sub + two + "#; + let numbering = r#" + + + + + + + + + + "#; + let bytes = + docx_parts(&[("word/document.xml", document), ("word/numbering.xml", numbering)]); + let doc = parse(&bytes).unwrap(); + let mut starts: Vec> = Vec::new(); + fn walk_lists(blocks: &[Block], out: &mut Vec>) { + for b in blocks { + if let Block::List(list) = b { + out.push(list.ordered().then_some(list.start)); + for item in &list.items { + walk_lists(&item.blocks, out); + } + } + } + } + walk_lists(&doc.blocks, &mut starts); + assert_eq!( + starts, + [Some(1), Some(1), Some(2)], + "sub-list starts at a; the outer sequence continues at two across the overriding instance instead of restarting" + ); + } + #[test] fn huge_numbering_start_values_cannot_overflow() { // H2: w:start is ST_DecimalNumber (xsd:int); out-of-range values are diff --git a/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs index a6334008..58f6c278 100644 --- a/src/formats/docx/numbering.rs +++ b/src/formats/docx/numbering.rs @@ -46,6 +46,15 @@ struct AbstractNum { pub struct Instance { pub levels: [LevelDef; LEVELS], pstyles: [Option; LEVELS], + /// Identity of the abstract definition this instance resolves to (after + /// any numStyleLink indirection). Instances sharing an abstract are one + /// logical list in Word, so their counters continue across instance + /// switches (#96). + pub(crate) abstract_key: u64, + /// Levels this instance overrides (`lvlOverride` with a nested `w:lvl` or + /// a `startOverride`). Entering the list through such an instance restarts + /// exactly those levels; every other level keeps counting (#96). + pub(crate) overrides: [bool; LEVELS], } impl Instance { @@ -104,24 +113,39 @@ pub fn parse( direct.insert(num_id, (abs_id, num)); } + // Stable identity per abstract definition (document order). Counters key + // on this so instances that share an abstract continue one logical list. + let mut abstract_keys: HashMap<&str, u64> = HashMap::new(); + for abs in root.find_all(ns::W, "abstractNum") { + let Some(id) = abs.attr(ns::W, "abstractNumId") else { continue }; + let key = abstract_keys.len() as u64; + abstract_keys.entry(id).or_insert(key); + } + let mut numbering = Numbering::default(); for (&num_id, &(abs_id, num_elem)) in &direct { - let Some(abs) = resolve_abstract(abs_id, &abstracts, &direct, style_num_id)? else { + let Some((resolved_id, abs)) = resolve_abstract(abs_id, &abstracts, &direct, style_num_id)? + else { log::warn!("numbering instance {num_id} references unknown abstract {abs_id:?}"); continue; }; let mut levels = abs.levels.clone(); let mut pstyles = abs.pstyles.clone(); + let mut overrides = [false; LEVELS]; for over in num_elem.find_all(ns::W, "lvlOverride") { let ilvl: usize = over.attr(ns::W, "ilvl").and_then(|v| v.parse().ok()).unwrap_or(0); if ilvl >= LEVELS { continue; } // A nested w:lvl replaces the level wholesale; startOverride is - // applied last so it survives the replacement. + // applied last so it survives the replacement. Both count as an + // override: entering the list through this instance restarts the + // level instead of continuing the shared sequence (#96). + let mut overridden = false; if let Some(lvl) = over.find(ns::W, "lvl") { levels[ilvl] = parse_level(lvl); pstyles[ilvl] = level_pstyle(lvl); + overridden = true; } if let Some(start) = over .find(ns::W, "startOverride") @@ -129,9 +153,19 @@ pub fn parse( .and_then(parse_start) { levels[ilvl].start = start; + overridden = true; } + overrides[ilvl] = overridden; } - numbering.instances.insert(num_id, Instance { levels, pstyles }); + numbering.instances.insert( + num_id, + Instance { + levels, + pstyles, + abstract_key: abstract_keys.get(resolved_id.as_str()).copied().unwrap_or(u64::MAX), + overrides, + }, + ); } Ok(numbering) } @@ -143,7 +177,7 @@ fn resolve_abstract<'n>( abstracts: &'n HashMap<&str, AbstractNum>, direct: &HashMap, style_num_id: &impl Fn(&str) -> Option, -) -> Result, ConvertError> { +) -> Result, ConvertError> { let mut seen: Vec = Vec::new(); let mut current = abs_id.to_string(); loop { @@ -157,14 +191,14 @@ fn resolve_abstract<'n>( return Ok(None); }; let Some(style_id) = &abs.num_style_link else { - return Ok(Some(abs)); + return Ok(Some((current.clone(), abs))); }; let linked = style_num_id(style_id) .and_then(|num_id| direct.get(&num_id)) .map(|(abs_id, _)| abs_id.to_string()); match linked { Some(next) => current = next, - None => return Ok(Some(abs)), + None => return Ok(Some((current.clone(), abs))), } } } @@ -226,6 +260,15 @@ struct InstanceState { value: [u64; LEVELS], initialized: [bool; LEVELS], restart_pending: [bool; LEVELS], + /// The instance currently driving this abstract. Switching instances + /// inside one logical list restarts only the levels the entering + /// instance overrides (#96). `None` until the list's first paragraph. + last_num: Option, + /// The overridden levels of the currently active instance. Each restarts + /// on its first USE under this instance — not eagerly at the switch, so a + /// following plain instance cannot consume a restart that was never + /// earned (cubic review of #129). + fresh_overrides: [bool; LEVELS], } impl Counters { @@ -234,9 +277,15 @@ impl Counters { /// reproducible from the marker kind alone, the composite label. pub fn next(&mut self, num_id: u64, ilvl: usize, instance: &Instance) -> (u64, Option) { let ilvl = ilvl.min(LEVELS - 1); - let state = self.state.entry(num_id).or_default(); + let state = self.state.entry(instance.abstract_key).or_default(); + if state.last_num != Some(num_id) { + // First paragraph of this list, or an instance switch within it. + state.fresh_overrides = instance.overrides; + state.last_num = Some(num_id); + } let def = &instance.levels[ilvl]; - if !state.initialized[ilvl] || state.restart_pending[ilvl] { + let override_restart = std::mem::take(&mut state.fresh_overrides[ilvl]); + if !state.initialized[ilvl] || state.restart_pending[ilvl] || override_restart { state.value[ilvl] = def.start; state.initialized[ilvl] = true; state.restart_pending[ilvl] = false; diff --git a/src/lib.rs b/src/lib.rs index 97099491..601ee902 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,15 +98,28 @@ impl Format { /// file content ([`Format::from_bytes`]); the extension is the fallback for /// signature-less formats (CSV) and unrecognizable containers. pub fn to_markdown(path: impl AsRef) -> Result { + to_markdown_with_password(path, None) +} + +/// Convert a document file to Markdown, decrypting it first when a +/// [`Some`]`password` is supplied and the file is a password-protected OOXML +/// package. Otherwise behaves exactly like [`to_markdown`]. +/// +/// A wrong password, or an encrypted file without one, is +/// [`ConvertError::Encrypted`] — the same rejection the no-password path has +/// always produced. Legacy encrypted binary formats (`.doc`, `.ppt`, `.xls`) +/// are out of scope and stay `Encrypted`. +pub fn to_markdown_with_password( + path: impl AsRef, + password: Option<&str>, +) -> Result { let path = path.as_ref(); let bytes = std::fs::read(path)?; - let Some(format) = Format::from_bytes(&bytes).or_else(|| Format::from_path(path)) else { - return Err(ConvertError::Unsupported(format!( - "unrecognized file content and extension: {}", - path.display() - ))); - }; - to_markdown_bytes(&bytes, format) + // Detection is allowed to fail here: an encrypted OOXML container has no + // recognizable signature until after decryption, so hand the unresolved + // format down and let the byte-level entry point decide (#130 review). + let format = Format::from_bytes(&bytes).or_else(|| Format::from_path(path)); + to_markdown_bytes_with_password(&bytes, format, password) } /// Convert an in-memory document to Markdown. Pass a [`Format`] to select the @@ -116,6 +129,30 @@ pub fn to_markdown_bytes( bytes: &[u8], format: impl Into>, ) -> Result { + to_markdown_bytes_with_password(bytes, format, None) +} + +/// [`to_markdown_bytes`] with optional decryption of password-protected +/// OOXML packages (`.docx`/`.xlsx`/`.pptx` and their macro variants). +/// +/// A `None` or empty password behaves exactly like [`to_markdown_bytes`]: +/// encrypted input is rejected with [`ConvertError::Encrypted`]. With a +/// password, an encrypted package is decrypted first — a wrong password +/// still ends in `Encrypted`, and the converted result comes from the +/// plaintext package, so every resource limit applies unchanged. +pub fn to_markdown_bytes_with_password( + bytes: &[u8], + format: impl Into>, + password: Option<&str>, +) -> Result { + let decrypted; + let bytes = match password.filter(|p| !p.is_empty()) { + Some(pw) if package::archive::is_encrypted_ooxml(bytes) => { + decrypted = package::crypto::decrypt_ooxml(bytes.to_vec(), pw)?; + decrypted.as_slice() + } + _ => bytes, + }; let format = resolve_format(bytes, format.into())?; // PDFs convert to Markdown directly (pdf-inspector) without passing // through the document model. @@ -125,6 +162,19 @@ pub fn to_markdown_bytes( Ok(document_to_markdown(&to_document(bytes, format)?)) } +/// True when `bytes` are a password-protected OOXML package. +pub fn is_encrypted_ooxml(bytes: &[u8]) -> bool { + package::archive::is_encrypted_ooxml(bytes) +} + +/// Decrypt a password-protected OOXML package into its plaintext zip bytes. +/// +/// Wrong passwords and unsupported schemes end in [`ConvertError::Encrypted`]; +/// see [`package::crypto::decrypt_ooxml`] for details. +pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertError> { + package::crypto::decrypt_ooxml(bytes, password) +} + /// Parse an in-memory document into the document model. Pass a [`Format`] to /// select the parser, or `None` to detect it from the content. /// diff --git a/src/package/archive.rs b/src/package/archive.rs index ce6637bd..1549d44c 100644 --- a/src/package/archive.rs +++ b/src/package/archive.rs @@ -138,6 +138,17 @@ impl<'a> Package<'a> { } } +/// True when `bytes` are an OLE compound container carrying an OOXML +/// encrypted package — the same shape [`probe_ole`] classifies as +/// [`ConvertError::Encrypted`]. +pub fn is_encrypted_ooxml(bytes: &[u8]) -> bool { + const OLE_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + bytes.starts_with(&OLE_MAGIC) + && cfb::CompoundFile::open(Cursor::new(bytes)) + .map(|file| file.exists("EncryptionInfo") || file.exists("EncryptedPackage")) + .unwrap_or(false) +} + /// A zip-open failure on OOXML input may actually be an OLE compound file: /// an encrypted package, or a legacy binary document with the wrong /// extension. diff --git a/src/package/crypto.rs b/src/package/crypto.rs new file mode 100644 index 00000000..e1b42356 --- /dev/null +++ b/src/package/crypto.rs @@ -0,0 +1,71 @@ +//! Password-protected OOXML packages. +//! +//! An encrypted OOXML file is an OLE compound container whose payload is a +//! zip; [`crate::package::archive::probe_ole`] rejects it as +//! [`ConvertError::Encrypted`]. With a password in hand the container can be +//! decrypted back to that zip and converted like any plaintext package, so +//! all existing resource limits apply to the decrypted bytes unchanged. + +use crate::error::ConvertError; + +/// Decrypt a password-protected OOXML package into its plaintext zip bytes. +/// +/// Every failure — malformed `EncryptionInfo`, unsupported scheme, wrong +/// password — maps to [`ConvertError::Encrypted`]: without a usable +/// plaintext there is nothing else useful to say, and that is the error +/// callers already handle. +pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertError> { + let plain = office_crypto::decrypt_from_bytes(bytes, password).map_err(|e| { + log::debug!("OOXML decryption failed: {e}"); + ConvertError::Encrypted + })?; + // office-crypto does not check the EncryptionInfo password verifier, so a + // wrong password still "succeeds" — into noise. The decrypted payload is + // always an OOXML zip (the 8-byte size header is stripped), and the + // signature alone is not proof, so validate it with the same archive + // reader every package goes through next. Validation runs BEFORE the + // size policy so wrong-password noise keeps the documented Encrypted + // result even when it happens to be huge (cubic review of #130). + if !plain.starts_with(b"PK") || zip_check_broken(&plain) { + log::debug!("OOXML decryption produced a non-zip payload (wrong password?)"); + return Err(ConvertError::Encrypted); + } + // Decryption materialises the WHOLE package in memory where the + // plaintext path streams parts lazily, so it gets its own explicit + // budget rather than borrowing MAX_TOTAL_BYTES' part-read semantics. + const DECRYPTED_PACKAGE_BUDGET_BYTES: u64 = 512 * 1024 * 1024; + let total: u64 = plain.len() as u64; + if total > DECRYPTED_PACKAGE_BUDGET_BYTES { + return Err(ConvertError::ResourceLimit { + limit: "decrypted_package_bytes", + detail: format!( + "decrypted OOXML package is {total} bytes, over the {DECRYPTED_PACKAGE_BUDGET_BYTES} byte budget" + ), + }); + } + Ok(plain) +} + +/// Cheap structural probe: can the shared zip reader actually open this? +fn zip_check_broken(plain: &[u8]) -> bool { + zip::ZipArchive::new(std::io::Cursor::new(plain)).map(|z| z.len()).is_err() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn garbage_container_maps_to_encrypted() { + let err = decrypt_ooxml(vec![0xD0, 0xCF, 0x11, 0xE0], "nope").unwrap_err(); + assert!(matches!(err, ConvertError::Encrypted)); + } + + #[test] + fn non_zip_payload_maps_to_encrypted() { + // office-crypto does not verify the password, so a wrong one yields + // noise with an Ok status; the zip-signature gate must catch it. + let err = decrypt_ooxml(vec![0u8; 64], "wrong").unwrap_err(); + assert!(matches!(err, ConvertError::Encrypted)); + } +} diff --git a/src/package/mod.rs b/src/package/mod.rs index a3c974f7..9afd9406 100644 --- a/src/package/mod.rs +++ b/src/package/mod.rs @@ -3,6 +3,7 @@ //! OPC/EPUB target resolution. pub mod archive; +pub mod crypto; pub mod limits; pub mod path; pub mod relationships; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index cb693101..250e90c9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -7,6 +7,7 @@ pub fn fixture_root() -> PathBuf { } /// Recursively collect every file under `dir`, sorted for determinism. +#[allow(dead_code)] // not every integration-test binary walks the corpus pub fn walk(dir: &Path, out: &mut Vec) { let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); entries.sort(); diff --git a/tests/encrypted.rs b/tests/encrypted.rs new file mode 100644 index 00000000..8dc3cb26 --- /dev/null +++ b/tests/encrypted.rs @@ -0,0 +1,59 @@ +//! Password-protected OOXML packages (#102). +//! +//! The fixture is an agile (SHA-512) encrypted `.docx` taken from the +//! office-crypto crate's MIT-licensed test suite; its password is +//! `testPassword`. Without a password the corpus sweep already records the +//! rejection under the fixture's `--errors` annotation. + +use common::fixture_root; + +mod common; + +use anydoc::{ConvertError, Format}; + +const FIXTURE: &str = "encrypted/agile-sha512-docx--errors.docx"; +const PASSWORD: &str = "testPassword"; + +#[test] +fn encrypted_docx_converts_with_password() { + let path = fixture_root().join(FIXTURE); + let bytes = std::fs::read(&path).unwrap(); + let md = anydoc::to_markdown_bytes_with_password(&bytes, None, Some(PASSWORD)) + .expect("decrypt+convert"); + assert!(md.contains("testing testing"), "unexpected body: {md}"); +} + +#[test] +fn encrypted_docx_without_password_stays_encrypted() { + let path = fixture_root().join(FIXTURE); + let bytes = std::fs::read(&path).unwrap(); + let err = anydoc::to_markdown_bytes(&bytes, Format::Docx).unwrap_err(); + assert!(matches!(err, ConvertError::Encrypted)); +} + +#[test] +fn encrypted_docx_with_wrong_password_is_encrypted() { + let path = fixture_root().join(FIXTURE); + let bytes = std::fs::read(&path).unwrap(); + let err = + anydoc::to_markdown_bytes_with_password(&bytes, Format::Docx, Some("wrong")).unwrap_err(); + assert!(matches!(err, ConvertError::Encrypted)); +} + +#[test] +fn empty_password_behaves_like_no_password() { + let path = fixture_root().join(FIXTURE); + let bytes = std::fs::read(&path).unwrap(); + let err = anydoc::to_markdown_bytes_with_password(&bytes, Format::Docx, Some("")).unwrap_err(); + assert!(matches!(err, ConvertError::Encrypted)); +} + +#[test] +fn plaintext_docx_ignores_password_argument() { + // A password on a non-encrypted file must not change anything. + let path = fixture_root().join("docx/handmade-numbering.docx"); + let bytes = std::fs::read(&path).unwrap(); + let with_pw = anydoc::to_markdown_bytes_with_password(&bytes, None, Some("ignored")); + let without = anydoc::to_markdown_bytes(&bytes, None).unwrap(); + assert_eq!(with_pw.unwrap(), without); +} diff --git a/tests/fixtures/encrypted/agile-sha512-docx--errors.docx b/tests/fixtures/encrypted/agile-sha512-docx--errors.docx new file mode 100644 index 00000000..757c285f Binary files /dev/null and b/tests/fixtures/encrypted/agile-sha512-docx--errors.docx differ diff --git a/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap b/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap index 6a19b3e2..4a760316 100644 --- a/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap +++ b/tests/snapshots/snapshots__docx__handmade-numbering.docx.snap @@ -18,7 +18,7 @@ Interruption paragraph. 4. One-four continues the count -1. Two-one independent counter +5. Two-one independent counter 10. Ten-start via override @@ -28,7 +28,7 @@ Interruption paragraph. Suppressed numbering paragraph -5. Style-numbered paragraph +8. Style-numbered paragraph 1. pStyle-bound level one diff --git a/tests/snapshots/snapshots__docx__text.docx.snap b/tests/snapshots/snapshots__docx__text.docx.snap index 655b643e..2eee1b87 100644 --- a/tests/snapshots/snapshots__docx__text.docx.snap +++ b/tests/snapshots/snapshots__docx__text.docx.snap @@ -28,7 +28,7 @@ Interrupting paragraph between lists. - IV. Roman starting at four -- I. Roman five +- V. Roman five - Bullet one diff --git a/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap b/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap new file mode 100644 index 00000000..bd8df238 --- /dev/null +++ b/tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +ERROR: document is encrypted diff --git a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap index 750148a5..1ce7a0ab 100644 --- a/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap +++ b/tests/snapshots/snapshots__malformed__corrupt-styles--skips.docx.snap @@ -28,7 +28,7 @@ Interrupting paragraph between lists. - IV. Roman starting at four -- I. Roman five +- V. Roman five - Bullet one diff --git a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap index 750148a5..1ce7a0ab 100644 --- a/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap +++ b/tests/snapshots/snapshots__malformed__missing-styles--skips.docx.snap @@ -28,7 +28,7 @@ Interrupting paragraph between lists. - IV. Roman starting at four -- I. Roman five +- V. Roman five - Bullet one diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 5a710a5b..1145fb61 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -99,8 +99,17 @@ pub fn format_from_path(path: &str) -> Option { /// /// Throws an `Error` carrying a `ConvertErrorCode` on `code`. #[wasm_bindgen(js_name = toMarkdownBytes)] -pub fn to_markdown_bytes(bytes: &[u8], format: Option) -> Result { - anydoc::to_markdown_bytes(bytes, format.map(anydoc::Format::from)).map_err(convert_error) +pub fn to_markdown_bytes( + bytes: &[u8], + format: Option, + password: Option, +) -> Result { + anydoc::to_markdown_bytes_with_password( + bytes, + format.map(anydoc::Format::from), + password.as_deref(), + ) + .map_err(convert_error) } /// Parse an in-memory document into the document model, which also carries