fix(runtime): #29 isWellFormed/toWellFormed lone-surrogate detection - #180
Merged
Conversation
String literals with lone surrogates (U+D800–U+DFFF) were silently
discarded at parse time: `Wtf8Atom::as_str()` returns None for lone
surrogates, and the old `unwrap_or("")` threw them away.
Fix:
- Add `Expr::WtfString(Vec<u8>)` HIR variant for WTF-8 string literals
- Add `js_string_from_wtf8_bytes` runtime function that sets a new
`STRING_FLAG_HAS_LONE_SURROGATES` flag in `StringHeader.flags`
- `isWellFormed()` → O(1) flag check; `toWellFormed()` scans raw WTF-8
bytes and replaces `0xED 0xA0..0xBF 0x80..0xBF` with U+FFFD (0xEF 0xBF 0xBD)
- Propagate the flag through `js_string_concat` so concatenating a
tainted string produces a tainted result
- Update `StringHeader` copies in `perry-ui-macos` and `perry-jsruntime`
- Add `test-files/test_is_well_formed.ts` (20 cases, matches Node byte-for-byte)
Gap suite: `test_gap_string_methods` flips FAIL → PASS.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #29.
What was broken
String.prototype.isWellFormed()always returnedtrue, andtoWellFormed()was a no-op, because lone surrogates were silently discarded at parse time.SWC stores string literals with lone surrogates as
Wtf8Atom. Callingas_str()on aWtf8Atomthat contains lone surrogates returnsNone(they can't be represented as valid UTF-8). The old code atlower_patterns.rsdid:So
"\uD800"(lone high surrogate) became an empty string""in the HIR.isWellFormed()was then checking a perfectly-valid empty string and returningtrue.Approach (StringHeader flag, not full WTF-8)
A full WTF-8 migration would touch every string operation in the runtime. Instead, this PR uses a minimal flag-based approach:
New HIR variant
Expr::WtfString(Vec<u8>)— carries raw WTF-8 bytes through the pipeline for string literals that contain lone surrogates. RegularExpr::String(String)is unchanged.New runtime function
js_string_from_wtf8_bytes— allocates and stores the raw WTF-8 bytes, setsSTRING_FLAG_HAS_LONE_SURROGATESin the newStringHeader.flagsfield.StringHeadergains aflags: u32field (offset 16, struct grows 16→20 bytes). Flag propagates throughjs_string_concatand fused concat variants so a concatenation of a tainted + clean string is tainted.isWellFormed()— O(1) flag check. ReturnsfalseiffSTRING_FLAG_HAS_LONE_SURROGATESis set.toWellFormed()— scans raw WTF-8 bytes. Replaces the WTF-8 lone-surrogate pattern (0xED 0xA0–0xBF 0x80–0xBF) with U+FFFD (0xEF 0xBF 0xBD). Returns a clean string viajs_string_from_bytes.Before / After
Gap test delta
test_gap_string_methods: FAIL → PASSGap suite total: 18/28 → 25/27 (several other tests in the suite had also improved between the last recorded sweep and now; this PR is responsible only for the
string_methodsflip).Test file
test-files/test_is_well_formed.ts— 20 cases covering:\uD800)\uDFFF)𐀀= U+10000)toWellFormedreplacementisWellFormedon normal ASCII / Unicode strings.lengthof a string containing a lone surrogate (should be 1, not 0)All 20 cases match Node
--experimental-strip-typesbyte-for-byte.What this does NOT fix
lower.rs/lower_types.rs)String.prototype.normalize()with lone surrogatesJSON.stringify/ regex engines /encodeURIComponent(those would need a deeper WTF-8 migration)These adjacent cases are noted in GAPS.md's "lone surrogate handling (WTF-8)" entry and are out of scope for this PR.
Struct layout note
StringHeadergrows from 16 to 20 bytes. All construction sites usestd::mem::size_of::<StringHeader>()so they adjust automatically. The two out-of-crate copies (perry-ui-macos/src/string_header.rsandperry-jsruntime/src/bridge.rs) are updated in this PR.Generated by Claude Code