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 00000000..757c285f Binary files /dev/null and b/tests/fixtures/encrypted/password-test.docx differ diff --git a/tests/fixtures/encrypted/password-test.xlsx b/tests/fixtures/encrypted/password-test.xlsx new file mode 100644 index 00000000..bdce29a8 Binary files /dev/null and b/tests/fixtures/encrypted/password-test.xlsx differ 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.