From d3e6b4e53f120b0b901c3ecc86ab43c71dd09b3c Mon Sep 17 00:00:00 2001 From: Patrik Simms Date: Sun, 16 Aug 2026 18:39:33 +0200 Subject: [PATCH] feat: accept passwords for encrypted OOXML files Summary: - decrypt password-protected OOXML before format detection and conversion - expose optional passwords through Node, Python, wasm, and the CLI - cover successful, missing, and incorrect password behavior with fixtures Rationale: - callers with a known password can now convert encrypted Office files without changing the existing behavior for callers without one - explicit Rust variants preserve compatibility with existing signatures Tests: - cargo fmt --all --check - cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo test --locked - npm run build && npm test (node) - maturin build --release --locked and Python unit tests Closes #102 AI-Assisted: true AI-Agent: codex AI-Model: openai/gpt-5.6-sol --- Cargo.lock | 150 +++++++++++++++++- Cargo.toml | 1 + README.md | 6 + node/README.md | 4 + node/cli.js | 16 +- node/index.d.ts | 6 +- node/src/lib.rs | 36 ++++- node/test.mjs | 19 +++ python/README.md | 3 + python/anydoc/_anydoc.pyi | 14 +- python/src/lib.rs | 35 +++- python/tests/test_anydoc.py | 7 + src/lib.rs | 82 +++++++++- tests/fixtures/encrypted/password-test.docx | Bin 0 -> 25088 bytes tests/fixtures/encrypted/password-test.xlsx | Bin 0 -> 15360 bytes tests/passwords.rs | 35 ++++ ...pshots__encrypted__password-test.docx.snap | 5 + ...pshots__encrypted__password-test.xlsx.snap | 5 + wasm/README.md | 3 + wasm/src/lib.rs | 31 +++- wasm/test.mjs | 6 + 21 files changed, 426 insertions(+), 38 deletions(-) create mode 100644 tests/fixtures/encrypted/password-test.docx create mode 100644 tests/fixtures/encrypted/password-test.xlsx create mode 100644 tests/passwords.rs create mode 100644 tests/snapshots/snapshots__encrypted__password-test.docx.snap create mode 100644 tests/snapshots/snapshots__encrypted__password-test.xlsx.snap diff --git a/Cargo.lock b/Cargo.lock index a01c9140..7c92d79c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,8 +98,9 @@ dependencies = [ "flate2", "insta", "log", + "office-crypto", "pdf-inspector", - "quick-xml", + "quick-xml 0.41.0", "sha2 0.11.0", "zip", ] @@ -150,6 +151,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" @@ -162,6 +169,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" @@ -195,6 +214,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 = "byteorder" version = "1.5.0" @@ -214,7 +239,7 @@ dependencies = [ "encoding_rs", "fast-float2", "log", - "quick-xml", + "quick-xml 0.41.0", "serde", "zip", ] @@ -476,6 +501,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" @@ -601,6 +637,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" @@ -1057,6 +1099,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" @@ -1069,6 +1132,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" @@ -1180,6 +1265,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" @@ -1205,6 +1299,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" @@ -1248,6 +1348,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" @@ -1365,6 +1474,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" @@ -1422,6 +1542,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" @@ -1444,6 +1575,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" @@ -1730,6 +1867,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 04ed82f8..7440340b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ csv = "1.4.0" flate2 = "1" encoding_rs = "0.8.35" log = "0.4" +office-crypto = "0.3.0" pdf-inspector = "1.14.2" quick-xml = "0.41.0" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } diff --git a/README.md b/README.md index ed4cb9e4..f48a64d8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ markdown = anydoc.to_markdown_bytes(data) # Or name it, which signature-less formats (CSV) need: markdown = anydoc.to_markdown_bytes(data, "csv") +# Password-protected OOXML +markdown = anydoc.to_markdown_bytes(data, password="secret") + # Or stop at the document model, which also carries embedded assets: document = anydoc.to_document(data) ``` @@ -121,6 +124,9 @@ let markdown = anydoc::to_markdown_bytes(&bytes, None)?; // Or name it, which signature-less formats (CSV) need: let markdown = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Csv)?; +// Password-protected OOXML +let markdown = anydoc::to_markdown_bytes_with_password(&bytes, None, "secret")?; + // Or stop at the document model, which also carries embedded assets: let document = anydoc::to_document(&bytes, None)?; ``` diff --git a/node/README.md b/node/README.md index 5263150f..38468487 100644 --- a/node/README.md +++ b/node/README.md @@ -32,8 +32,12 @@ The package ships an `anydoc` command, so `npx` converts a document with no inst npx @firecrawl/anydoc report.docx # Markdown to stdout npx @firecrawl/anydoc slides.pptx -o slides.md # or to a file npx @firecrawl/anydoc - --format csv < data.csv # read stdin +npx @firecrawl/anydoc report.docx --password secret ``` +Set `ANYDOC_PASSWORD` instead of using `--password` when the password should +not appear in shell history or the process list. + Markdown goes to stdout, errors to stderr, and `anydoc --help` covers the rest. ## Usage diff --git a/node/cli.js b/node/cli.js index bfba762a..e662cc4d 100644 --- a/node/cli.js +++ b/node/cli.js @@ -13,7 +13,7 @@ Usage: Converts one document per invocation and writes the Markdown to stdout. Pass - as the input to read the document from stdin. Never prompts; all -diagnostics go to stderr. +diagnostics go to stderr. ANYDOC_PASSWORD is used when --password is omitted. Options: -o, --output Write the Markdown to instead of stdout @@ -21,6 +21,7 @@ Options: ${FORMATS} (extension aliases like xls, docm, ppsx resolve to these) + -p, --password Decrypt a password-protected OOXML document -h, --help Print this help and exit -V, --version Print the version and exit @@ -50,7 +51,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: null } let positionalOnly = false for (let i = 0; i < argv.length; i++) { let arg = argv[i] @@ -95,6 +96,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)`) } @@ -133,12 +138,13 @@ async function main() { let markdown try { + const password = args.password ?? process.env.ANYDOC_PASSWORD if (args.input === '-') { - markdown = await toMarkdownBytes(await readStdin(), format) + markdown = await toMarkdownBytes(await readStdin(), format, password) } else if (format !== undefined) { - markdown = await toMarkdownBytes(await readFile(args.input), format) + markdown = await toMarkdownBytes(await readFile(args.input), format, password) } else { - markdown = await toMarkdown(args.input) + markdown = await toMarkdown(args.input, password) } } catch (error) { fail(CONVERSION_ERROR, error.message) diff --git a/node/index.d.ts b/node/index.d.ts index 27ceba0a..73ecbfd0 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -271,7 +271,7 @@ export declare const enum TableKind { * * Rejects with an `Error` carrying a `ConvertErrorCode` on `code`. */ -export declare function toDocument(bytes: Uint8Array, format?: Format | undefined | null): Promise +export declare function toDocument(bytes: Uint8Array, format?: Format | undefined | null, password?: string | undefined | null): Promise /** * Convert a document file to Markdown. The format is detected from the file @@ -281,7 +281,7 @@ export declare function toDocument(bytes: Uint8Array, format?: Format | undefine * Rejects with an `Error` carrying a `ConvertErrorCode` on `code`; a file * that cannot be read is `'io'`. */ -export declare function toMarkdown(path: string): Promise +export declare function toMarkdown(path: string, password?: string | undefined | null): Promise /** * Convert an in-memory document to Markdown. Without a format, it is @@ -290,4 +290,4 @@ export declare function toMarkdown(path: string): Promise * * 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..edb7bc1d 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -97,8 +97,8 @@ pub fn format_from_path(path: String) -> Option { /// Rejects with an `Error` carrying a `ConvertErrorCode` on `code`; a file /// that cannot be read is `'io'`. #[napi(ts_return_type = "Promise")] -pub fn to_markdown(path: String) -> AsyncTask { - AsyncTask::new(MarkdownFileTask { path, failure: Failure::default() }) +pub fn to_markdown(path: String, password: Option) -> AsyncTask { + AsyncTask::new(MarkdownFileTask { path, password, failure: Failure::default() }) } /// Convert an in-memory document to Markdown. Without a format, it is @@ -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(), }) } @@ -126,10 +128,15 @@ pub fn to_markdown_bytes( /// /// Rejects with an `Error` carrying a `ConvertErrorCode` on `code`. #[napi(ts_return_type = "Promise")] -pub fn to_document(bytes: Uint8Array, format: Option) -> AsyncTask { +pub fn to_document( + bytes: Uint8Array, + format: Option, + password: Option, +) -> AsyncTask { AsyncTask::new(DocumentTask { bytes: bytes.to_vec(), format: format.map(Into::into), + password, failure: Failure::default(), }) } @@ -162,6 +169,7 @@ impl Failure { pub struct MarkdownFileTask { path: String, + password: Option, failure: Failure, } @@ -170,7 +178,11 @@ impl Task for MarkdownFileTask { type JsValue = String; fn compute(&mut self) -> Result { - anydoc::to_markdown(&self.path).map_err(|e| self.failure.capture(e)) + match self.password.as_deref() { + Some(password) => anydoc::to_markdown_with_password(&self.path, password), + None => anydoc::to_markdown(&self.path), + } + .map_err(|e| self.failure.capture(e)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { @@ -185,6 +197,7 @@ impl Task for MarkdownFileTask { pub struct MarkdownBytesTask { bytes: Vec, format: Option, + password: Option, failure: Failure, } @@ -193,7 +206,13 @@ 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)) + match self.password.as_deref() { + Some(password) => { + anydoc::to_markdown_bytes_with_password(&self.bytes, self.format, password) + } + None => anydoc::to_markdown_bytes(&self.bytes, self.format), + } + .map_err(|e| self.failure.capture(e)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { @@ -208,6 +227,7 @@ impl Task for MarkdownBytesTask { pub struct DocumentTask { bytes: Vec, format: Option, + password: Option, failure: Failure, } @@ -216,7 +236,11 @@ impl Task for DocumentTask { type JsValue = Document; fn compute(&mut self) -> Result { - anydoc::to_document(&self.bytes, self.format).map_err(|e| self.failure.capture(e)) + match self.password.as_deref() { + Some(password) => anydoc::to_document_with_password(&self.bytes, self.format, password), + None => anydoc::to_document(&self.bytes, self.format), + } + .map_err(|e| self.failure.capture(e)) } fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { diff --git a/node/test.mjs b/node/test.mjs index ec98a3c6..83e33426 100644 --- a/node/test.mjs +++ b/node/test.mjs @@ -23,6 +23,7 @@ const OUTLINE = fixture('docx/handmade-outline.docx') const RICH = fixture('docx/handmade-rich.docx') const CSV = fixture('csv/sheet.csv') const ENCRYPTED = fixture('malformed/encrypted--errors.odt') +const ENCRYPTED_DOCX = fixture('encrypted/password-test.docx') test('toMarkdown detects the format from the file content', async () => { const markdown = await toMarkdown(OUTLINE) @@ -34,6 +35,12 @@ test('toMarkdownBytes converts in memory', async () => { assert.match(markdown, /\| Quarter \| Widgets \|/) }) +test('password-protected OOXML converts with the password', async () => { + const bytes = await readFile(ENCRYPTED_DOCX) + assert.ok((await toMarkdownBytes(bytes, undefined, 'testPassword')).length > 0) + await assert.rejects(toMarkdownBytes(bytes, undefined, 'wrong'), { code: 'encrypted' }) +}) + test('toMarkdownBytes detects the format when none is named', async () => { const markdown = await toMarkdownBytes(await readFile(RICH)) assert.match(markdown, /\| Quarter \| Widgets \|/) @@ -118,6 +125,18 @@ test('cli reads stdin with an explicit format', async () => { assert.match(stdout, /\| --- \|/) }) +test('cli decrypts OOXML with an argument or environment password', async () => { + const fromArgument = await runCli([ENCRYPTED_DOCX, '--password', 'testPassword']) + assert.equal(fromArgument.code, undefined) + assert.ok(fromArgument.stdout.length > 0) + + const fromEnvironment = await runCli([ENCRYPTED_DOCX], { + env: { ...process.env, ANYDOC_PASSWORD: 'testPassword' }, + }) + assert.equal(fromEnvironment.code, undefined) + assert.ok(fromEnvironment.stdout.length > 0) +}) + test('cli exits 1 when the document cannot be converted', async () => { const { code, stderr } = await runCli(['no-such-file.docx']) assert.equal(code, 1) diff --git a/python/README.md b/python/README.md index fa979712..c67e19a4 100644 --- a/python/README.md +++ b/python/README.md @@ -40,6 +40,9 @@ markdown = anydoc.to_markdown_bytes(data) # Or name it, which signature-less formats (CSV) need: markdown = anydoc.to_markdown_bytes(data, "csv") +# Password-protected OOXML +markdown = anydoc.to_markdown_bytes(data, password="secret") + # Or stop at the document model, which also carries embedded assets: document = anydoc.to_document(data) ``` diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc/_anydoc.pyi index 19dadbad..25c4bbcb 100644 --- a/python/anydoc/_anydoc.pyi +++ b/python/anydoc/_anydoc.pyi @@ -53,17 +53,25 @@ def format_from_extension(extension: str) -> Format | None: def format_from_path(path: str | os.PathLike[str]) -> Format | None: """The format a path's extension names.""" -def to_markdown(path: str | os.PathLike[str]) -> str: +def to_markdown(path: str | os.PathLike[str], password: str | None = None) -> str: """Convert a document file to Markdown. The format is detected from the 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.""" -def to_document(data: bytes | bytearray, format: Format | None = None) -> Document: +def to_document( + data: bytes | bytearray, + format: Format | None = None, + password: str | None = None, +) -> Document: """Parse an in-memory document into the document model, which also carries the embedded assets. Without a format, it is detected from the content. diff --git a/python/src/lib.rs b/python/src/lib.rs index abcbbd62..cc754d33 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -150,18 +150,32 @@ fn format_from_path(path: PathBuf) -> Option<&'static str> { /// content; the extension is the fallback for signature-less formats (CSV) /// and unrecognizable containers. #[pyfunction] -fn to_markdown(py: Python<'_>, path: PathBuf) -> PyResult { - py.detach(|| anydoc::to_markdown(&path)).map_err(|e| convert_error(py, e)) +#[pyo3(signature = (path, password=None))] +fn to_markdown(py: Python<'_>, path: PathBuf, password: Option<&str>) -> PyResult { + py.detach(|| match password { + Some(password) => anydoc::to_markdown_with_password(&path, password), + None => anydoc::to_markdown(&path), + }) + .map_err(|e| convert_error(py, e)) } /// 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. #[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(|| match password { + Some(password) => anydoc::to_markdown_bytes_with_password(&data, format, password), + None => anydoc::to_markdown_bytes(&data, format), + }) + .map_err(|e| convert_error(py, e)) } /// Parse an in-memory document into the document model, which also carries @@ -170,15 +184,20 @@ fn to_markdown_bytes(py: Python<'_>, data: Vec, format: Option<&str>) -> PyR /// Unsupported for `pdf`: PDF conversion produces Markdown directly and has /// no document-model form; use `to_markdown_bytes`. #[pyfunction] -#[pyo3(signature = (data, format=None))] +#[pyo3(signature = (data, format=None, password=None))] fn to_document( py: Python<'_>, data: Vec, format: Option<&str>, + password: Option<&str>, ) -> PyResult { let format = format.map(parse_format).transpose()?; - let parsed = - py.detach(|| anydoc::to_document(&data, format)).map_err(|e| convert_error(py, e))?; + let parsed = py + .detach(|| match password { + Some(password) => anydoc::to_document_with_password(&data, format, password), + None => anydoc::to_document(&data, format), + }) + .map_err(|e| convert_error(py, e))?; document::document(py, parsed) } diff --git a/python/tests/test_anydoc.py b/python/tests/test_anydoc.py index 1328d44a..8b9324c2 100644 --- a/python/tests/test_anydoc.py +++ b/python/tests/test_anydoc.py @@ -13,6 +13,7 @@ RICH = FIXTURES / "docx" / "handmade-rich.docx" CSV = FIXTURES / "csv" / "sheet.csv" ENCRYPTED = FIXTURES / "malformed" / "encrypted--errors.odt" +ENCRYPTED_DOCX = FIXTURES / "encrypted" / "password-test.docx" ZIPBOMB = FIXTURES / "abuse" / "zipbomb--errors.docx" @@ -25,6 +26,12 @@ def test_to_markdown_bytes_converts_in_memory(self): markdown = anydoc.to_markdown_bytes(RICH.read_bytes(), "docx") self.assertIn("| Quarter | Widgets |", markdown) + def test_password_protected_ooxml_converts_with_the_password(self): + data = ENCRYPTED_DOCX.read_bytes() + self.assertTrue(anydoc.to_markdown_bytes(data, password="testPassword")) + with self.assertRaises(anydoc.EncryptedError): + anydoc.to_markdown_bytes(data, password="wrong") + def test_to_markdown_bytes_detects_the_format_when_none_is_named(self): markdown = anydoc.to_markdown_bytes(RICH.read_bytes()) self.assertIn("| Quarter | Widgets |", markdown) diff --git a/src/lib.rs b/src/lib.rs index efba6ff8..858bd077 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub use error::ConvertError; use render::markdown::document_to_markdown; +use std::borrow::Cow; use std::path::Path; /// Input format. Selects the parser; container variants that share a parser @@ -98,7 +99,19 @@ 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 { - let path = path.as_ref(); + to_markdown_path(path.as_ref(), None) +} + +/// Convert a password-protected document file to Markdown. The password is +/// used only when the input is an encrypted OOXML package. +pub fn to_markdown_with_password( + path: impl AsRef, + password: &str, +) -> Result { + to_markdown_path(path.as_ref(), Some(password)) +} + +fn to_markdown_path(path: &Path, password: Option<&str>) -> Result { 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!( @@ -106,7 +119,7 @@ pub fn to_markdown(path: impl AsRef) -> Result { path.display() ))); }; - to_markdown_bytes(&bytes, format) + to_markdown_bytes_impl(&bytes, Some(format), password) } /// Convert an in-memory document to Markdown. Pass a [`Format`] to select the @@ -116,13 +129,31 @@ pub fn to_markdown_bytes( bytes: &[u8], format: impl Into>, ) -> Result { - let format = resolve_format(bytes, format.into())?; + to_markdown_bytes_impl(bytes, format.into(), None) +} + +/// Convert an in-memory document to Markdown, decrypting an encrypted OOXML +/// package with `password` before detecting and parsing its inner format. +pub fn to_markdown_bytes_with_password( + bytes: &[u8], + format: impl Into>, + password: &str, +) -> Result { + to_markdown_bytes_impl(bytes, format.into(), Some(password)) +} + +fn to_markdown_bytes_impl( + bytes: &[u8], + format: Option, + password: Option<&str>, +) -> Result { + let (bytes, format) = prepare_input(bytes, format, password)?; // PDFs convert to Markdown directly (pdf-inspector) without passing // through the document model. if format == Format::Pdf { - return formats::pdf::to_markdown(bytes); + return formats::pdf::to_markdown(&bytes); } - Ok(document_to_markdown(&to_document(bytes, format)?)) + Ok(document_to_markdown(&formats::parse(&bytes, format)?)) } /// Parse an in-memory document into the document model. Pass a [`Format`] to @@ -134,7 +165,46 @@ pub fn to_document( bytes: &[u8], format: impl Into>, ) -> Result { - formats::parse(bytes, resolve_format(bytes, format.into())?) + to_document_impl(bytes, format.into(), None) +} + +/// Parse an in-memory document into the document model, decrypting an +/// encrypted OOXML package with `password` first. +pub fn to_document_with_password( + bytes: &[u8], + format: impl Into>, + password: &str, +) -> Result { + to_document_impl(bytes, format.into(), Some(password)) +} + +fn to_document_impl( + bytes: &[u8], + format: Option, + password: Option<&str>, +) -> Result { + let (bytes, format) = prepare_input(bytes, format, password)?; + formats::parse(&bytes, format) +} + +fn prepare_input<'a>( + bytes: &'a [u8], + format: Option, + password: Option<&str>, +) -> Result<(Cow<'a, [u8]>, Format), ConvertError> { + if matches!(package::archive::probe_ole(bytes), Some(ConvertError::Encrypted)) { + let Some(password) = password else { + return Err(ConvertError::Encrypted); + }; + let decrypted = office_crypto::decrypt_from_bytes(bytes.to_vec(), password) + .map_err(|_| ConvertError::Encrypted)?; + let Some(format) = Format::from_bytes(&decrypted) else { + return Err(ConvertError::Encrypted); + }; + return Ok((Cow::Owned(decrypted), format)); + } + + Ok((Cow::Borrowed(bytes), resolve_format(bytes, format)?)) } fn resolve_format(bytes: &[u8], format: Option) -> Result { diff --git a/tests/fixtures/encrypted/password-test.docx b/tests/fixtures/encrypted/password-test.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/fixtures/encrypted/password-test.xlsx b/tests/fixtures/encrypted/password-test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..bdce29a8d9576fcf1c40d7be4e6d060df4be5325 GIT binary patch literal 15360 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 literal 0 HcmV?d00001 diff --git a/tests/passwords.rs b/tests/passwords.rs new file mode 100644 index 00000000..ea3fa42d --- /dev/null +++ b/tests/passwords.rs @@ -0,0 +1,35 @@ +use anydoc::{ConvertError, Format}; + +const PASSWORD: &str = "testPassword"; + +fn fixture(name: &str) -> Vec { + std::fs::read(format!("tests/fixtures/encrypted/{name}")).unwrap() +} + +#[test] +fn encrypted_ooxml_converts_with_the_password() { + let docx = fixture("password-test.docx"); + let markdown = anydoc::to_markdown_bytes_with_password(&docx, None, PASSWORD).unwrap(); + assert!(!markdown.is_empty()); + + let xlsx = fixture("password-test.xlsx"); + let document = anydoc::to_document_with_password(&xlsx, None, PASSWORD).unwrap(); + assert!(!document.blocks.is_empty()); +} + +#[test] +fn decrypted_content_decides_the_format() { + let docx = fixture("password-test.docx"); + let document = anydoc::to_document_with_password(&docx, Format::Excel, PASSWORD).unwrap(); + assert!(!document.blocks.is_empty()); +} + +#[test] +fn encrypted_ooxml_without_the_right_password_stays_encrypted() { + let docx = fixture("password-test.docx"); + assert!(matches!(anydoc::to_markdown_bytes(&docx, Format::Docx), Err(ConvertError::Encrypted))); + assert!(matches!( + anydoc::to_markdown_bytes_with_password(&docx, Format::Docx, "wrong"), + Err(ConvertError::Encrypted) + )); +} diff --git a/tests/snapshots/snapshots__encrypted__password-test.docx.snap b/tests/snapshots/snapshots__encrypted__password-test.docx.snap new file mode 100644 index 00000000..bd8df238 --- /dev/null +++ b/tests/snapshots/snapshots__encrypted__password-test.docx.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +ERROR: document is encrypted diff --git a/tests/snapshots/snapshots__encrypted__password-test.xlsx.snap b/tests/snapshots/snapshots__encrypted__password-test.xlsx.snap new file mode 100644 index 00000000..bd8df238 --- /dev/null +++ b/tests/snapshots/snapshots__encrypted__password-test.xlsx.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +ERROR: document is encrypted diff --git a/wasm/README.md b/wasm/README.md index a2e0dfd2..ed554845 100644 --- a/wasm/README.md +++ b/wasm/README.md @@ -34,6 +34,9 @@ The package is built with `wasm-pack --target web`: it loads with a plain ` 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 { + match password.as_deref() { + Some(password) => anydoc::to_markdown_bytes_with_password( + bytes, + format.map(anydoc::Format::from), + password, + ), + None => anydoc::to_markdown_bytes(bytes, format.map(anydoc::Format::from)), + } + .map_err(convert_error) } /// Parse an in-memory document into the document model, which also carries @@ -111,9 +123,18 @@ pub fn to_markdown_bytes(bytes: &[u8], format: Option) -> Result) -> Result { - let document = - anydoc::to_document(bytes, format.map(anydoc::Format::from)).map_err(convert_error)?; +pub fn to_document( + bytes: &[u8], + format: Option, + password: Option, +) -> Result { + let document = match password.as_deref() { + Some(password) => { + anydoc::to_document_with_password(bytes, format.map(anydoc::Format::from), password) + } + None => anydoc::to_document(bytes, format.map(anydoc::Format::from)), + } + .map_err(convert_error)?; serde_wasm_bindgen::to_value(&Document::from(document)) .map_err(|error| js_sys::Error::new(&error.to_string()).into()) } diff --git a/wasm/test.mjs b/wasm/test.mjs index afcebfcc..41fd209c 100644 --- a/wasm/test.mjs +++ b/wasm/test.mjs @@ -23,12 +23,18 @@ const RICH = await readFile(fixture('docx/handmade-rich.docx')) const CSV = await readFile(fixture('csv/sheet.csv')) const PDF = await readFile(fixture('pdf/text.pdf')) const ENCRYPTED = await readFile(fixture('malformed/encrypted--errors.odt')) +const ENCRYPTED_DOCX = await readFile(fixture('encrypted/password-test.docx')) test('toMarkdownBytes converts in memory', () => { const markdown = toMarkdownBytes(RICH, 'docx') assert.match(markdown, /\| Quarter \| Widgets \|/) }) +test('password-protected OOXML converts with the password', () => { + assert.ok(toMarkdownBytes(ENCRYPTED_DOCX, undefined, 'testPassword').length > 0) + assert.throws(() => toMarkdownBytes(ENCRYPTED_DOCX, undefined, 'wrong'), { code: 'encrypted' }) +}) + test('toMarkdownBytes detects the format when none is named', () => { assert.match(toMarkdownBytes(RICH), /\| Quarter \| Widgets \|/) // CSV carries no signature, so it has to be named.