Skip to content

Commit afb9725

Browse files
authored
fix(core): report an unterminated string/comment instead of silently ending the entity scan [stacked on #3675] (#3699)
## Reviewer summary - **Without this:** the same truncated/corrupted-file data loss #3695 fixed on the TypeScript path is still silent on the Rust (WASM/native) load path — a model can lose its entire tail with zero signal, and which load path a user happened to hit decided whether they were ever told their model was incomplete. - **Evidence:** on the unmodified scanner, a fixture with a valid entity, an unterminated-string entity, then another valid entity scans to `left: None, right: Some(...)` — the malformed record was silently dropped instead of attributed. The PR's own framing: "the malformed record must be attributed, not silently dropped." - **Risk:** the sharded parallel-scan pre-pass needed care to avoid conflating a genuine terminal stop with a mid-string artifact from a shard's speculative start; a chunk-count sweep (`[1, 2, 3, 4, 5, 7, 8, 11, 16, 32, 64]`) confirms the parallel index matches the serial truncation point exactly at every shard count. Behavior is unchanged on purpose — still no resync, only the silence is fixed. - **Sequencing:** stacked on #3675 and #3695 — merge both first. --- **Stacked on #3675 (`fix-step-comment-scanning`) and #3695 (`hunt-truncation-observability`) — merge those first.** This PR is based on `fix-step-comment-scanning`'s tip. #3695 fixed the identical defect on the TypeScript side and, in its body, confirmed the Rust side has the same shape and is unfixed. This PR closes that TS/Rust divergence. ## Summary `EntityScanner::find_entity_end` (`rust/core/src/parser/scanner.rs`) has no byte to resume from once a record opens a `'` string or a `/* ... */` comment that never closes — the inner `memchr::memchr(b'\'', &content[pos..])?` (or `skip_step_comment`) returns `None`, and that `?` used to propagate straight out of `next_entity`, ending the **entire** scan with no trace of why. A truncated download, a failed export, or a lossy round-trip through another tool could silently drop every entity after the break on the WASM/native load path, while the TS fallback (post-#3695) reported it — so which load path a user hit decided whether they knew their model was incomplete. ### RED (executed, pre-fix) `rust/core/src/parser/scanner_tests.rs::unterminated_string_in_a_record_loses_every_entity_after_it` (temporarily run against the unfixed `scanner.rs` via `git stash`): a fixture with `#11` (valid), `#12` (unterminated string), `#13` (valid) scans to `ids == [11]` — #12 and #13 silently gone, and there was no method on the scanner to say why. ### GREEN (this branch) `EntityScanner` now exposes `malformed_record_start() -> Option<usize>`, set the first time `find_entity_end` (or the candidate-hunt loop's own `skip_step_comment` call) returns `None`. Behaviour is **unchanged** on purpose: the scan still stops there, no resync — an unterminated string leaves no reliable byte to resume from, and guessing wrong risks fabricating entities from misaligned bytes, the same call #3695 made on the TS side. Only the silence is fixed. Every whole-file scan across `rust/core`, `rust/processing`, and `rust/wasm-bindings` now reports it through the existing `report_oversized_ids` sink (a new `report_malformed_records`, and the combined `report_scan_diagnostics` convenience both call it): - `rust/core/src/decoder.rs::build_entity_index` - `rust/core/src/columnar_index.rs::ColumnarEntityIndex::from_scan` - `rust/processing/src/processor/mod.rs` (native/server streaming load) - `rust/wasm-bindings/src/api/parsing.rs` (`scanEntitiesFast`/`scanGeometryEntitiesFast`, the direct WASM twin of TS's `scanIfcEntities`) - `rust/wasm-bindings/src/api/gpu_meshes/prepass.rs` (`buildPrePassOnce`'s serial/columns path) — also exports a new `malformedRecordFound` boolean on the `entity-index` event, alongside the existing `oversizedIdCount` ## Per-site verdict — every string/comment-state site in the files this hunt named | Site | Verdict | |---|---| | `scanner.rs` `find_entity_end` (the catastrophic one) | **Fixed.** | | `scanner.rs` `next_entity`'s candidate-hunt loop (`skip_step_comment` between records) | **Fixed**, same shape, same edit. | | `scanner.rs` `has_non_null_attribute`'s own `in_string` loop (~line 402/481 in the pre-PR file) | **Cannot reach.** Bounded `while pos < content.len()` over an ALREADY-DELIMITED slice `[start, end)` that a prior `find_entity_end` success already terminated — the slice's quotes are balanced by construction. Worst case: the loop just finishes at `content.len()`, mis-reading at most this one attribute — never losing anything past this entity. Same reasoning TS's `entity-extractor.ts` used to clear its own bounded loops. | | `scanner.rs` `data_section_start` | **Cannot reach.** Bounded by `limit = len.min(1 << 18)`; an unresolved `in_string` at the cap just falls through to the `0` fallback (header-skip heuristic, not entity loss). | | `tokenizer.rs` `string_literal`/`parse_string_content` | **Cannot reach.** `parse_entity` is only ever called on a single record's already-scanner-validated, already-terminated byte span; an unterminated quote there fails to parse *that one entity* (a `nom::Err`), not the whole scan. | | `lexical.rs` `skip_step_comment`/`skip_step_trivia` | Pre-existing, deliberate refusal-on-`None` design from #3303 (shared by both fixed call sites above); no change needed to the function itself. | | #3675's own "unterminated comment inside a record" branch | **Not introduced by #3675** in these Rust files — the comment-refusal-ends-scan shape here predates it, from #3303/#1579. Fixed anyway, by the same one-line-shape edit, since it shares `find_entity_end`'s failure path (see `unterminated_comment_inside_a_record_is_reported`, `unterminated_comment_between_records_is_reported`). | ## The sharded parallel-scan pre-pass — understood before choosing a representation Per the known blocker: the sibling #3395 oversized-id counter uses `Vec<usize>` of offsets, not a plain counter, because chunk `i > 0` starts speculatively (`EntityScanner::new_at`) and can land inside a quoted value, producing refusals that are artefacts of where the shard started rather than real file content — only the stitch (which knows where a shard's *retained* region begins) can tell them apart. A malformed-record stop is representable more simply — `Option<usize>`, not a `Vec`, because unlike an oversized-id refusal (skip one record, keep scanning) a malformed stop is **terminal**: `next_entity` never resumes after it, so there is at most one per `EntityScanner` instance. But it needed more than a signal: **byte-identity is this module's whole contract**, and the serial scanner stops *permanently* on a malformed record — nothing past that byte, anywhere in the rest of the file, is ever in the serial index. So the parallel path's stitch (`rust/processing/src/parallel_scan/native.rs::stitch`) now: - treats a malformed stop inside a chunk's **resynchronised/attributed** region (`chunk.malformed_start >= target`, mirroring the refusal count's `< target` filter) as real, and drops every chunk after it from the merge — same as the existing `expected_start: None` early-`break`; - treats one inside a chunk's *speculative* prefix (before `target`) as a mid-string artefact and ignores it (the existing `Err`-arm serial rescan already self-heals this case, since a chunk that dies in its own garbage never produces `target` for the binary search to find); - extends the `Err`-arm's serial rescan (`rescan_range`) to report its own malformed stop too, since that scan starts at a validated real boundary and so is never speculative. New tests (`rust/processing/src/parallel_scan_tests.rs`) sweep chunk counts `[1, 2, 3, 4, 5, 7, 8, 11, 16, 32, 64]` over a fixture with a malformed record placed so it lands in different shards at different `n`, asserting the parallel index matches the serial truncation point exactly and the stitched flag reports `true` — plus one test confirming the report reaches an installed `set_report_sink`. **Not extended to the browser's sharded pre-pass** (`prepass_sharded.rs` / `scan_shard_classified_with_refusals`): that path's stitch lives in the host's TypeScript (main thread), not this crate — extending the same attribution there is real, separate TS work, out of scope here. Reported as a gap in the changeset. ## Gates run (foreground, one `cargo` at a time) - `cargo test -p ifc-lite-core` — 226 lib tests + all integration tests, 0 failed - `cargo test -p ifc-lite-processing` — including the new `parallel_scan_tests.rs` cases and the pre-existing byte-identity/refusal-parity sweep (unaffected), 0 failed - `cargo build -p ifc-lite-wasm --target wasm32-unknown-unknown` — clean - `cargo build --workspace --exclude ifc-lite-wasm` — clean - `module_size_ratchet` test — clean. `parallel_scan.rs` grew past 400 lines (net new functionality, not just comments) and is split into a sibling `parallel_scan/native.rs`, the same `#[path]` pattern `decoder.rs`/`decoder/caches.rs` already uses (not a `_tests.rs` file, so not ratchet-exempt on that basis — split instead of allowlisted). `scanner.rs`, `processor/mod.rs`, and `prepass.rs` stayed within their existing recorded budgets by trimming doc-comment verbosity, not by touching the allowlist. Changeset: `@ifc-lite/wasm` patch (`.changeset/report-rust-scanner-malformed-record.md`). Labelled `unqueued` — this is a hunt-adjacent fix closing a diagnosed TS/Rust divergence, not tied to a `ready` issue. --- ## Merge order (dry-run only — nothing pushed) Checked against `upstream/main` at `6f445b6f2` (2026-09-03). #3695, #3699, and #3744 all touch the STEP scanner files; dry-run merges (`git merge --no-commit --no-ff` / `git apply --3way` in scratch branches, never pushed) were run in every order that matters, with the merged region read line-by-line afterward rather than trusted on a clean exit. **TypeScript side (`step-lexing.ts`, `tokenizer.ts`, `scan-worker-source.ts`, `scan-worker-inline.ts`, `entity-scanner.ts`):** #3744 and #3695 auto-merge cleanly in *either* order. Verified after each merge that `isSpaceByte` (step-lexing.ts) and all its inline twins (3× in `tokenizer.ts::scanEntitiesFast`, 1 in `scan-worker-source.ts::skipTriviaAt` + 3 inline copies) still test the full six-byte set (`0x20 0x09 0x0D 0x0A 0x0C 0x0B`), and that `Skip.unterminated?: 'string' | 'comment'` plus its two set-sites in `skipTrivia` are still present. `tsc --noEmit` in `packages/parser` and its vitest suite (93 files / 1039 passed, 2 skipped) are clean in both orders. `entity-refs-from-index.ts` is #3744-only in this set — no overlap. **Rust side (`scanner.rs`, `scanner_tests.rs`, `lexical.rs`):** `scanner.rs` also auto-merges cleanly with #3699 in either order — verified `malformed_record_start`/`mark_malformed` (the #3699 reporting field, its two call sites, and the accessor) and the three `skip_step_trivia` call sites (the #3744 fix) both survive intact. `scanner_tests.rs` is the one real friction point: **every order produces a git conflict there**, not a silent merge — both PRs append new `#[test]` functions near the same end-of-file anchor. Read both sides: the conflict is purely additive (no test asserts something the other side changes), so resolution is "keep both blocks," not a judgment call. `lexical.rs` is untouched by #3699's own diff, so it's unaffected either way.
1 parent 7659f88 commit afb9725

22 files changed

Lines changed: 947 additions & 328 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ifc-lite/wasm': patch
3+
---
4+
5+
`EntityScanner`'s HEADER skip (`rust/core/src/parser/scanner_header.rs`) matched the `DATA;` section marker inside a STEP `/* ... */` comment. ISO 10303-21 allows a comment wherever whitespace is allowed, the HEADER included, so `HEADER; /* DATA; #99=IFCWALL($); */ ENDSEC; DATA; #1=IFCWALL($);` ended the marker search inside the comment and started the entity scan there: `#99`, a record the file does not declare, came back alongside the real `#1`. The search already skipped quoted strings for exactly this reason; it now skips complete comments the same way, through the shared `skip_step_comment`.
6+
7+
An unterminated `/*` in the HEADER gets the same answer as a missing marker, so the scan starts at the top and `next_entity` meets the same comment, which reports it through the `malformed_record_start` channel rather than silently returning nothing. A headerless partial file whose records precede a bad comment still scans those records.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@ifc-lite/wasm': patch
3+
---
4+
5+
The Rust twin of #3695's TS fix, landing alongside it: `EntityScanner::find_entity_end` (`rust/core`) has no byte to resume from when a record opens a `'` string or a `/* ... */` comment that never closes, so the scan stops there, correctly, but until now with no trace of why. A truncated download, a failed export, or a lossy round-trip through another tool could silently drop every entity after the break on the WASM/native load path, so which path a user hit decided whether they knew their model was incomplete.
6+
7+
`EntityScanner` now exposes `malformed_record_start()`, and every index-building whole-file scan reports it through the existing `report_oversized_ids` sink: `columnar_index.rs`, `decoder.rs`, the streaming processor (`rust/processing/src/processor/mod.rs`), the wasm sharded-prepass path (`rust/wasm-bindings/src/api/gpu_meshes/prepass.rs`), and the wasm parsing entry points (`rust/wasm-bindings/src/api/parsing.rs`), via `ifc_lite_core::report_malformed_records` and the combined `report_scan_diagnostics` convenience both call. The behaviour is unchanged on purpose: an unterminated string leaves no reliable resume point, so the scan still stops there rather than guessing past it, only the silence is fixed.
8+
9+
The native sharded parallel scan (`rust/processing::build_entity_index_parallel`, files 8MB+) now also attributes a malformed stop to the shard whose real (resynchronised) region actually contains it, distinguishing it from a false stop a shard's speculative mid-record start can produce, and reports it once, stitched, exactly like the existing #3395 oversized-id refusal count, byte-identical to the serial scanner's truncation point.
10+
11+
The wasm-bindings columns event (`buildPrePassOnce`'s sharded/columns path) now also carries `malformedRecordFound` alongside the existing `oversizedIdCount`. This is the wasm half only: the TS side does not read this field yet, and wiring the browser host to surface it is a separate, following change.
12+
13+
Two gaps are explicitly NOT covered here, and are deferred to follow-up issues: the browser's sharded pre-pass (`scan_shard_classified_with_refusals` / `stitchShards`) discards a shard's malformed-record offset entirely, so a malformed stop on that path stays silent; and `parse_stream` (`rust/core/src/streaming.rs`) plus the server's `/parse/json` handler run whole-file scans that report neither the #3395 oversized-id count nor a malformed stop.

rust/core/src/columnar_index.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,10 @@ impl ColumnarEntityIndex {
178178
starts.push(start as u32);
179179
lengths.push((end - start) as u32);
180180
}
181-
crate::parser::report_oversized_ids(scanner.skipped_oversized_ids());
181+
crate::parser::report_scan_diagnostics(
182+
scanner.skipped_oversized_ids(),
183+
scanner.malformed_record_start().is_some(),
184+
);
182185
Self::from_unsorted(ids, starts, lengths)
183186
}
184187

rust/core/src/decoder.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
use crate::columnar_index::EntityIndexStore;
1010
use crate::error::{Error, Result};
11-
use crate::parser::{parse_entity, report_oversized_ids, EntityScanner};
11+
use crate::parser::{parse_entity, report_scan_diagnostics, EntityScanner};
1212
use crate::schema_gen::{AttributeValue, DecodedEntity};
1313
use rustc_hash::FxHashMap;
1414
use std::sync::Arc;
@@ -36,7 +36,7 @@ where
3636
while let Some((id, _type_name, start, end)) = scanner.next_entity() {
3737
index.insert(id, (start, end));
3838
}
39-
report_oversized_ids(scanner.skipped_oversized_ids());
39+
report_scan_diagnostics(scanner.skipped_oversized_ids(), scanner.malformed_record_start().is_some());
4040
index
4141
}
4242

