Skip to content

feat: add MHTML support - #149

Open
marcellmanfrin wants to merge 37 commits into
firecrawl:mainfrom
marcellmanfrin:feature/mhtml-support-pr
Open

feat: add MHTML support#149
marcellmanfrin wants to merge 37 commits into
firecrawl:mainfrom
marcellmanfrin:feature/mhtml-support-pr

Conversation

@marcellmanfrin

@marcellmanfrin marcellmanfrin commented Aug 28, 2026

Copy link
Copy Markdown

Scope

Adds MHTML/MHT parsing and conversion, completing the MHTML portion of #52.

This head is explicitly reconciled with standalone HTML PR #147 head c6b7bb18608d4bde426a71d9a5300070d8f653fc.

The reconciled functional commit 32c647468193fbff6e4d21b07ab8395295e79e75 is a direct child of that #147 head. Its tree is 48566f9d8eee0e9836c7b48a05ea05c827a1a1ec, exactly the same tree as the previously validated/reviewed #149 head 17fe665d8f921bb1a881bb33a5ec4b87ceb43dd5. The reconciliation therefore changes ancestry only; it does not change the MHTML/HTML code content.

Summary

  • adds Format::Mhtml with .mhtml / .mht support
  • conservatively detects Chrome/Blink-style multipart/related archives without classifying ordinary multipart email as MHTML
  • decodes quoted-printable/base64 and honors MIME charset and start
  • resolves Content-ID, Content-Location, relative URLs, <base href>, and case-insensitive cid: references
  • preserves linked/inline CSS document order and embedded image assets
  • preflights decoded MHTML HTML roots before constructing the MHTML DOM

#147 reconciliation and realistic corpus

The branches were reconciled semantically rather than by blindly taking either side's HTML implementation. The current #149 commit is now directly based on the validated #147 head, while retaining the exact content tree of the previous #149 head. This proves the latest #147 anchor-recovery change was already present semantically in #149 and no MHTML-specific behavior was lost during the ancestry rewrite.

The #149 tree includes the shared HTML list fixes from #147, its controlled/LibreOffice HTML corpus, HTML list/corpus regressions and snapshots, while preserving the MHTML-specific MIME, charset, resource-resolution, CSS, and asset behavior.

tests/mhtml_corpus.rs reuses the #147 fixtures. It validates byte-for-byte standalone HTML vs MHTML invariance where MIME should not change semantics, base64 UTF-8 roots, realistic linked CSS and embedded images, deliberate relative-image resolution through MHTML Content-Location, and conservative handling of ordinary MIME messages.

Review follow-up fixes

The current head includes the earlier review fixes plus the latest Cubic follow-up:

  • bare relative references no longer match a MIME part solely because its Content-ID has the same bare text; Content-ID resources require cid: semantics
  • fragments on a part's Content-Location are removed from the resource-index key, matching fragment-stripped lookup behavior
  • HTML complexity preflight models HTML5 repair of repeated unclosed <a> start tags without accumulating false nesting depth
  • anchor recovery removes only the prior active anchor from the preflight stack instead of truncating intervening structural elements, preserving eager structural-depth accounting
  • review-specific regressions were moved into the discoverable feature suites: HTML cases in tests/html.rs, MHTML resource-resolution cases in tests/mhtml.rs; tests/review_followup.rs was removed

The earlier resource-safety fix remains: decoded MHTML HTML roots run preflight_html_complexity before the MHTML DOM is materialized.

The previous Cubic review of the identical content tree completed with 0 new issues in the latest delta. Because the #147 reconciliation rewrote branch ancestry, Cubic intentionally skipped its automatic review of the rewritten head. A manual re-review was triggered for exact head 32c647468193fbff6e4d21b07ab8395295e79e75 (check 99186037778); it has reviewed all 4 files and is currently completing its final review processing.

A separate P3 performance/maintainability thread about multiple HTML passes remains intentionally unchanged after audit: the bounded charset sniff, streaming pre-DOM complexity preflight, and actual HTML5 tree construction serve distinct purposes. Removing the full-document preflight would weaken pre-DOM resource enforcement; combining it with HTML5 tree construction would require a substantially larger parser/tree-sink refactor.

Fresh GitHub Actions validation

Validated functional SHA: 32c647468193fbff6e4d21b07ab8395295e79e75.

