ifc_lite_core/
step_encoding.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//! STEP string escape decoding (ISO 10303-21 / IFC).
6//!
7//! Decoding only. The one writer is `ifc_lite_export::escape_step_string`
8//! (a second, literal-unsafe encoder here was deleted: architecture review
9//! behind #4577, F6). The round trip `decode_ifc_string(escape(s)) == s` is
10//! pinned in `rust/export/tests/step_escape_parity.rs`.
11//!
12//! IFC string attribute values encode non-ASCII characters with backslash
13//! escape sequences. This module decodes them to native UTF-8 so the Rust
14//! crates, CLI, and server surface the same text the browser parser does via
15//! `decodeIfcString` in `@ifc-lite/encoding`. The two decoders are pinned to a
16//! shared test-vector fixture (`tests/fixtures/ifc_string_vectors.json`).
17//!
18//! Supported escapes:
19//! - `\X2\HHHH..\X0\` UTF-16 code units, 4 hex digits each (surrogate pairs ok)
20//! - `\X4\HHHHHHHH..\X0\` Unicode scalar values, 8 hex digits each
21//! - `\X\HH` single ISO-8859-1 byte (NOT code-page dependent: always the ISO
22//!   10646 row-0 value, per ISO 10303-21 6.4.3)
23//! - `\S\C` extended ASCII: code point of `C` plus 128, mapped through the
24//!   currently selected `\P?\` code page (default ISO 8859-1)
25//! - `\PA\`..`\PI\` code-page directive (A=ISO 8859-1 .. I=ISO 8859-9):
26//!   tracked for subsequent `\S\` escapes, then dropped. Any other letter is
27//!   dropped without changing the active page.
28//!
29//! ISO 10303-21 also doubles the reverse solidus inside a string literal, so
30//! `\\` decodes to one `\`. That arm sits AFTER the directive arms: a directive
31//! immediately followed by an escaped backslash ends in three backslashes
32//! (`\X2\00FC\X0\` + `\\`), and collapsing pairs first would eat the
33//! directive's own terminator.
34//!
35//! Unknown or malformed escapes are passed through unchanged. The `''`
36//! doubled-quote escape is NOT handled here — the tokenizer's consumers strip
37//! the surrounding quotes and un-double before calling this.
38
39use std::borrow::Cow;
40
41#[path = "step_codepages.rs"]
42mod step_codepages;
43use step_codepages::resolve_extended_char;
44
45/// Whether the apostrophe at `pos`, inside a STEP string literal, is the
46/// operand of the PAGE directive `\S\` (#7540). Tested before a doubled `''`:
47/// `'5\S\''` is "5§" and closes at its last quote. No other legal string unit
48/// ends in `\S\` right before an apostrophe, so this cannot misfire on a legal
49/// file.
50#[inline]
51pub fn is_page_quote_operand(bytes: &[u8], pos: usize) -> bool {
52    pos >= 3 && bytes[pos - 1] == b'\\' && bytes[pos - 2] == b'S' && bytes[pos - 3] == b'\\'
53}
54
55/// Index just past the apostrophe closing the STEP string literal whose
56/// opening apostrophe is at `open`, or `None` when it never closes (#7589).
57///
58/// The one rule for every walker that splits, rewrites or scans STEP text:
59/// `''` is an escaped quote and a `\S\` operand is text. A walker that ends
60/// the literal at the operand misreads every later comma, paren and
61/// reference in the record. The TypeScript twin is `stepStringLiteralEnd` in
62/// `@ifc-lite/encoding`.
63pub fn step_string_literal_end(bytes: &[u8], open: usize) -> Option<usize> {
64    let mut p = open + 1;
65    loop {
66        p += memchr::memchr(b'\'', bytes.get(p..)?)?;
67        if is_page_quote_operand(bytes, p) {
68            p += 1;
69        } else if bytes.get(p + 1) == Some(&b'\'') {
70            p += 2;
71        } else {
72            return Some(p + 1);
73        }
74    }
75}
76
77/// Decode IFC STEP string escapes to UTF-8.
78///
79/// Returns the input borrowed and untouched when it contains no backslash, so
80/// the common case (plain names, GUIDs, enums) is allocation-free.
81///
82/// This handles only backslash escapes. The `''` doubled-quote escape is
83/// collapsed by the STEP tokenizer's consumers (they strip the surrounding
84/// quotes and un-double), so decoding must not touch quotes or it would
85/// double-collapse those paths.
86pub fn decode_ifc_string(s: &str) -> Cow<'_, str> {
87    if !s.as_bytes().contains(&b'\\') {
88        return Cow::Borrowed(s);
89    }
90
91    let bytes = s.as_bytes();
92    let n = bytes.len();
93    let mut out = String::with_capacity(n);
94    let mut i = 0;
95    let mut codepage: u8 = 1;
96
97    while i < n {
98        if bytes[i] != b'\\' {
99            // Copy one whole UTF-8 character; `i` is always on a char boundary
100            // because every escape marker is ASCII.
101            let ch = s[i..].chars().next().unwrap();
102            out.push(ch);
103            i += ch.len_utf8();
104            continue;
105        }
106
107        // `\PC\` code-page directive: track A..I as codepage 1..9 for
108        // subsequent `\S\` escapes, then drop the four bytes. Any other
109        // letter (or an unrecognized custom-page form) is dropped without
110        // changing the active page.
111        if i + 3 < n && bytes[i + 1] == b'P' && bytes[i + 3] == b'\\' {
112            let letter = bytes[i + 2];
113            if (b'A'..=b'I').contains(&letter) {
114                codepage = letter - b'A' + 1;
115            }
116            i += 4;
117            continue;
118        }
119
120        // `\S\C`: byte value is the code point of `C` plus 128, mapped
121        // through the active code page. Read `C` as a whole char and advance
122        // by its UTF-8 length so a malformed multi-byte `C` can't leave `i`
123        // mid-character (which would panic the next slice).
124        if i + 3 < n && bytes[i + 1] == b'S' && bytes[i + 2] == b'\\' {
125            let c = s[i + 3..].chars().next().unwrap();
126            let code = resolve_extended_char(codepage, c as u32 + 128);
127            out.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
128            i += 3 + c.len_utf8();
129            continue;
130        }
131
132        // `\X\HH`: a single ISO-8859-1 byte.
133        if i + 4 < n && bytes[i + 1] == b'X' && bytes[i + 2] == b'\\' {
134            if let (Some(hi), Some(lo)) = (hex_val(bytes[i + 3]), hex_val(bytes[i + 4])) {
135                let code = ((hi << 4) | lo) as u32;
136                out.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
137                i += 5;
138                continue;
139            }
140        }
141
142        // `\X2\HHHH..\X0\`: UTF-16 code units (decoded as a unit, so surrogate
143        // pairs spanning two groups combine correctly).
144        if starts_with(bytes, i, b"\\X2\\") {
145            if let Some(end) = find(bytes, i + 4, b"\\X0\\") {
146                let hex = &s[i + 4..end];
147                if !hex.is_empty()
148                    && hex.len().is_multiple_of(4)
149                    && hex.bytes().all(|c| c.is_ascii_hexdigit())
150                {
151                    let units: Vec<u16> = (0..hex.len())
152                        .step_by(4)
153                        .map(|j| u16::from_str_radix(&hex[j..j + 4], 16).unwrap())
154                        .collect();
155                    out.push_str(&String::from_utf16_lossy(&units));
156                    i = end + 4;
157                    continue;
158                }
159            }
160        }
161
162        // `\X4\HHHHHHHH..\X0\`: Unicode scalar values.
163        if starts_with(bytes, i, b"\\X4\\") {
164            if let Some(end) = find(bytes, i + 4, b"\\X0\\") {
165                let hex = &s[i + 4..end];
166                if !hex.is_empty()
167                    && hex.len().is_multiple_of(8)
168                    && hex.bytes().all(|c| c.is_ascii_hexdigit())
169                {
170                    for j in (0..hex.len()).step_by(8) {
171                        let v = u32::from_str_radix(&hex[j..j + 8], 16).unwrap();
172                        out.push(char::from_u32(v).unwrap_or('\u{FFFD}'));
173                    }
174                    i = end + 4;
175                    continue;
176                }
177            }
178        }
179
180        // `\\`: one literal reverse solidus (ISO 10303-21 doubles it inside a
181        // string literal). Checked after the directive arms so a `\X0\`/`\X\`
182        // terminator adjacent to an escaped backslash is consumed by its own
183        // directive first, never paired with the escape that follows it.
184        if i + 1 < n && bytes[i + 1] == b'\\' {
185            out.push('\\');
186            i += 2;
187            continue;
188        }
189
190        // Unknown escape: keep the backslash and advance one byte.
191        out.push('\\');
192        i += 1;
193    }
194
195    Cow::Owned(out)
196}
197
198#[inline]
199fn hex_val(b: u8) -> Option<u8> {
200    match b {
201        b'0'..=b'9' => Some(b - b'0'),
202        b'a'..=b'f' => Some(b - b'a' + 10),
203        b'A'..=b'F' => Some(b - b'A' + 10),
204        _ => None,
205    }
206}
207
208#[inline]
209fn starts_with(bytes: &[u8], at: usize, pat: &[u8]) -> bool {
210    bytes.len() >= at + pat.len() && &bytes[at..at + pat.len()] == pat
211}
212
213fn find(bytes: &[u8], from: usize, pat: &[u8]) -> Option<usize> {
214    if pat.is_empty() || from + pat.len() > bytes.len() {
215        return None;
216    }
217    bytes[from..]
218        .windows(pat.len())
219        .position(|w| w == pat)
220        .map(|p| from + p)
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn step_string_literal_end_keeps_page_operands_inside_7589() {
229        let vectors: serde_json::Value = serde_json::from_str(include_str!(
230            "../tests/fixtures/step-page-directive.json"
231        ))
232        .unwrap();
233        for vector in vectors.as_array().unwrap() {
234            let literal = vector["literal"].as_str().unwrap();
235            let text = format!("({literal},'next')");
236            assert_eq!(super::step_string_literal_end(text.as_bytes(), 1), Some(1 + literal.len()), "{}", vector["name"]);
237        }
238        assert_eq!(super::step_string_literal_end(br"('a\S\'", 1), None);
239        assert_eq!(super::step_string_literal_end(b"('it''", 1), None);
240    }
241
242    #[test]
243    fn no_backslash_is_borrowed_and_unchanged() {
244        assert!(matches!(decode_ifc_string("Hello World"), Cow::Borrowed(_)));
245        // A typical base64 IFC GUID contains no backslash.
246        assert_eq!(decode_ifc_string("3Bvg7$qHb0gP37$Qz2vN1k"), "3Bvg7$qHb0gP37$Qz2vN1k");
247    }
248
249    #[test]
250    fn decodes_x2_bmp() {
251        assert_eq!(decode_ifc_string(r"Br\X2\00FC\X0\cke"), "Br\u{FC}cke");
252    }
253
254    #[test]
255    fn decodes_x2_surrogate_pair() {
256        assert_eq!(decode_ifc_string(r"\X2\D83DDE00\X0\"), "\u{1F600}");
257    }
258
259    #[test]
260    fn decodes_x4_astral() {
261        assert_eq!(decode_ifc_string(r"\X4\0001F600\X0\"), "\u{1F600}");
262    }
263
264    #[test]
265    fn decodes_x_and_s() {
266        assert_eq!(decode_ifc_string(r"\X\E9"), "\u{E9}");
267        assert_eq!(decode_ifc_string(r"\S\a"), "\u{E1}");
268    }
269
270    #[test]
271    fn drops_code_page_directive() {
272        assert_eq!(decode_ifc_string(r"\PA\Hello"), "Hello");
273    }
274
275    #[test]
276    fn collapses_the_doubled_reverse_solidus() {
277        // ISO 10303-21 doubles the reverse solidus inside a string literal just
278        // as it doubles the apostrophe, so the pair is ONE backslash (#2323).
279        assert_eq!(decode_ifc_string(r"C:\\temp"), r"C:\temp");
280        // Two escaped backslashes stay two: exactly one collapsing pass.
281        assert_eq!(decode_ifc_string(r"\\\\"), r"\\");
282        // A directive consumes its own \X0\ terminator before the pair escape is
283        // considered, so a trailing escaped backslash survives whole.
284        assert_eq!(decode_ifc_string("\\X2\\00FC\\X0\\\\\\"), "\u{FC}\\");
285        // Mirror case: a leading escaped backslash makes the rest literal text.
286        assert_eq!(decode_ifc_string(r"\\X2\00FC\X0\"), r"\X2\00FC\X0\");
287    }
288
289    #[test]
290    fn keeps_unknown_escape() {
291        assert_eq!(decode_ifc_string(r"a\Qb"), r"a\Qb");
292        // Malformed (no terminator) is passed through, not panicked on.
293        assert_eq!(decode_ifc_string(r"\X2\00FC"), r"\X2\00FC");
294    }
295
296    #[test]
297    fn s_escape_before_multibyte_char_does_not_panic() {
298        // A malformed `\S\` followed by a multi-byte UTF-8 char must not leave
299        // the cursor mid-character (previously panicked via a non-boundary
300        // slice, aborting the whole wasm instance under panic=abort).
301        let _ = decode_ifc_string("\\S\\\u{00E9}tail");
302        let _ = decode_ifc_string("x\\S\\\u{1F600}y");
303        // The canonical single-ASCII form is unchanged.
304        assert_eq!(decode_ifc_string(r"\S\a"), "\u{E1}");
305    }
306
307    #[test]
308    fn s_escape_honours_the_selected_code_page() {
309        // ISO 10303-21 6.4.3: \PE\ selects ISO 8859-5 (Cyrillic). Before
310        // codepage tracking, every \S\ was decoded as if the default page
311        // (ISO 8859-1) were active, giving U+00D0 (LATIN CAPITAL LETTER ETH)
312        // instead of the correct U+0430 (CYRILLIC SMALL LETTER A).
313        assert_eq!(decode_ifc_string(r"\PE\\S\P"), "\u{0430}");
314        // The page persists across multiple \S\ escapes in the same string...
315        assert_eq!(decode_ifc_string(r"\PE\\S\P\S\Q"), "\u{0430}\u{0431}");
316        // ...until another \P?\ directive switches it again.
317        assert_eq!(decode_ifc_string(r"\PE\\S\P\PA\\S\P"), "\u{0430}\u{00D0}");
318        // A byte position ISO 8859-6 (Arabic, \PF\) itself leaves unassigned
319        // falls back to the raw code point rather than U+FFFD.
320        assert_eq!(decode_ifc_string(r"\PF\\S\0"), "\u{00B0}");
321    }
322}