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}