ifc_lite_core/
express_id.rs

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 single place in this crate that decides whether a digit run read
6//! from a raw IFC byte slice fits an express id.
7//!
8//! ISO 10303-21 writes an instance name (`#<digits>`) with no upper bound, so
9//! a file MAY legally contain `#4294967297`. Nothing downstream of this crate
10//! can hold one: every store that keys on an express id narrows it to `u32`.
11//! Accumulating with `wrapping_mul`/`wrapping_add` does not error on an
12//! oversized run, it silently maps it onto a real low-numbered id — a value
13//! collision, not a missing value (issue #3395, split for the reference
14//! readers in #3421).
15//!
16//! [`parse_express_id`] and the internal prefix reader serve both sides: the
17//! definition scanner ([`crate::parser::scanner::EntityScanner`]) and every
18//! `#<digits>` reference reader in [`crate::fast_parse`] and
19//! [`crate::decoder`], and — now that it is `pub` — the REFERENCE readers in
20//! `ifc-lite-geometry`, `ifc-lite-export` and `ifc-lite-processing` that read
21//! raw STEP bytes outside this crate. A second, independently-written copy of
22//! this accumulation is exactly the drift #3395 was careful to avoid. New
23//! callers reuse these canonical readers, which share the digit arithmetic.
24//!
25//! The bound is inclusive: `u32::MAX` is a legitimate express id and parses
26//! successfully. Refusal (`None`) is the only outcome for anything past it —
27//! there is no saturating variant here. A caller that saturated an oversized
28//! reference to `u32::MAX` would risk binding it to a real entity that
29//! legitimately holds that id, which is the same collision this function
30//! exists to prevent, just relocated to the sentinel value. Contrast
31//! [`crate::fast_parse::parse_indices_direct`], which deliberately
32//! *saturates* an out-of-range vertex index to `u32::MAX`: that value is a
33//! sentinel a downstream bounds check drops, not a key another value could
34//! collide with, so saturation is safe there and is not safe here.
35
36const UNCHECKED_DIGITS: usize = 9;
37
38#[inline]
39fn append_digit(value: u32, byte: u8) -> u32 {
40    value * 10 + (byte - b'0') as u32
41}
42
43#[inline]
44fn append_digit_checked(value: u32, byte: u8) -> Option<u32> {
45    value.checked_mul(10)?.checked_add((byte - b'0') as u32)
46}
47
48/// Read the leading ASCII digit run, returning its byte length and express id.
49/// An empty or overflowing run yields None, but overflow never stops scanning:
50/// callers still need the exact prefix end to validate the declaration (#3987).
51/// First-nine-digit arithmetic is unchecked because it cannot exceed 999999999.
52#[inline]
53pub(crate) fn parse_express_id_prefix(input: &[u8]) -> (usize, Option<u32>) {
54    let mut end = 0;
55    let mut result = 0u32;
56    for &byte in input.iter().take(UNCHECKED_DIGITS) {
57        if !byte.is_ascii_digit() { return (end, (end != 0).then_some(result)); }
58        result = append_digit(result, byte);
59        end += 1;
60    }
61    if end == 0 { return (0, None); }
62    let mut value = Some(result);
63    for &byte in &input[end..] {
64        if !byte.is_ascii_digit() { break; }
65        value = value.and_then(|value| append_digit_checked(value, byte));
66        end += 1;
67    }
68    (end, value)
69}
70
71/// Parse `digits` — an already-validated, non-empty run of ASCII digit bytes
72/// — into a `u32` express id, or `None` if the value does not fit.
73///
74/// Two loops rather than one: a run of at most 9 digits is at most
75/// 999_999_999 and cannot overflow `u32`, so the common case (every real
76/// exporter's ids) keeps the unchecked instruction sequence. Only a 10+
77/// digit run — which no real exporter emits — pays for `checked_mul` /
78/// `checked_add`.
79///
80/// Callers are expected to have already located the digit run (e.g. by
81/// scanning forward while `is_ascii_digit()` holds); this function does not
82/// search for one and returns `Some(0)` for an all-zero run rather than
83/// treating it as absent — callers that treat id `0` as "no reference" must
84/// check that themselves, the same way they did before this helper existed.
85#[inline]
86pub fn parse_express_id(digits: &[u8]) -> Option<u32> {
87    debug_assert!(
88        !digits.is_empty() && digits.iter().all(u8::is_ascii_digit),
89        "parse_express_id expects a validated, non-empty digit run"
90    );
91    let mut result: u32 = 0;
92    if digits.len() <= UNCHECKED_DIGITS {
93        for &b in digits {
94            result = append_digit(result, b);
95        }
96        return Some(result);
97    }
98    for &b in digits {
99        result = append_digit_checked(result, b)?;
100    }
101    Some(result)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn short_runs_parse_without_overflow_checks() {
110        assert_eq!(parse_express_id(b"1"), Some(1));
111        assert_eq!(parse_express_id(b"999999999"), Some(999_999_999));
112    }
113
114    #[test]
115    fn max_u32_is_inclusive() {
116        assert_eq!(parse_express_id(b"4294967295"), Some(u32::MAX));
117    }
118
119    #[test]
120    fn one_past_max_is_refused_not_wrapped() {
121        // 4294967296 = 2^32: the first value that does not fit u32. A
122        // wrapping accumulator would yield 0; this must yield None.
123        assert_eq!(parse_express_id(b"4294967296"), None);
124    }
125
126    #[test]
127    fn the_defect_value_is_refused_not_aliased_to_a_real_id() {
128        // 4294967297 % 2^32 == 1: a wrapping accumulator binds this to a
129        // real #1, which is the actual defect (#3421), not merely an
130        // overflow that errors.
131        assert_eq!(parse_express_id(b"4294967297"), None);
132    }
133
134    /// #3987: independent decimal-parser oracle covers leading zeroes and long
135    /// overflow prefixes, including bytes that must remain for the scanner.
136    #[test]
137    fn express_prefix_matches_checked_decimal_oracle_3987() {
138        let runs = ["0", "1", "999999999", "1000000000", "4294967295",
139            "4294967296", "4294967297", "999999999999999999999999999999999"];
140        for zeros in [0, 1, 9, 10, 64, 1024] {
141            for run in runs {
142                let digits = format!("{}{run}", "0".repeat(zeros));
143                let expected = digits.parse::<u32>().ok();
144                assert_eq!(parse_express_id(digits.as_bytes()), expected);
145                for suffix in [b"".as_slice(), b"=IFCWALL($);", b"/* ; */ =", b"x", b"\xff"] {
146                    let source = [digits.as_bytes(), suffix].concat();
147                    assert_eq!(parse_express_id_prefix(&source), (digits.len(), expected));
148                }
149            }
150        }
151        for source in [b"".as_slice(), b"=", b"-1", b"+1", b" ", b"\xff"] {
152            assert_eq!(parse_express_id_prefix(source), (0, None));
153        }
154        for value in [2u32, 19, 100, 123456789, u32::MAX - 1] {
155            let source = format!("{value};trailing");
156            assert_eq!(parse_express_id_prefix(source.as_bytes()),
157                (value.to_string().len(), Some(value)));
158        }
159    }
160}