Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ match anydoc::to_markdown(path) {
| `NeedsOcr` | Scanned or image-only pages of a PDF, listed in `pages` |
| `Malformed` | Structurally unusable: no meaningful content could be extracted |
| `Encrypted` | Encrypted or password-protected |
| `ResourceLimit` | Crossed a fixed safety limit (decompression, nesting, node count) |
| `ResourceLimit` | Crossed a fixed safety limit or encountered a recoverable allocation failure |
| `MissingPart` | A part required for any meaningful output is absent |
| `Io` | The file could not be read, from `to_markdown` only |

Expand Down
2 changes: 1 addition & 1 deletion node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ try {
| `needsOcr` | Scanned or image-only pages of a PDF, listed in `pages` |
| `malformed` | Structurally unusable: no meaningful content could be extracted |
| `encrypted` | Encrypted or password-protected |
| `resourceLimit` | Crossed a fixed safety limit (decompression, nesting, node count) |
| `resourceLimit` | Crossed a fixed safety limit or encountered a recoverable allocation failure |
| `missingPart` | A part required for any meaningful output is absent |
| `io` | The file could not be read, from `toMarkdown` only |
| `hosted` | `ocr: 'hosted'` could not get the document through Firecrawl Parse |
Expand Down
2 changes: 1 addition & 1 deletion python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ except (anydoc.EncryptedError, anydoc.UnsupportedError) as error:
| `NeedsOcrError` | Scanned or image-only pages of a PDF, listed in `pages` |
| `MalformedError` | Structurally unusable: no meaningful content could be extracted |
| `EncryptedError` | Encrypted or password-protected |
| `ResourceLimitError` | Crossed a fixed safety limit (decompression, nesting, node count) |
| `ResourceLimitError` | Crossed a fixed safety limit or encountered a recoverable allocation failure |
| `MissingPartError` | A part required for any meaningful output is absent |
| `HostedError` | `ocr="hosted"` could not get the document through Firecrawl Parse |
| `OSError` | The file could not be read, from `to_markdown` only |
Expand Down
5 changes: 3 additions & 2 deletions python/anydoc/_anydoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ class EncryptedError(ConvertError):

class ResourceLimitError(ConvertError):
"""A fixed safety limit was crossed: decompression, nesting depth, node
count, repeat expansion, or retained asset bytes."""
count, repeat expansion, or retained asset bytes; or an allocation failed
recoverably."""

limit: str
"""The limit that was crossed, e.g. `max_entry_bytes`."""
"""The limit that was crossed, e.g. `max_entry_bytes`, or `memory_allocation`."""

class MissingPartError(ConvertError):
"""A part required for any meaningful output is absent."""
Expand Down
3 changes: 2 additions & 1 deletion python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ create_exception!(
ResourceLimitError,
ConvertError,
"A fixed safety limit was crossed: decompression, nesting depth, node \
count, repeat expansion, or retained asset bytes. `limit` names it."
count, repeat expansion, or retained asset bytes; or an allocation failed \
recoverably. `limit` names the limit or is `memory_allocation`."
);

create_exception!(
Expand Down
12 changes: 7 additions & 5 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
//!
//! An error means a complete conversion was impossible: the input was
//! unreadable or structurally unusable, encrypted, or crossed a fixed
//! safety/resource limit. Recoverable producer quirks never surface here -
//! safety/resource limit, or encountered a recoverable allocation failure.
//! Recoverable producer quirks never surface here -
//! they are recovered or skipped (and logged via the `log` facade) while
//! conversion continues.

Expand Down Expand Up @@ -34,8 +35,9 @@ pub enum ConvertError {
/// The document is encrypted or password-protected.
Encrypted,
/// A fixed safety limit was exceeded (decompression, nesting depth, node
/// count, repeat expansion, retained asset bytes). These are hard errors
/// in every case; see `package::limits` for the documented defaults.
/// count, repeat expansion, retained asset bytes), or an allocation failed
/// recoverably (`memory_allocation`). These are hard errors in every case;
/// see `package::limits` for the documented fixed limits.
ResourceLimit {
/// Name of the limit that was hit.
limit: &'static str,
Expand Down Expand Up @@ -117,8 +119,8 @@ impl ConvertError {
ConvertError::Malformed { part: Some(part.into()), detail: detail.into() }
}

/// True when recovery must not swallow this error: fixed safety limits
/// hard-fail in every context, including optional parts.
/// True when recovery must not swallow this error: resource limits and
/// recoverable allocation failures hard-fail even for optional parts.
pub(crate) fn is_fatal(&self) -> bool {
matches!(self, ConvertError::ResourceLimit { .. })
}
Expand Down
99 changes: 92 additions & 7 deletions src/package/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,8 @@ impl<'a> Package<'a> {
// remains of the whole-archive total.
let remaining_total = limits::MAX_TOTAL_BYTES.saturating_sub(self.total_read);
let cap = limits::MAX_ENTRY_BYTES.min(remaining_total);
let mut bytes = Vec::new();
let read = (&mut file).take(cap + 1).read_to_end(&mut bytes).map_err(|e| {
ConvertError::Malformed {
part: Some(name.to_string()),
detail: format!("corrupt archive entry: {e}"),
}
})? as u64;
let bytes = read_part_bytes((&mut file).take(cap + 1), name)?;
let read = bytes.len() as u64;
if read > cap {
return Err(if remaining_total < limits::MAX_ENTRY_BYTES {
ConvertError::ResourceLimit {
Expand Down Expand Up @@ -138,6 +133,22 @@ impl<'a> Package<'a> {
}
}

/// Read an already capped entry before committing it to the package cache.
fn read_part_bytes(mut reader: impl Read, name: &str) -> Result<Vec<u8>, ConvertError> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).map_err(|e| match e.kind() {
std::io::ErrorKind::OutOfMemory => ConvertError::ResourceLimit {
limit: "memory_allocation",
detail: format!("allocation failed while reading {name}: {e}"),
},
_ => ConvertError::Malformed {
part: Some(name.to_string()),
detail: format!("corrupt archive entry: {e}"),
},
})?;
Ok(bytes)
}

/// 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.
Expand Down Expand Up @@ -169,6 +180,80 @@ mod tests {
w.finish().unwrap().into_inner()
}

struct FailingReader(std::io::ErrorKind);

impl Read for FailingReader {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
Err(self.0.into())
}
}

#[test]
fn allocation_read_errors_are_fatal_resource_limits() {
for prefix in [b"".as_slice(), b"partial content".as_slice()] {
let reader = Cursor::new(prefix).chain(FailingReader(std::io::ErrorKind::OutOfMemory));
let err = read_part_bytes(reader, "word/document.xml").unwrap_err();
assert!(
matches!(
&err,
ConvertError::ResourceLimit { limit: "memory_allocation", detail }
if detail.contains("word/document.xml")
),
"expected an allocation resource limit, got: {err}"
);
assert_eq!(err.code(), "resourceLimit");
assert!(err.is_fatal(), "optional parts must not swallow allocation failures");
}
}

#[test]
fn invalid_data_read_errors_remain_malformed() {
for prefix in [b"".as_slice(), b"partial content".as_slice()] {
let reader = Cursor::new(prefix).chain(FailingReader(std::io::ErrorKind::InvalidData));
let err = read_part_bytes(reader, "word/document.xml").unwrap_err();
assert!(matches!(
&err,
ConvertError::Malformed { part: Some(part), detail }
if part == "word/document.xml"
&& detail == &format!("corrupt archive entry: {}",
std::io::Error::from(std::io::ErrorKind::InvalidData))
));
assert_eq!(err.code(), "malformed");
assert!(!err.is_fatal());
}
}

#[test]
fn corrupt_entry_is_not_cached_or_charged() {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
writer
.start_file(
"word/document.xml",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
writer.write_all(b"<document/>").unwrap();
let mut data = writer.finish().unwrap().into_inner();
let start = zip::ZipArchive::new(Cursor::new(data.as_slice()))
.unwrap()
.by_name("word/document.xml")
.unwrap()
.data_start()
.unwrap() as usize;
data[start] ^= 1; // Change the payload without updating its CRC.
let mut pkg = Package::open(&data).unwrap();
let err = pkg.part("word/document.xml").unwrap_err();
assert!(matches!(&err, ConvertError::Malformed { part: Some(part), detail }
if part == "word/document.xml" && detail.starts_with("corrupt archive entry: ")));
assert!(!err.is_fatal());
assert!(pkg.cache.is_empty());
assert_eq!(pkg.total_read, 0);
assert!(pkg.optional_part("word/document.xml").unwrap().is_none());
assert!(pkg.cache.is_empty());
assert_eq!(pkg.total_read, 0);
}

#[test]
fn repeated_reads_are_cached_and_charged_once() {
let data = one_part_zip("media/a.bin", &[7u8; 4096]);
Expand Down
2 changes: 1 addition & 1 deletion wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ try {
| `needsOcr` | Pages of a PDF are scanned or image-only; `pages` names them |
| `malformed` | Structurally unusable: no meaningful content could be extracted |
| `encrypted` | Encrypted or password-protected |
| `resourceLimit` | Crossed a fixed safety limit (decompression, nesting, node count) |
| `resourceLimit` | Crossed a fixed safety limit or encountered a recoverable allocation failure |
| `missingPart` | A part required for any meaningful output is absent |

`error.message` carries the detail, naming the package part at fault where the format identifies one. TypeScript gets the union as `ConvertErrorCode`. The crate's `io` code has no counterpart here: there is no filesystem to read from.
Expand Down