rust/core/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ pub use legacy_entities::{
9898
};
9999
pub use model_bounds::{scan_model_bounds, scan_placement_bounds, ModelBounds};
100100
pub use parser::{
101-
entity_count, oversized_id_report, parse_entity, report_oversized_ids, set_report_sink,
102-
skip_step_comment, EntityScanner, Token,
101+
entity_count, oversized_id_report, parse_entity, report_malformed_records,
102+
report_oversized_ids, report_scan_diagnostics, set_report_sink, skip_step_comment,
103+
EntityScanner, Token,
103104
};
104105
pub use project_units::{
105106
measure::{measure_unit, MeasureUnit},
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! The one place Rust says that a scan stopped early because a record had no
6+
//! terminator - the Rust twin of the TS fix on `EntityScanResult.malformedRecordCount`
7+
//! (`packages/parser/src/entity-scanner.ts`, #3695).
8+
//!
9+
//! [`EntityScanner`](super::EntityScanner) stops the whole scan the moment a
10+
//! record opens a `'` string, a `/* ... */` comment, or simply runs to end of
11+
//! input with none of them ever closing - [`EntityScanner::find_entity_end`]
12+
//! has no byte to resume from once that happens (unlike the oversized-id
13+
//! refusal in [`super::oversized_ids`], which SKIPS the one record and keeps
14+
//! scanning). A model that comes back with its tail silently missing reads
15+
//! exactly like a complete one, so this module is the other half: the caller
16+
//! reports it rather than staying quiet.
17+
//!
18+
//! Modelled directly on [`super::oversized_ids`] - same "one home, not one
19+
//! call site" reasoning, same host-installed sink (shared with it via
20+
//! [`super::report_sink`], not a second `OnceLock`) - but the count this
21+
//! reports is always 0 or 1: once a scan stops here it never resumes, so
22+
//! there is nothing left to accumulate a second refusal from.
23+
//!
24+
//! One constant message, not a builder like [`super::oversized_id_report`]:
25+
//! the oversized-id report names a count, so it has to allocate a formatted
26+
//! string; this report names nothing that varies, so a `const &str` is the
27+
//! whole of it and there is no `Option<String>`-returning sibling to keep in
28+
//! sync with it.
29+
30+
/// The one-line report for a scan that stopped because of a malformed
31+
/// record.
32+
const MALFORMED_RECORD_MESSAGE: &str =
33+
"scan: stopped early - a record had no terminating ';' before end of input \
34+
(an unterminated quoted string, comment, or truncated file); the entities \
35+
returned may be an incomplete view of this file (#3695)";
36+
37+
/// Emit [`MALFORMED_RECORD_MESSAGE`] to the installed sink (stderr by
38+
/// default; see [`super::set_report_sink`]).
39+
///
40+
/// A no-op at `stopped == false`, so a caller can hand it
41+
/// `scanner.malformed_record_start().is_some()` unconditionally, exactly
42+
/// like [`super::report_oversized_ids`] takes `skipped_oversized_ids()`.
43+
pub fn report_malformed_records(stopped: bool) {
44+
if !stopped {
45+
return;
46+
}
47+
super::report_sink::emit(MALFORMED_RECORD_MESSAGE);
48+
}
49+
50+
#[cfg(test)]
51+
mod tests {
52+
use super::*;
53+
54+
#[test]
55+
fn message_names_the_shape_of_the_problem() {
56+
assert!(
57+
MALFORMED_RECORD_MESSAGE.contains("incomplete"),
58+
"report must warn the caller the model may be short: {MALFORMED_RECORD_MESSAGE}"
59+
);
60+
}
61+
}

rust/core/src/parser/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,22 @@
1717
//! unterminated comment mean" differently gives the same answer (#3303).
1818
1919
mod lexical;
20+
mod malformed_records;
2021
mod oversized_ids;
22+
mod report_sink;
2123
mod scanner;
2224
mod tokenizer;
2325

2426
pub use lexical::skip_step_comment;
27+
pub use malformed_records::report_malformed_records;
2528
pub use oversized_ids::{oversized_id_report, report_oversized_ids, set_report_sink};
2629
pub use scanner::{entity_count, EntityScanner};
2730
pub use tokenizer::{parse_entity, Token};
31+
32+
/// [`report_oversized_ids`] + [`report_malformed_records`] in one call — the
33+
/// two diagnostics every whole-file `EntityScanner` walk in this workspace
34+
/// emits after a scan, so every call site needs only one line, not two.
35+
pub fn report_scan_diagnostics(skipped_oversized_ids: usize, malformed_record_found: bool) {
36+
report_oversized_ids(skipped_oversized_ids);
37+
report_malformed_records(malformed_record_found);
38+
}

rust/core/src/parser/oversized_ids.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,11 @@
4040
//! scan with the sink still unset. Anything embedding this crate with its own
4141
//! log pipeline can do the same.
4242
43-
use std::sync::OnceLock;
43+
use super::report_sink::REPORT_SINK;
4444

45-
/// Host-installed destination for [`report_oversized_ids`]. Set once, because
46-
/// a swappable sink invites a reset race between two loads on different
47-
/// threads and nothing here needs one.
48-
static REPORT_SINK: OnceLock<fn(&str)> = OnceLock::new();
49-
50-
/// Route [`report_oversized_ids`] to `sink` instead of stderr.
45+
/// Route every scan diagnostic in this crate — [`report_oversized_ids`] and
46+
/// [`super::malformed_records::report_malformed_records`] alike — to `sink`
47+
/// instead of stderr.
5148
///
5249
/// Returns `true` when this call installed the sink, `false` when one was
5350
/// already installed (the first wins). Callers on `wasm32` MUST install one:
@@ -81,10 +78,7 @@ pub fn report_oversized_ids(skipped: usize) {
8178
let Some(message) = oversized_id_report(skipped) else {
8279
return;
8380
};
84-
match REPORT_SINK.get() {
85-
Some(sink) => sink(&format!("[ifc-lite] {message}")),
86-
None => eprintln!("[ifc-lite] {message}"),
87-
}
81+
super::report_sink::emit(&message);
8882
}
8983

9084
#[cfg(test)]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+
//! The one host-installed destination every scan diagnostic in this crate
6+
//! writes to (issue #3395's oversized-id refusals, and the malformed-record
7+
//! stop this module now shares the channel with).
8+
//!
9+
//! One sink, not one per diagnostic kind: [`super::oversized_ids`] and
10+
//! [`super::malformed_records`] both report a scan that came back short of
11+
//! what the file declares, for a different reason each, but a host that
12+
//! wants to see one wants to see the other the same way — installing two
13+
//! sinks (or forgetting to install the second) would silently drop half the
14+
//! warnings on whichever target adds a diagnostic later. `set_report_sink`
15+
//! stays exported from [`super::oversized_ids`] (its original, still public
16+
//! name) so no caller-visible API changes.
17+
18+
use std::sync::OnceLock;
19+
20+
/// Set once, because a swappable sink invites a reset race between two loads
21+
/// on different threads and nothing here needs one.
22+
pub(super) static REPORT_SINK: OnceLock<fn(&str)> = OnceLock::new();
23+
24+
/// Route a diagnostic `message` to the installed sink, or stderr by default.
25+
pub(super) fn emit(message: &str) {
26+
match REPORT_SINK.get() {
27+
Some(sink) => sink(&format!("[ifc-lite] {message}")),
28+
None => eprintln!("[ifc-lite] {message}"),
29+
}
30+
}

rust/core/src/parser/scanner.rs

Lines changed: 47 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
//! Independent of the nom [`tokenizer`](super::tokenizer): does its own
88
//! hand-rolled, quote- and comment-aware parsing without building [`Token`]s.
99
10+
#[path = "scanner_header.rs"]
11+
mod scanner_header;
12+
use scanner_header::data_section_start;
13+
1014
/// Fast entity scanner over raw IFC bytes without full parsing.
1115
/// O(n) performance for finding entities by type
1216
/// Uses memchr for SIMD-accelerated byte searching
@@ -20,6 +24,8 @@ pub struct EntityScanner<'a> {
2024
/// [`Self::skipped_oversized_id_starts`]. It never allocates on a file
2125
/// with nothing to refuse, which is every real file.
2226
skipped_oversized_id_starts: Vec<usize>,
27+
/// See [`Self::malformed_record_start`] for what this points at.
28+
malformed_record_start: Option<usize>,
2329
}
2430

2531
impl<'a> EntityScanner<'a> {
@@ -38,6 +44,7 @@ impl<'a> EntityScanner<'a> {
3844
bytes,
3945
position: data_section_start(bytes),
4046
skipped_oversized_id_starts: Vec::new(),
47+
malformed_record_start: None,
4148
}
4249
}
4350

@@ -61,6 +68,7 @@ impl<'a> EntityScanner<'a> {
6168
bytes,
6269
position: clamped,
6370
skipped_oversized_id_starts: Vec::new(),
71+
malformed_record_start: None,
6472
}
6573
}
6674

@@ -99,6 +107,23 @@ impl<'a> EntityScanner<'a> {
99107
&self.skipped_oversized_id_starts
100108
}
101109

110+
/// The byte offset that stopped this scan because no terminator was
111+
/// found, or `None` otherwise: a record's `line_start` (its `#`) when
112+
/// [`find_entity_end`](Self::find_entity_end) fails, or the `/` of an
113+
/// unterminated comment found BETWEEN records (no record to name yet).
114+
/// A whole-file scan needs only `is_some()`; a SHARDED scan needs the
115+
/// offset, for the reason [`Self::skipped_oversized_id_starts`] does.
116+
pub fn malformed_record_start(&self) -> Option<usize> {
117+
self.malformed_record_start
118+
}
119+
120+
/// Record `at` as this scan's stop point, the first time only.
121+
fn mark_malformed(&mut self, at: usize) {
122+
if self.malformed_record_start.is_none() {
123+
self.malformed_record_start = Some(at);
124+
}
125+
}
126+
102127
/// Scan for the next entity
103128
/// Returns (entity_id, type_name, line_start, line_end)
104129
#[inline]
@@ -152,13 +177,18 @@ impl<'a> EntityScanner<'a> {
152177
// past `*/`; if not, it's a STEP arithmetic '/' inside a
153178
// value list (rare; just step past it).
154179
if candidate + 1 < len && bytes[candidate + 1] == b'*' {
155-
// An unterminated `/*` here means corrupt input.
156-
// `skip_step_comment` refuses (returns `None`) rather
157-
// than silently consuming the rest of the file — see
158-
// its doc comment for why that's the right call for a
159-
// scanner (issue #3303).
160-
self.position = super::lexical::skip_step_comment(bytes, candidate)?;
161-
continue;
180+
// An unterminated `/*` means corrupt input (#3303) —
181+
// same "no resume point" shape as `find_entity_end`.
182+
match super::lexical::skip_step_comment(bytes, candidate) {
183+
Some(next_pos) => {
184+
self.position = next_pos;
185+
continue;
186+
}
187+
None => {
188+
self.mark_malformed(candidate);
189+
return None;
190+
}
191+
}
162192
}
163193
// Lone '/' — not a comment. Skip past.
164194
self.position = candidate + 1;
@@ -194,7 +224,15 @@ impl<'a> EntityScanner<'a> {
194224
// Find the end of the entity (semicolon) while respecting quoted strings
195225
// IFC strings use single quotes and can contain semicolons
196226
let line_content = &bytes[line_start..];
197-
let end_offset = self.find_entity_end(line_content)?;
227+
let end_offset = match self.find_entity_end(line_content) {
228+
Some(o) => o,
229+
None => {
230+
// No terminator found (see `find_entity_end`'s doc
231+
// comment) — record and stop, per `tokenizer.ts`.
232+
self.mark_malformed(line_start);
233+
return None;
234+
}
235+
};
198236
let line_end = line_start + end_offset + 1;
199237

200238
// Parse entity ID — digit range already validated in the candidate loop.
@@ -384,6 +422,7 @@ impl<'a> EntityScanner<'a> {
384422
pub fn reset(&mut self) {
385423
self.position = data_section_start(self.bytes);
386424
self.skipped_oversized_id_starts.clear();
425+
self.malformed_record_start = None;
387426
}
388427

389428
/// Fast check if attribute at given index is non-null (not '$')
@@ -513,59 +552,6 @@ where
513552
EntityScanner::new(content).count()
514553
}
515554

516-
/// Locate the byte offset of the first character after `DATA;` (skipping the
517-
/// STEP HEADER section). Returns 0 if the marker isn't found — partial files
518-
/// without a HEADER still scan from the top.
519-
///
520-
/// Scanning the HEADER for entities is unsafe: the HEADER is a free-form
521-
/// STEP record that legally contains arbitrary characters inside quoted
522-
/// strings (filenames, descriptions). CATIA emits `FILE_NAME('…\X0\2#.ifc'…)`,
523-
/// and a tokenizer that anchors on `#` will latch onto the in-string `#`,
524-
/// flip `find_entity_end`'s quote parity, and drop the rest of the file.
525-
/// See issue #654.
526-
///
527-
/// Quote-aware: the marker is only matched outside `'…'` strings, since a
528-
/// HEADER field could legally contain the literal text `DATA;` in a
529-
/// description or filename. Escaped single quotes (`''`) are treated as a
530-
/// pair of in-string characters per ISO 10303-21.
531-
fn data_section_start(bytes: &[u8]) -> usize {
532-
const MARKER: &[u8] = b"DATA;";
533-
let len = bytes.len();
534-
if len < MARKER.len() {
535-
return 0;
536-
}
537-
// Cap the header scan. Real-world headers are <2 KB; an unbounded scan
538-
// here would defeat the point of an O(1)-up-front fix on giant files
539-
// that legitimately lack a HEADER section.
540-
let limit = len.min(1 << 18); // 256 KB
541-
let mut pos = 0;
542-
let mut in_string = false;
543-
while pos < limit {
544-
let b = bytes[pos];
545-
if in_string {
546-
if b == b'\'' {
547-
if pos + 1 < limit && bytes[pos + 1] == b'\'' {
548-
pos += 2; // escaped quote
549-
continue;
550-
}
551-
in_string = false;
552-
}
553-
pos += 1;
554-
continue;
555-
}
556-
if b == b'\'' {
557-
in_string = true;
558-
pos += 1;
559-
continue;
560-
}
561-
if b == b'D' && pos + MARKER.len() <= len && &bytes[pos..pos + MARKER.len()] == MARKER {
562-
return pos + MARKER.len();
563-
}
564-
pos += 1;
565-
}
566-
0
567-
}
568-
569555
#[cfg(test)]
570556
#[path = "scanner_tests.rs"]
571557
mod scanner_tests;

0 commit comments

Comments
 (0)