Parent HTML SHA: c6b7bb18608d4bde426a71d9a5300070d8f653fc (current validated head of #147).

Temporary CI-only branch: verify/mhtml-reconciled-c6b7bb1-full.
Temporary CI-only commit used to launch the authoritative gate: 156d6d858e1d5349e8716e278b329830187a071f. This commit is not part of the PR code; its sole parent is the validated functional SHA above, and the workflow explicitly checks out that SHA detached before testing.

Authoritative verification: run 33284379175, job 99184901026 — success.

  • HTML and MHTML regressions: passed (html, html_corpus, html_list, mhtml, mhtml_charset, mhtml_corpus)
  • Rust cargo fmt --all -- --check: passed
  • Rust workspace/all-targets/all-features Clippy with -D warnings: passed
  • Rust cargo test --locked: passed
  • Node npm ci, build, tests, and generated binding determinism: passed
  • WASM wasm32 Clippy, wasm-pack 0.15.0 release build, runtime tests: passed
  • Python locked Maturin release wheel, installed compiled site-packages module, full unittest suite: passed
  • real MHTML fixture validation: passed
  • exact candidate identity after all builds/tests: passed

The current PR head is exactly the validated functional SHA above.

Real MHTML validation

The authoritative run reconstructed and SHA-256-verified the original 505,883-byte SEI/TRE-PB Chrome/Blink MHTML fixture and converted it directly.

  • input SHA-256: 387c6f2d7223da224a8f55962b97eac947734c97fc84888e1e5619e93745837c
  • detected directly as mhtml
  • direct conversion without manual HTML extraction
  • expected Portuguese TRE-PB/ETP content present
  • no , Ã, or  mojibake markers
  • output size: 78,154 bytes
  • output SHA-256: f6f75cec7bc06afe038bd89cd7967e5af358ec3562553f5bfc5c47c0299eb80c

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 21 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/formats/html.rs
Comment thread src/formats/html.rs Outdated
Comment thread src/formats/html.rs Outdated
Comment thread src/formats/html.rs
Comment thread src/formats/detect.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread tests/mhtml.rs Outdated
@marcellmanfrin
marcellmanfrin force-pushed the feature/mhtml-support-pr branch from af6e811 to 049b63a Compare August 28, 2026 19:36
@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai review this pull request

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review this pull request

@marcellmanfrin I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 21 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/formats/detect.rs
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/html.rs Outdated
Comment thread src/formats/html.rs
Comment thread tests/mhtml.rs
Comment thread src/formats/html.rs Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/mhtml.rs">

<violation number="1" location="src/formats/mhtml.rs:152">
P3: The new `html_resource_base` re-parses the entire root HTML with `Html::parse_document`, and `collect_stylesheets_in_order` parses the same string again immediately after (plus a third parse inside `parse_text_with_context`). Parsing the document twice for resource-base extraction adds a redundant full-DOM-build of a potentially large HTML root. Parse the document once in `parse()` and pass the parsed root (or the extracted base) into both helpers to avoid the duplicate parse.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/mhtml.rs Outdated
@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai Please re-review the two still-open older threads on this PR against the current head "f23fdfdddf6ec2de475c4727bb2455ec8b11a956":

  1. "src/lib.rs" — ordinary "multipart/related; type="text/html"" email being misdetected as MHTML (review comment 3882775384). The current implementation requires archive-specific evidence, and the regression "ordinary_related_html_email_is_not_mhtml" passes.

  2. "src/formats/detect.rs" — PDF-vs-HTML marker precedence (review comment 3883736268). The current detector behavior is covered by the passing regressions "pdf_header_before_html_marker_remains_pdf" and "html_prefix_wins_over_embedded_pdf_marker".

The current head has passed the complete AnyDoc validation gate, and the real 505,883-byte Chrome/Blink MHTML fixture was also converted directly and validated successfully.

Please verify whether these two findings still apply to the current head and resolve/update the old threads accordingly if they are now addressed.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

@cubic-dev-ai Please re-review the two still-open older threads on this PR against the current head "f23fdfdddf6ec2de475c4727bb2455ec8b11a956":

  1. "src/lib.rs" — ordinary "multipart/related; type="text/html"" email being misdetected as MHTML (review comment 3882775384). The current implementation requires archive-specific evidence, and the regression "ordinary_related_html_email_is_not_mhtml" passes.

  2. "src/formats/detect.rs" — PDF-vs-HTML marker precedence (review comment 3883736268). The current detector behavior is covered by the passing regressions "pdf_header_before_html_marker_remains_pdf" and "html_prefix_wins_over_embedded_pdf_marker".
    ...

@marcellmanfrin I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 21 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/mhtml.rs">

<violation number="1" location="src/formats/mhtml.rs:158">
P2: When an MHTML document contains `<base href="">`, relative embedded resources stop resolving against the root `Content-Location`. Treat an empty resolved base as absent so the root location remains the fallback.</violation>

<violation number="2" location="src/formats/mhtml.rs:418">
P2: When an embedded image or stylesheet URL includes a fragment, the lookup retains the fragment even though MIME parts are keyed by the underlying resource URL. Strip fragments for embedded-resource matching while preserving them for external image URLs.</violation>

<violation number="3" location="src/formats/mhtml.rs:424">
P2: When an MHTML root has no usable URL base, protocol-relative images are dropped because `is_absolute_uri` rejects `//host/path`. Preserve network-path references as external images, matching the standalone HTML frontend.</violation>
</file>

<file name="src/formats/html.rs">

<violation number="1" location="src/formats/html.rs:246">
P2: Alternating 257 heading tags triggers `max_xml_depth` before HTML5 parsing can repair them, because `close_implied_before_start` does not close prior `h1`–`h6` elements. Track heading tags as mutually implied-closing elements so malformed HTML is not rejected solely by the preflight stack approximation.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/mhtml.rs
Comment thread src/formats/html.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 21 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/mhtml.rs
Comment thread src/formats/html.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 21 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/html.rs
Comment thread src/formats/mhtml.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/mhtml.rs">

<violation number="1" location="src/formats/mhtml.rs:246">
P2: For real multipart content types this comparison is always false, so `mime_boundary_from_headers` now returns `None` for every part and the MIME preflight guards are never reached. `ContentType::ctype()` in mail-parser 0.11.8 returns the full type/subtype (e.g. "multipart/related"), not the primary type "multipart"; the primary type requires `get_type()`. Because the only callers are lines 142 and 185 of the preflight, this effectively disables the base64-decoder `max_entry_bytes` reserve check and the `max_mime_depth` nesting check for any multipart MHTML, re-enabling the resource-exhaustion path the guards were added to block. Use `content_type.is_multipart()` instead.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/mhtml.rs
// mail-parser only nests multipart media types. A `boundary` parameter on
// any other media type does not create nested MIME parts, so the preflight
// must not treat boundary-looking body text there as nested MIME.
if !content_type.ctype().eq_ignore_ascii_case("multipart") {

@cubic-dev-ai cubic-dev-ai Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For real multipart content types this comparison is always false, so mime_boundary_from_headers now returns None for every part and the MIME preflight guards are never reached. ContentType::ctype() in mail-parser 0.11.8 returns the full type/subtype (e.g. "multipart/related"), not the primary type "multipart"; the primary type requires get_type(). Because the only callers are lines 142 and 185 of the preflight, this effectively disables the base64-decoder max_entry_bytes reserve check and the max_mime_depth nesting check for any multipart MHTML, re-enabling the resource-exhaustion path the guards were added to block. Use content_type.is_multipart() instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/mhtml.rs, line 246:

<comment>For real multipart content types this comparison is always false, so `mime_boundary_from_headers` now returns `None` for every part and the MIME preflight guards are never reached. `ContentType::ctype()` in mail-parser 0.11.8 returns the full type/subtype (e.g. "multipart/related"), not the primary type "multipart"; the primary type requires `get_type()`. Because the only callers are lines 142 and 185 of the preflight, this effectively disables the base64-decoder `max_entry_bytes` reserve check and the `max_mime_depth` nesting check for any multipart MHTML, re-enabling the resource-exhaustion path the guards were added to block. Use `content_type.is_multipart()` instead.</comment>

<file context>
@@ -238,12 +238,15 @@ fn mime_header_body_start(bytes: &[u8], start: usize, end: usize) -> Option<(usi
+    // mail-parser only nests multipart media types. A `boundary` parameter on
+    // any other media type does not create nested MIME parts, so the preflight
+    // must not treat boundary-looking body text there as nested MIME.
+    if !content_type.ctype().eq_ignore_ascii_case("multipart") {
+        return None;
+    }
</file context>
Suggested change
if !content_type.ctype().eq_ignore_ascii_case("multipart") {
if !content_type.is_multipart() {
Fix with cubic

@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai ultrareview: focus on the MHTML MIME preflight resource limits (base64 decoder reserve checks, MIME nesting depth, and the multipart media-type guard in mime_boundary_from_headers), HTML/MHTML charset and encoding handling, and format detection precedence.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 31, 2026

Copy link
Copy Markdown

@cubic-dev-ai ultrareview: focus on the MHTML MIME preflight resource limits (base64 decoder reserve checks, MIME nesting depth, and the multipart media-type guard in mime_boundary_from_headers), HTML/MHTML charset and encoding handling, and format detection precedence.

@marcellmanfrin Couldn't start the ultrareview: PR author marcellmanfrin has no assigned seat on this installation. Please, assign one at https://www.cubic.dev/settings?tab=subscription.

@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai Please start a fresh AI code review of the current head 108180ecf67691161ae72d396fb26cd559e358b0, covering the full PR diff (all files), not only the changes from recent commits. This head integrates the standalone HTML work from #147 (frameset and relative-image fixes), the MHTML resource-hardening follow-up from marcellmanfrin#4, and the multipart media-type guard fix for the MIME preflight. A full local gate (Rust fmt/clippy/tests, Node bindings, WASM, Python wheel + unittest) and both canonical real-fixture conversions (MHTML and standalone root HTML, clean Portuguese encoding, zero U+FFFD) passed on this exact SHA.

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 31, 2026

Copy link
Copy Markdown

@cubic-dev-ai Please start a fresh AI code review of the current head 108180ecf67691161ae72d396fb26cd559e358b0, covering the full PR diff (all files), not only the changes from recent commits. This head integrates the standalone HTML work from #147 (frameset and relative-image fixes), the MHTML resource-hardening follow-up from marcellmanfrin#4, and the multipart media-type guard fix for the MIME preflight. A full local gate (Rust fmt/clippy/tests, Node bindings, WASM, Python wheel + unittest) and both canonical real-fixture conversions (MHTML and standalone root HTML, clean Portuguese encoding, zero U+FFFD) passed on this exact SHA.

@marcellmanfrin I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 2 new issues found across 41 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/html.rs">

<violation number="1" location="src/formats/html.rs:76">
P3: When the HTML root is a frameset, this returns Document::default() and silently discards the embedded image `assets` and ordered stylesheets that were already collected and size-limit-checked by the caller. For MHTML this drops all embedded resources even though they were valid. Assign the collected assets (and stylesheets) to the returned document instead of discarding them.</violation>
</file>

<file name="src/lib.rs">

<violation number="1" location="src/lib.rs:50">
P2: Adding `Html` and `Mhtml` to this public enum breaks downstream users with exhaustive `match` expressions. Ship this behind a major-version release, or establish a non-exhaustive enum API before adding further variants.</violation>
</file>

Requires human review: Auto-approval blocked by 1 unresolved issue from a previous review of this commit.

Fix all with cubic | Re-trigger cubic

Comment thread src/lib.rs
/// EPUB 2 and 3 (`.epub`).
Epub,
/// Standalone HTML5 (`.html`, `.htm`). JavaScript is not executed.
Html,

@cubic-dev-ai cubic-dev-ai Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Adding Html and Mhtml to this public enum breaks downstream users with exhaustive match expressions. Ship this behind a major-version release, or establish a non-exhaustive enum API before adding further variants.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/lib.rs, line 50:

<comment>Adding `Html` and `Mhtml` to this public enum breaks downstream users with exhaustive `match` expressions. Ship this behind a major-version release, or establish a non-exhaustive enum API before adding further variants.</comment>

<file context>
@@ -46,6 +46,11 @@ pub enum Format {
     /// EPUB 2 and 3 (`.epub`).
     Epub,
+    /// Standalone HTML5 (`.html`, `.htm`). JavaScript is not executed.
+    Html,
+    /// MIME HTML aggregate (`.mhtml`, `.mht`). Resources are resolved only
+    /// from MIME parts embedded in the input; no network requests are made.
</file context>
Fix with cubic

Comment thread src/formats/html.rs Outdated
let body = match root.descendent_elements().find(|e| e.value().name() == "body") {
Some(body) => body,
None if root.descendent_elements().any(|e| e.value().name() == "frameset") => {
return Ok(Document::default());

@cubic-dev-ai cubic-dev-ai Bot Aug 31, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When the HTML root is a frameset, this returns Document::default() and silently discards the embedded image assets and ordered stylesheets that were already collected and size-limit-checked by the caller. For MHTML this drops all embedded resources even though they were valid. Assign the collected assets (and stylesheets) to the returned document instead of discarding them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/html.rs, line 76:

<comment>When the HTML root is a frameset, this returns Document::default() and silently discards the embedded image `assets` and ordered stylesheets that were already collected and size-limit-checked by the caller. For MHTML this drops all embedded resources even though they were valid. Assign the collected assets (and stylesheets) to the returned document instead of discarding them.</comment>

<file context>
@@ -0,0 +1,543 @@
+    let body = match root.descendent_elements().find(|e| e.value().name() == "body") {
+        Some(body) => body,
+        None if root.descendent_elements().any(|e| e.value().name() == "frameset") => {
+            return Ok(Document::default());
+        }
+        None => {
</file context>
Fix with cubic

Comment thread src/formats/mhtml.rs Outdated
Marcell Manfrin added 2 commits August 31, 2026 20:12
Cubic finding (PR 149, src/formats/html.rs): the frameset branch of
document_from_parsed_html returned Document::default(), silently
discarding the assets that had already been collected and checked
against the size limits. The loss is observable for MHTML archives
whose HTML root is a frameset document: embedded image parts vanished
from the resulting Document.

Return Document { assets, ..Document::default() } in the frameset
branch. Regression: an MHTML archive with a frameset root and one
image part retains the asset while still producing no blocks.
Cubic finding (PR 149, src/formats/mhtml.rs): MhtmlCtx::link_target and
MhtmlCtx::anchor_id duplicated StandaloneCtx verbatim. Move both
implementations into HtmlCtx as default trait methods so only the
image_source hook (which genuinely differs per frontend) and
frontends with scoped link semantics (EPUB chapters) carry their own
implementations. Behavior is unchanged for every frontend.
@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/formats/html.rs (frameset discards collected assets, run a58250e3)

@cubic-dev-ai Please re-check this finding against the current head bfebd996c8b0e760035aa9a131a0740a656176e9. It is fixed by commit da61872: the frameset branch of document_from_parsed_html now returns Document { assets, ..Document::default() }, retaining the already collected and size-limit-checked assets. The regression frameset_root_preserves_collected_assets (MHTML archive with a frameset root plus one image part retains the asset while producing no blocks) passes, and the full gate plus both canonical real-fixture conversions passed on this exact SHA. If the finding is addressed, please resolve the thread.

@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/lib.rs:50 (Format enum variants, run a58250e3)

Addressed by #154, which marks Format #[non_exhaustive] as a standalone API-compatibility change (with defensive wildcard arms in the Node/WASM bindings; the Python bindings are lookup-based and unaffected). This crate is 0.x, so adding variants is semver-legal either way; #154 removes the downstream breakage concern. If #154 lands first, this PR's next rebase will also carry the wildcard arms in the binding matches it already edits.

@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/formats/mhtml.rs:246 (ContentType::ctype() comparison, run 6bf94ab5)

@cubic-dev-ai Please re-check this finding against the current head bfebd996c8b0e760035aa9a131a0740a656176e9. I believe it is a false positive for mail-parser 0.11.8:

  1. Source: ContentType::ctype() returns &self.c_type (core/header.rs), and the content-type parser fills c_type in ContentState::Type — the token before /, lowercased (parsers/fields/content_type.rs). For multipart/related, ctype() returns "multipart", not the full type/subtype. The primary/sub split is exactly what Message::is_content_type relies on (ct.c_type.eq_ignore_ascii_case(type_)).
  2. Tests: at this head, excessive_multipart_nesting_is_rejected_before_mime_parse (300 levels of real multipart nesting → max_mime_depth error) and base64_decoder_preflight_rejects_large_reserve_before_mime_parse both pass. They require mime_boundary_from_headers to return Some for real multipart parts, i.e. the guards demonstrably remain active.
  3. The suggested content_type.is_multipart() does not exist on ContentType in 0.11.8 (it is a MessagePart method checking PartType::Multipart), so the suggested fix cannot compile at this site.

If the finding is addressed by this evidence, please resolve the thread.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Re: review thread on src/formats/html.rs (frameset discards collected assets, run a58250e3)

@cubic-dev-ai Please re-check this finding against the current head bfebd996c8b0e760035aa9a131a0740a656176e9. It is fixed by commit da61872: the frameset branch of document_from_parsed_html now returns Document { assets, ..Document::default() }, retaining the already collected and size-limit-checked assets. The regression frameset_root_preserves_collected_assets (MHTML archive with a frameset root plus one image part retains the asset while producing no blocks) passes, and the full gate plus both canonical real-fixture conversions passed on this exact SHA. If the finding is addressed, please resolve the thread.

@marcellmanfrin I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Re: review thread on src/formats/mhtml.rs:246 (ContentType::ctype() comparison, run 6bf94ab5)

@cubic-dev-ai Please re-check this finding against the current head bfebd996c8b0e760035aa9a131a0740a656176e9. I believe it is a false positive for mail-parser 0.11.8:

  1. Source: ContentType::ctype() returns &self.c_type (core/header.rs), and the content-type parser fills c_type in ContentState::Type — the token before /, lowercased (parsers/fields/content_type.rs). For multipart/related, ctype() returns "multipart", not the full type/subtype. The primary/sub split is exactly what Message::is_content_type relies on (ct.c_type.eq_ignore_ascii_case(type_)).
    ...

@marcellmanfrin You're right, and this finding looks wrong — I'm retracting it.

Verified against mail-parser 0.11.8's actual API:

  1. ContentType::ctype() returns &self.c_type, the primary type only ("multipart" for multipart/related); the subtype lives in the separate c_subtype field. That's exactly the split mail-parser's own mime_type() helper relies on internally, matching ctype() against "multipart", "text", etc. So the guard content_type.ctype().eq_ignore_ascii_case("multipart") is the correct way to test "is this a real multipart media type" on the ContentType object.

  2. is_multipart() does not exist on ContentType in 0.11.8 — it's a MessagePart method that checks matches!(self.body, PartType::Multipart(_)). So my suggested fix couldn't compile at that site, which alone sinks the comment.

  3. The pass/fail behavior you describe matches: excessive_multipart_nesting_is_rejected_before_mime_parse (300 real levels → max_mime_depth) requires mime_boundary_from_headers to return Some for genuine multipart parts, which it does precisely because ctype() returns the primary type. The media-type guard remains active, and flat MHTML whose text/plain part carries a boundary-looking header is correctly not counted.

The code at the current head is doing the right thing, and the suggested alternative was wrong. Resolving the thread.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 1 new issue found across 41 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/mhtml.rs">

<violation number="1" location="src/formats/mhtml.rs:139">
P2: A base64 part followed by large-but-valid later parts can be spuriously rejected. The preflight computes the decoder reserve from the entire remaining message (`bytes.len() - body_start`), not just the current part's encoded region, then rejects when that exceeds MAX_ENTRY_BYTES. Because MAX_TOTAL_BYTES (512MB) is far larger and non-image/non-HTML parts are never otherwise size-checked, a valid archive (e.g. two ~70MB images) fails RESOURCE_LIMIT solely because of data after the first base64 part. Bound the reserve to the part's own body (up to its terminating boundary) rather than the whole remaining message.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/formats/mhtml.rs Outdated
Comment thread src/formats/html.rs
Comment thread src/shared/html.rs
Comment thread src/formats/mhtml.rs
Comment thread src/formats/mhtml.rs
let headers = &bytes[..body_start];

if headers_use_base64(headers) {
enforce_base64_parser_reserve(bytes.len().saturating_sub(body_start), max_entry_bytes)?;

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A base64 part followed by large-but-valid later parts can be spuriously rejected. The preflight computes the decoder reserve from the entire remaining message (bytes.len() - body_start), not just the current part's encoded region, then rejects when that exceeds MAX_ENTRY_BYTES. Because MAX_TOTAL_BYTES (512MB) is far larger and non-image/non-HTML parts are never otherwise size-checked, a valid archive (e.g. two ~70MB images) fails RESOURCE_LIMIT solely because of data after the first base64 part. Bound the reserve to the part's own body (up to its terminating boundary) rather than the whole remaining message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/mhtml.rs, line 139:

<comment>A base64 part followed by large-but-valid later parts can be spuriously rejected. The preflight computes the decoder reserve from the entire remaining message (`bytes.len() - body_start`), not just the current part's encoded region, then rejects when that exceeds MAX_ENTRY_BYTES. Because MAX_TOTAL_BYTES (512MB) is far larger and non-image/non-HTML parts are never otherwise size-checked, a valid archive (e.g. two ~70MB images) fails RESOURCE_LIMIT solely because of data after the first base64 part. Bound the reserve to the part's own body (up to its terminating boundary) rather than the whole remaining message.</comment>

<file context>
@@ -0,0 +1,641 @@
+    let headers = &bytes[..body_start];
+
+    if headers_use_base64(headers) {
+        enforce_base64_parser_reserve(bytes.len().saturating_sub(body_start), max_entry_bytes)?;
+    }
+
</file context>
Fix with cubic

Marcell Manfrin added 2 commits August 31, 2026 22:50
Cubic findings (PR 149 review run ed78d83a, shared HTML frontend):

1. The complexity preflight did not model HTML5's implicit <p> closure
   on block-level start tags, so repeated <p><div> pairs accumulated
   phantom nesting depth: documents html5ever repairs into shallow trees
   were rejected and the depth accounting diverged from the DOM the
   limits are meant to model. Close an innermost open <p> when a
   block-level start tag arrives; deeper arrangements remain
   over-counted, keeping the preflight fail-closed.

2. href="#" produced LinkTarget::Anchor(""), which the Markdown
   renderer cannot resolve, so the link was dropped to plain text.
   Preserve the empty fragment as a relative "#" URL instead.

Regressions: 200 <p><div> pairs now convert (depth 200 < limit), 300
pairs are still rejected before DOM construction, and a bare-hash link
renders as [top](#).
Cubic findings (PR 149 review run ed78d83a):

1. (P1) mail-parser eagerly decodes part bodies while parsing, and the
   preflight did not bound quoted-printable parts at all, so an
   oversized QP part materialized the very allocation MAX_ENTRY_BYTES
   should reject. Bound the encoded region of QP parts in the preflight
   (quoted-printable never expands, so the encoded size upper-bounds
   the decoded allocation) and decode image parts through the same
   guarded path as the HTML root (transfer_decoded_part_bytes) so the
   limits are enforced before decoded bytes are used, independent of
   parser internals.

2. When the HTML part carries no absolute Content-Location and
   <base href> is relative, embedded relative images and stylesheets
   were never joined against that base because resolve_resource_reference
   required a scheme. Join base and reference as archive paths,
   mirroring the resolution applied when the resource index is built.

Regressions: an over-limit QP part is rejected by the preflight (and a
small one passes); a relative base resolves an embedded image through
Content-Location.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/html.rs
Comment thread tests/mhtml.rs
Marcell Manfrin added 2 commits August 31, 2026 23:29
Cubic (PR 149, src/formats/html.rs:308) claimed dialog and summary do
not implicitly close an open <p>; html5ever's in-body mode
(tree_builder/rules.rs) proves they do. Keep both and complete the
preflight list to the parser's full close_p_element_in_button_scope
set: center, dir, listing, plaintext, and search were missing. table
is retained: html5ever closes <p> for <table> outside quirks mode,
and the preflight assumes standards mode.
Cubic (PR 149, tests/mhtml.rs): with symmetric relative references on
both sides, the original fixture matched even without base resolution.
Use a ./ dot segment in the image reference: it only collapses to the
part key through the relative base join plus path normalization.
Verified red without the resolve_resource_reference change and green
with it.
@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/formats/mhtml.rs (ContentType::ctype() comparison, run 6bf94ab5)

@cubic-dev-ai Please resolve this thread: the finding is a false positive for mail-parser 0.11.8.

  1. ContentType::ctype() returns &self.c_type (core/header.rs), and the content-type parser fills c_type in ContentState::Type — the lowercased token before /. For multipart/related, ctype() returns "multipart", not "multipart/related". This is the same primary/sub split that Message::is_content_type relies on (ct.c_type.eq_ignore_ascii_case(type_)).
  2. At the current head de11157, excessive_multipart_nesting_is_rejected_before_mime_parse (300 levels of real multipart nesting → max_mime_depth error) and base64_decoder_preflight_rejects_large_reserve_before_mime_parse both pass. They require mime_boundary_from_headers to return Some for real multipart parts, so the guards are provably active.
  3. The suggested content_type.is_multipart() does not exist on ContentType in 0.11.8 (it is a MessagePart method checking PartType::Multipart), so the suggested fix cannot compile at this site.

@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/lib.rs:50 (Format enum variants, run a58250e3)

Addressed by #154, which marks Format #[non_exhaustive] as a standalone API-compatibility change (with defensive wildcard arms in the Node/WASM bindings; the Python bindings are lookup-based and unaffected). This crate is 0.x, so adding variants is semver-legal either way; #154 removes the downstream breakage concern. If #154 lands first, this PR's next rebase will also carry the wildcard arms in the binding matches it already edits.

@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/formats/mhtml.rs:139 (base64 reserve computed from the remaining message, run ed78d83a)

This conservatism is deliberate. mail-parser 0.11.x reserves from MessageStream::remaining() before scanning for the active MIME boundary, so the safe upper bound for the decoder reserve is the entire remaining message rather than only the current part's encoded region (documented in the code comment at the call site). A per-part bound would under-approximate the parser's reservation and re-enable the allocation path the guard blocks. The cost is a possible spurious rejection only when a base64 part is followed by very large parts; such inputs stay within MAX_TOTAL_BYTES and the failure mode is a clean ResourceLimit error. Keeping this behavior; open to revisiting if mail-parser's reservation semantics change.

@marcellmanfrin

Copy link
Copy Markdown
Author

Re: review thread on src/formats/html.rs (frameset discards collected assets, run a58250e3)

Fixed in this branch by commit da61872: the frameset branch of document_from_parsed_html returns Document { assets, ..Document::default() }, retaining the already collected and size-limit-checked assets. The regression frameset_root_preserves_collected_assets passes, and the full gate plus both canonical real-fixture conversions passed on the current head de11157. @cubic-dev-ai Please re-check this finding against de11157 and resolve the thread if it is addressed.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Re: review thread on src/formats/html.rs (frameset discards collected assets, run a58250e3)

Fixed in this branch by commit da61872: the frameset branch of document_from_parsed_html returns Document { assets, ..Document::default() }, retaining the already collected and size-limit-checked assets. The regression frameset_root_preserves_collected_assets passes, and the full gate plus both canonical real-fixture conversions passed on the current head de11157. @cubic-dev-ai Please re-check this finding against de11157 and resolve the thread if it is addressed.

@marcellmanfrin I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Re: review thread on src/formats/mhtml.rs (ContentType::ctype() comparison, run 6bf94ab5)

@cubic-dev-ai Please resolve this thread: the finding is a false positive for mail-parser 0.11.8.

  1. ContentType::ctype() returns &self.c_type (core/header.rs), and the content-type parser fills c_type in ContentState::Type — the lowercased token before /. For multipart/related, ctype() returns "multipart", not "multipart/related". This is the same primary/sub split that Message::is_content_type relies on (ct.c_type.eq_ignore_ascii_case(type_)).
    ...

@marcellmanfrin Confirmed — this is the same ctype() thread I already retracted, and your evidence matches what I verified against mail-parser 0.11.8's actual API. Resolving.

To recap so it stays on record:

  1. ContentType::ctype() returns &self.c_type, i.e. the primary type before the / ("multipart" for multipart/related); the subtype lives in c_subtype. That's exactly the split Message::is_content_type leans on internally, so content_type.ctype().eq_ignore_ascii_case("multipart") is the correct "is this really a multipart media type" test on the ContentType object.

  2. The guard in mime_boundary_from_headers is what keeps excessive_multipart_nesting_is_rejected_before_mime_parse (300 real levels → max_mime_depth) and the base64 reserve preflight tests passing — they can only reach those code paths if the guard returns Some for genuine multipart parts, which it does because ctype() yields the primary type.

  3. is_multipart() isn't available on ContentType in 0.11.8 — it's a MessagePart method over PartType::Multipart. So my suggested alternative wouldn't even compile at that site, which on its own collapses the finding.

The code at the current head is doing the right thing. Thread resolved.

If you want this documented so it doesn't get re-flagged, the fix note is in the learnings store. Otherwise, happy to move on.

Tip: get faster answers by chatting with cubic’s review copilot in the review UI

@marcellmanfrin

Copy link
Copy Markdown
Author

@cubic-dev-ai ultrareview: focus on the MHTML MIME preflight resource limits (base64 reserve, quoted-printable bounds, nesting depth, media-type guard), resource resolution (Content-ID/Content-Location/base href), and the HTML/MHTML interaction in the shared frontend.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 1, 2026

Copy link
Copy Markdown

@cubic-dev-ai ultrareview: focus on the MHTML MIME preflight resource limits (base64 reserve, quoted-printable bounds, nesting depth, media-type guard), resource resolution (Content-ID/Content-Location/base href), and the HTML/MHTML interaction in the shared frontend.

@marcellmanfrin Couldn't start the ultrareview: PR author marcellmanfrin has no assigned seat on this installation. Please, assign one at https://www.cubic.dev/settings?tab=subscription.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 3 new issues found across 41 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/formats/html.rs">

<violation number="1" location="src/formats/html.rs:37">
P2: A maximum-size malformed or legacy-encoded HTML input can expand several-fold during `decode_html` before the resource limit is applied, causing avoidable memory exhaustion. Cap the decoded text allocation/output or reject inputs whose decoded representation exceeds the configured entry budget before parsing.</violation>

<violation number="2" location="src/formats/html.rs:278">
P2: When malformed HTML repeats `<button>` start tags without end tags, the HTML5 tree builder implicitly closes each previous button, but this preflight counts them as nested and returns `max_xml_depth` after 256 tags. Add `button` to the implied-close cases so browser-repairable documents are not rejected before DOM construction.</violation>
</file>

<file name="src/formats/mhtml.rs">

<violation number="1" location="src/formats/mhtml.rs:161">
P2: A boundary-looking string inside a MIME part can make the preflight scanner parse ordinary body text as a new part. Restrict boundary matches to delimiter lines (line start plus valid `CRLF`/closing suffix), matching the MIME parser’s boundary semantics.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Fix all with cubic | Re-trigger cubic

Comment thread src/formats/html.rs
open.pop();
}

let implied = match name {

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When malformed HTML repeats <button> start tags without end tags, the HTML5 tree builder implicitly closes each previous button, but this preflight counts them as nested and returns max_xml_depth after 256 tags. Add button to the implied-close cases so browser-repairable documents are not rejected before DOM construction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/html.rs, line 278:

<comment>When malformed HTML repeats `<button>` start tags without end tags, the HTML5 tree builder implicitly closes each previous button, but this preflight counts them as nested and returns `max_xml_depth` after 256 tags. Add `button` to the implied-close cases so browser-repairable documents are not rejected before DOM construction.</comment>

<file context>
@@ -0,0 +1,582 @@
+        open.pop();
+    }
+
+    let implied = match name {
+        "li" => &["li"][..],
+        "p" => &["p"][..],
</file context>
Fix with cubic

Comment thread src/formats/mhtml.rs
marker.extend_from_slice(boundary);

while offset < end {
let Some(marker_offset) = find_bytes(&bytes[offset..end], &marker) else {

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A boundary-looking string inside a MIME part can make the preflight scanner parse ordinary body text as a new part. Restrict boundary matches to delimiter lines (line start plus valid CRLF/closing suffix), matching the MIME parser’s boundary semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/mhtml.rs, line 161:

<comment>A boundary-looking string inside a MIME part can make the preflight scanner parse ordinary body text as a new part. Restrict boundary matches to delimiter lines (line start plus valid `CRLF`/closing suffix), matching the MIME parser’s boundary semantics.</comment>

<file context>
@@ -0,0 +1,688 @@
+    marker.extend_from_slice(boundary);
+
+    while offset < end {
+        let Some(marker_offset) = find_bytes(&bytes[offset..end], &marker) else {
+            break;
+        };
</file context>
Fix with cubic

Comment thread src/formats/html.rs
});
}

let text = decode_html(bytes);

@cubic-dev-ai cubic-dev-ai Bot Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A maximum-size malformed or legacy-encoded HTML input can expand several-fold during decode_html before the resource limit is applied, causing avoidable memory exhaustion. Cap the decoded text allocation/output or reject inputs whose decoded representation exceeds the configured entry budget before parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/html.rs, line 37:

<comment>A maximum-size malformed or legacy-encoded HTML input can expand several-fold during `decode_html` before the resource limit is applied, causing avoidable memory exhaustion. Cap the decoded text allocation/output or reject inputs whose decoded representation exceeds the configured entry budget before parsing.</comment>

<file context>
@@ -0,0 +1,582 @@
+        });
+    }
+
+    let text = decode_html(bytes);
+    parse_text_with_context(&text, None, &StandaloneCtx, Vec::new())
+}
</file context>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant