From 58f282ab0a7d4a04795b11e2b7c28b1516b174dd Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:07:30 +0000 Subject: [PATCH 1/5] fix(docx): continue counters across numIds that share an abstract (#96) Word treats every w:num referencing one w:abstractNum as a single logical list; startOverride exists precisely to break away from it. Counters were keyed per numId instead, so documents whose outline is split across several instances of one abstract restarted at every switch: headings came out as Section 1, 2, 1(!), 1.1(!), 3 ... exactly the reported multiple-section-1 / 4.x-to-1.1 symptom in #96. Reapplying the paragraph style clears it because it drops the direct numPr pointing at the second instance, collapsing the document back to one numId. Counters are now keyed by the resolved abstract definition. Entering a list through an instance that carries lvlOverride restarts exactly its overridden levels (nested w:lvl or startOverride); everything else keeps counting. The real-world text.docx fixture shows why this direction is right: consecutive items use numId 5 then numId 4 and the source itself labels them four, five -- previously rendered as IV, I. Snapshot updates: handmade-numbering's "Two-one independent counter" now continues the shared sequence (5.) rather than restarting (1.), and the style-referenced paragraph follows the merged sequence (8.). The override-driven cases (10., 7)) still restart as before. If the intent was that bare shared instances stay independent in anydoc despite Word, this is easy to flip behind a flag -- but Word parity is what #96 asks for. --- src/formats/docx/mod.rs | 41 +++++++++++ src/formats/docx/numbering.rs | 68 +++++++++++++++++-- ...pshots__docx__handmade-numbering.docx.snap | 4 +- .../snapshots/snapshots__docx__text.docx.snap | 2 +- ...malformed__corrupt-styles--skips.docx.snap | 2 +- ...malformed__missing-styles--skips.docx.snap | 2 +- 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index eee5317e..02c2e801 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -191,6 +191,47 @@ 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 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..bb892cd4 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,11 @@ struct InstanceState { value: [u64; LEVELS], initialized: [bool; LEVELS], restart_pending: [bool; LEVELS], + /// The instance currently driving this abstract (`u64::MAX` = none yet; + /// real `numId`s never reach the counters as `u64::MAX`). Switching + /// instances inside one logical list restarts only the levels the + /// entering instance overrides (#96). + last_num: u64, } impl Counters { @@ -234,7 +273,22 @@ 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_insert_with(|| InstanceState { last_num: u64::MAX, ..Default::default() }); + if state.last_num != num_id { + // First paragraph of this list, or an instance switch within it: + // overridden levels restart, everything else keeps counting. + if state.last_num != u64::MAX { + for (l, overridden) in instance.overrides.iter().enumerate() { + if *overridden { + state.restart_pending[l] = true; + } + } + } + state.last_num = num_id; + } let def = &instance.levels[ilvl]; if !state.initialized[ilvl] || state.restart_pending[ilvl] { state.value[ilvl] = def.start; 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__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 From d984f67cf558737bd858d56ddc32d23a9ca09f49 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:31:03 +0000 Subject: [PATCH 2/5] feat: decrypt password-protected OOXML when a password is supplied (#102) A password-protected .docx/.xlsx/.pptx was always ConvertError::Encrypted with no way to hand over a password, so ingestion pipelines that receive the password alongside the file had no path through the library, CLI, or bindings. - to_markdown_with_password / to_markdown_bytes_with_password decrypt an encrypted OOXML package via office-crypto before conversion; plaintext files ignore the argument, and every resource limit applies to the decrypted zip unchanged. No password, empty password, or wrong password: Encrypted, exactly as today. Legacy RC4 formats stay Encrypted. office-crypto does not check the EncryptionInfo verifier, so a wrong password yields noise with an Ok status - the zip-signature gate on the payload is the cheap honest wrong-password detection. - CLI: -p/--password with ANYDOC_PASSWORD env fallback (argv leaks into shell history and ps). - Node, Python and wasm bindings gain an optional trailing password arg; old signatures keep working. Fixture: agile SHA-512 encrypted .docx from office-crypto's MIT test suite (password testPassword), annotated --errors for the no-password corpus sweep. New integration tests cover convert-with-password, no/wrong/empty password rejection, and plaintext-with-password passthrough. Fixes #102 This change was prepared with AI assistance under human direction and review. --- Cargo.lock | 148 +++++++++++++++++- Cargo.toml | 1 + examples/convert.rs | 15 +- node/cli.js | 14 +- node/index.d.ts | 7 +- node/src/lib.rs | 6 +- python/anydoc/_anydoc.pyi | 9 +- python/src/lib.rs | 16 +- src/lib.rs | 41 ++++- src/package/archive.rs | 11 ++ src/package/crypto.rs | 50 ++++++ src/package/mod.rs | 1 + tests/common/mod.rs | 1 + tests/encrypted.rs | 59 +++++++ .../encrypted/agile-sha512-docx--errors.docx | Bin 0 -> 25088 bytes ...ypted__agile-sha512-docx--errors.docx.snap | 5 + wasm/src/lib.rs | 13 +- 17 files changed, 380 insertions(+), 17 deletions(-) create mode 100644 src/package/crypto.rs create mode 100644 tests/encrypted.rs create mode 100644 tests/fixtures/encrypted/agile-sha512-docx--errors.docx create mode 100644 tests/snapshots/snapshots__encrypted__agile-sha512-docx--errors.docx.snap 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..4b7244f7 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,10 @@ fn main() -> ExitCode { i += 1; assets = args.get(i).map(PathBuf::from); } + "-p" | "--password" => { + i += 1; + password = args.get(i).map(String::from); + } other => input = Some(PathBuf::from(other)), } i += 1; @@ -43,7 +50,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,7 +64,9 @@ fn run( output: Option<&Path>, format: Option, assets: Option<&Path>, + password: Option, ) -> Result<(), ConvertError> { + let password = password.or_else(|| std::env::var(PASSWORD_ENV).ok()); let bytes = std::fs::read(input)?; // Without -f the format comes from the file content, with the extension as // the fallback. @@ -73,7 +82,7 @@ fn run( }; let start = std::time::Instant::now(); - let markdown = anydoc::to_markdown_bytes(&bytes, format)?; + let markdown = anydoc::to_markdown_bytes_with_password(&bytes, format, password.as_deref())?; let elapsed = start.elapsed().as_secs_f64() * 1000.0; eprintln!("converted {} in {}", input.display(), millis(elapsed)); diff --git a/node/cli.js b/node/cli.js index bfba762a..1e0288db 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)`) } @@ -134,9 +142,9 @@ async function main() { let markdown try { if (args.input === '-') { - markdown = await toMarkdownBytes(await readStdin(), format) + markdown = await toMarkdownBytes(await readStdin(), format, args.password) } else if (format !== undefined) { - markdown = await toMarkdownBytes(await readFile(args.input), format) + markdown = await toMarkdownBytes(await readFile(args.input), format, 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..9f462398 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -157,11 +157,21 @@ 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. +/// 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/lib.rs b/src/lib.rs index 97099491..02aca2c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,6 +98,21 @@ 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 { @@ -106,7 +121,7 @@ pub fn to_markdown(path: impl AsRef) -> Result { path.display() ))); }; - to_markdown_bytes(&bytes, format) + to_markdown_bytes_with_password(&bytes, format, password) } /// Convert an in-memory document to Markdown. Pass a [`Format`] to select the @@ -116,6 +131,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. 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..54e1be5c --- /dev/null +++ b/src/package/crypto.rs @@ -0,0 +1,50 @@ +//! 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 the OOXML zip itself (the 8-byte size header is stripped), so its + // signature is the cheapest reliable wrong-password test. + if !plain.starts_with(b"PK") { + log::debug!("OOXML decryption produced a non-zip payload (wrong password?)"); + return Err(ConvertError::Encrypted); + } + Ok(plain) +} + +#[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 0000000000000000000000000000000000000000..757c285fc27d9b16ad6d4eb7d82c68afe8fd1038 GIT binary patch literal 25088 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 Hz%%~>(YA3k literal 0 HcmV?d00001 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/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 From 10b633d761ceb0c680640dbf40211b254f061c15 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:59:58 +0000 Subject: [PATCH 3/5] fix(docx): per-instance override restarts without sentinel collision Cubic review follow-ups on #129, both real: - last_num is now Option: a document may legally carry numId=18446744073709551615 (xsd:int clamps were only applied to start values), and a u64::MAX sentinel would have swallowed the instance switch that triggers an override restart. - Overridden levels restart on their first USE under the entering instance (fresh_overrides) instead of eagerly marking pending bits at the switch. Eager marking let a following plain instance consume a restart it never earned: n1(1), n2[startOverride](unused at L0), n3 plain restarted to 1 instead of continuing at 2. Regression test walks nested lists and asserts the continuation. --- src/formats/docx/mod.rs | 45 +++++++++++++++++++++++++++++++++++ src/formats/docx/numbering.rs | 34 +++++++++++++------------- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index 02c2e801..7911cabf 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -232,6 +232,51 @@ mod tests { ); } + #[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 bb892cd4..61637a7d 100644 --- a/src/formats/docx/numbering.rs +++ b/src/formats/docx/numbering.rs @@ -263,8 +263,15 @@ struct InstanceState { /// The instance currently driving this abstract (`u64::MAX` = none yet; /// real `numId`s never reach the counters as `u64::MAX`). Switching /// instances inside one logical list restarts only the levels the - /// entering instance overrides (#96). - last_num: u64, + /// entering instance overrides (#96). `None` until the list's first + /// paragraph; `Some` never collides with a real id because it is + /// compared as an `Option`, not against a sentinel value. + 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 { @@ -273,24 +280,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(instance.abstract_key) - .or_insert_with(|| InstanceState { last_num: u64::MAX, ..Default::default() }); - if state.last_num != num_id { - // First paragraph of this list, or an instance switch within it: - // overridden levels restart, everything else keeps counting. - if state.last_num != u64::MAX { - for (l, overridden) in instance.overrides.iter().enumerate() { - if *overridden { - state.restart_pending[l] = true; - } - } - } - state.last_num = num_id; + 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; From bcad3b7a183bd0aa8bf8df1d503cf5c45ff8c05b Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:04:32 +0000 Subject: [PATCH 4/5] fix: review follow-ups for encrypted OOXML support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cubic review round on #130, all addressed: - CLI: a path conversion with a password but no --format dropped the password entirely; such conversions now go through toMarkdownBytes with content detection. -p/--password without a value is a usage error instead of silently falling back to the environment variable. - CLI: the asset pass re-read the still-encrypted container after a successful password-protected Markdown conversion and died with Encrypted. The container is now decrypted once, up front, so Markdown and assets see the same plaintext. - Library: to_markdown_with_password no longer fails with Unsupported before decrypting when an encrypted file has no recognizable extension; unresolved formats flow through to the byte-level entry point. - crypto: the decrypted payload is bounded by MAX_TOTAL_BYTES before any part is read, and is validated by actually opening it as an archive — a wrong password whose noise happens to start with PK now still ends in Encrypted instead of Malformed. - is_encrypted_ooxml / decrypt_ooxml are re-exported for embedders. - Python binding doc: removed the duplicated summary line. - Carries the #129 per-instance override-restart fix so the two branches do not conflict. --- examples/convert.rs | 23 ++++++++++++++++++++--- node/cli.js | 12 +++++++----- python/src/lib.rs | 6 ++---- src/lib.rs | 23 +++++++++++++++++------ src/package/crypto.rs | 25 ++++++++++++++++++++++--- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/examples/convert.rs b/examples/convert.rs index 4b7244f7..ce6c1cfd 100644 --- a/examples/convert.rs +++ b/examples/convert.rs @@ -39,7 +39,13 @@ fn main() -> ExitCode { } "-p" | "--password" => { i += 1; - password = args.get(i).map(String::from); + 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)), } @@ -81,8 +87,19 @@ fn run( } }; + // Decrypt once so the asset pass below sees the same plaintext instead + // of re-reading the still-encrypted container (#130 review). + let decrypted; + let bytes: &[u8] = match password.as_deref() { + Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&bytes) => { + decrypted = anydoc::decrypt_ooxml(bytes.to_vec(), pw)?; + &decrypted + } + _ => &bytes, + }; + let start = std::time::Instant::now(); - let markdown = anydoc::to_markdown_bytes_with_password(&bytes, format, password.as_deref())?; + let markdown = anydoc::to_markdown_bytes(bytes, format)?; let elapsed = start.elapsed().as_secs_f64() * 1000.0; eprintln!("converted {} in {}", input.display(), millis(elapsed)); @@ -97,7 +114,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 1e0288db..256a1f21 100644 --- a/node/cli.js +++ b/node/cli.js @@ -129,7 +129,7 @@ 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, toMarkdown, toMarkdownBytes } = require('./index.js') let format if (args.format !== null) { @@ -141,10 +141,12 @@ async function main() { let markdown try { - if (args.input === '-') { - markdown = await toMarkdownBytes(await readStdin(), format, args.password) - } else if (format !== undefined) { - markdown = await toMarkdownBytes(await readFile(args.input), format, args.password) + // 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)) + const resolved = format ?? formatFromBytes(bytes) + markdown = await toMarkdownBytes(bytes, resolved, args.password) } else { markdown = await toMarkdown(args.input) } diff --git a/python/src/lib.rs b/python/src/lib.rs index 9f462398..d858faa3 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -157,10 +157,8 @@ 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. -/// 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`. +/// A non-None `password` decrypts a password-protected OOXML package first; +/// wrong passwords still raise `EncryptedError`. #[pyfunction] #[pyo3(signature = (data, format=None, password=None))] fn to_markdown_bytes( diff --git a/src/lib.rs b/src/lib.rs index 02aca2c8..601ee902 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,12 +115,10 @@ pub fn to_markdown_with_password( ) -> 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() - ))); - }; + // 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) } @@ -164,6 +162,19 @@ pub fn to_markdown_bytes_with_password( 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/crypto.rs b/src/package/crypto.rs index 54e1be5c..7d7d1820 100644 --- a/src/package/crypto.rs +++ b/src/package/crypto.rs @@ -19,17 +19,36 @@ pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertE log::debug!("OOXML decryption failed: {e}"); ConvertError::Encrypted })?; + // The same archive budget that governs plaintext packages must bound the + // decrypted one too, or a small encrypted file could inflate past every + // limit before any part is read. + let total: u64 = plain.len() as u64; + if total > crate::package::limits::MAX_TOTAL_BYTES { + return Err(ConvertError::ResourceLimit { + limit: "max_total_bytes", + detail: format!( + "decrypted OOXML package is {total} bytes, over the {} byte budget", + crate::package::limits::MAX_TOTAL_BYTES + ), + }); + } // office-crypto does not check the EncryptionInfo password verifier, so a // wrong password still "succeeds" — into noise. The decrypted payload is - // always the OOXML zip itself (the 8-byte size header is stripped), so its - // signature is the cheapest reliable wrong-password test. - if !plain.starts_with(b"PK") { + // 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. + 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); } 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::*; From 7a4cac6ae4ae79330a1409b390d0d4fdf62c8346 Mon Sep 17 00:00:00 2001 From: Mr-Neutr0n <64578610+Mr-Neutr0n@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:47:41 +0000 Subject: [PATCH 5/5] fix: cubic review round three on encrypted OOXML - crypto: wrong-password validation now runs before the size policy, so oversized noise still ends in Encrypted rather than ResourceLimit; the decrypted-in-memory budget becomes its own named constant with a note on why it is stricter than the plaintext part-read budget. - cli.js: a password-bearing path without --format keeps the extension fallback (formatFromPath) so signature-less CSV behaves exactly as without a password. - convert.rs: format detection moved after decryption, mirroring to_markdown_with_password - an encrypted file with an unrecognized extension no longer dies with Unsupported pre-decrypt. - numbering.rs: dropped the stale u64::MAX sentinel sentence from the last_num doc comment. --- examples/convert.rs | 32 +++++++++++++++++--------------- node/cli.js | 12 ++++++++++-- src/formats/docx/numbering.rs | 9 +++------ src/package/crypto.rs | 30 ++++++++++++++++-------------- 4 files changed, 46 insertions(+), 37 deletions(-) diff --git a/examples/convert.rs b/examples/convert.rs index ce6c1cfd..d1d36642 100644 --- a/examples/convert.rs +++ b/examples/convert.rs @@ -73,11 +73,24 @@ fn run( password: Option, ) -> Result<(), ConvertError> { let password = password.or_else(|| std::env::var(PASSWORD_ENV).ok()); - let bytes = std::fs::read(input)?; - // Without -f the format comes from the file content, with the extension as - // the fallback. + 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!( @@ -87,17 +100,6 @@ fn run( } }; - // Decrypt once so the asset pass below sees the same plaintext instead - // of re-reading the still-encrypted container (#130 review). - let decrypted; - let bytes: &[u8] = match password.as_deref() { - Some(pw) if !pw.is_empty() && anydoc::is_encrypted_ooxml(&bytes) => { - decrypted = anydoc::decrypt_ooxml(bytes.to_vec(), pw)?; - &decrypted - } - _ => &bytes, - }; - let start = std::time::Instant::now(); let markdown = anydoc::to_markdown_bytes(bytes, format)?; let elapsed = start.elapsed().as_secs_f64() * 1000.0; diff --git a/node/cli.js b/node/cli.js index 256a1f21..21a8afa2 100644 --- a/node/cli.js +++ b/node/cli.js @@ -129,7 +129,13 @@ async function main() { // Loaded after argument handling so --help and --version work even where // no native binding is available. - const { formatFromBytes, formatFromExtension, toMarkdown, toMarkdownBytes } = require('./index.js') + const { + formatFromBytes, + formatFromExtension, + formatFromPath, + toMarkdown, + toMarkdownBytes, +} = require('./index.js') let format if (args.format !== null) { @@ -145,7 +151,9 @@ async function main() { // --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)) - const resolved = format ?? formatFromBytes(bytes) + // 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/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs index 61637a7d..58f6c278 100644 --- a/src/formats/docx/numbering.rs +++ b/src/formats/docx/numbering.rs @@ -260,12 +260,9 @@ struct InstanceState { value: [u64; LEVELS], initialized: [bool; LEVELS], restart_pending: [bool; LEVELS], - /// The instance currently driving this abstract (`u64::MAX` = none yet; - /// real `numId`s never reach the counters as `u64::MAX`). Switching - /// instances inside one logical list restarts only the levels the - /// entering instance overrides (#96). `None` until the list's first - /// paragraph; `Some` never collides with a real id because it is - /// compared as an `Option`, not against a sentinel value. + /// 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 diff --git a/src/package/crypto.rs b/src/package/crypto.rs index 7d7d1820..e1b42356 100644 --- a/src/package/crypto.rs +++ b/src/package/crypto.rs @@ -19,28 +19,30 @@ pub fn decrypt_ooxml(bytes: Vec, password: &str) -> Result, ConvertE log::debug!("OOXML decryption failed: {e}"); ConvertError::Encrypted })?; - // The same archive budget that governs plaintext packages must bound the - // decrypted one too, or a small encrypted file could inflate past every - // limit before any part is read. - let total: u64 = plain.len() as u64; - if total > crate::package::limits::MAX_TOTAL_BYTES { - return Err(ConvertError::ResourceLimit { - limit: "max_total_bytes", - detail: format!( - "decrypted OOXML package is {total} bytes, over the {} byte budget", - crate::package::limits::MAX_TOTAL_BYTES - ), - }); - } // 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. + // 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) }