Skip to content

fix(path): #7621 — path.* arms read an SSO string's inline bytes as a StringHeader pointer - #7626

Merged
proggeramlug merged 3 commits into
mainfrom
fix/7621-path-resolve-sso
Aug 8, 2026
Merged

fix(path): #7621 — path.* arms read an SSO string's inline bytes as a StringHeader pointer#7626
proggeramlug merged 3 commits into
mainfrom
fix/7621-path-resolve-sso

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Closes #7621.

Root cause

crates/perry-codegen/src/expr/arrays_finds.rs:604 (and eleven siblings)
unboxed a path.* operand with unbox_to_i64bitcast double -> i64; and POINTER_MASK — and handed the low 48 bits to a runtime entry that dereferences
them as *const StringHeader.

That is the header for a heap string (STRING_TAG = 0x7FFF, payload = the
pointer) and the characters for a small-string-optimized one
(SHORT_STRING_TAG = 0x7FF9, payload = length + up to SHORT_STRING_MAX_LEN = 5
inline bytes, crates/perry-runtime/src/value/tags.rs:96). A literal is interned
onto the heap, so path.resolve("/root", "s1") worked; a computed short string
takes the inline form, so path.resolve("/root", seg(1)) threw
ERR_INVALID_ARG_TYPE. The #214 class.

Bisected by length on a pre-fix build — the boundary is exactly
SHORT_STRING_MAX_LEN:

resolve len=2  THROW      resolve len=6  "/root/seeeee"
resolve len=3  THROW      resolve len=7  "/root/seeeeee"
resolve len=4  THROW      resolve len=8  "/root/seeeeeee"
resolve len=5  THROW

Scope: twelve arms, not the five the issue named

Every arm below was confirmed broken on a pre-fix build with a computed 2-byte
operand. Two of them were silently wrong rather than throwing, which is why
they had never been noticed:

arm pre-fix node
resolve(a, b), resolve(p), join, win32.join, normalize, extname, dirname, basename, basename(p, ext), isAbsolute THROW ERR_INVALID_ARG_TYPE the value
parse(p).base "" "se"
matchesGlob(p, pat) matched against "" matches against p

path.relative, path.format and path.toNamespacedPath were already fine —
they take NaN-boxed values (js_path_relative_checked, #2995), which is the
precedent this PR generalises.

The fix

Single-operand arms call the new js_path_arg_header(value) -> i64. It
materialises only the SSO case and otherwise reproduces the old mask bit for
bit — heap strings and every non-string. That asymmetry is the point: each
entry point keeps its own established non-string behaviour (js_path_join
throws, js_path_matches_glob defaults to "") unexamined and unchanged, so the
blast radius is provably the SSO case alone.

It is deliberately not js_get_string_pointer_unified (which
unbox_str_handle wraps). That helper coerces non-strings — a number comes back
as "5" — which would silently turn path.isAbsolute(5) from Node's
ERR_INVALID_ARG_TYPE throw into false.

Two-operand arms hand both operands to the runtime NaN-boxed
(js_path_*_value). Codegen cannot close that window itself, which is the
"why it is not a one-line fix" the issue flagged: materialising the first operand
allocates, and rooting::with_operands_rooted yields the lowering registers,
not slots, so the second operand's register is stale the instant the first is
materialised and there is no re-read to reach for (RootedSlot has no read, by
design — see the note in crates/perry-codegen/src/rooting.rs:334). Doing both
unboxes inside one runtime call puts the window somewhere it can be closed
properly: crates/perry-runtime/src/path/value_args.rs roots the first operand in
a RuntimeHandleScope and re-reads it through RuntimeHandle::across_const,
never binding the pre-collection address.

A non-string operand is a masked garbage address, so it is deliberately not
rooted — handing the collector something to mark that is not an object would be a
worse bug than the one being fixed.

Did the fix need rooting discipline? Yes — and here is its honest status

The SSO half is sabotage-proven in both directions. The rooting half is
defensive, not instrument-proven, and the module docs say so at the site:

  • Disabling the SSO branch (if false && ...is_short_string()) turns
    sso_operand_resolves_to_a_real_header red and crashes
    both_operands_survive_the_materialisation_window; the gap test threw at line
    2 with exit 1 on the pre-fix binary.

  • Reverting across_const to a pre-bound let a_ptr = a_ptr0; produced zero
    faults: byte-identical on the gap test under PERRY_GC_ZEAL=1 +
    PERRY_GC_PROTECT_FROMSPACE=1 (402,214 copying minors observed via
    PERRY_GC_DIAG), and 0/400,000 mismatches on an SSO-pair path.join hammer
    under PERRY_GC_FORCE_EVACUATE=1 + from-space protection.

    That is not a fluke, it is structural: the window is allocation-to-allocation,
    and a collection reached from inside an allocation runs with
    GC_FLAG_IN_ALLOC set, which makes the copying minor ineligible. Nothing
    moves at an allocation point today, so no available instrument can turn that
    window red. The ordering is written the correct way because it is the shape the
    invariant asks for and costs nothing — not because a probe caught it.

Validation (local; CI backlog is deep, so this is the evidence)

  • test-files/test_gap_7621_path_sso_operands.ts — byte-identical to
    node --experimental-strip-types on the pinned 26.5.1, exit 0 both sides.
    Covers both sides of the SSO boundary (lengths 2..10), computed and literal
    operands, absolute and relative bases (asserted cwd-independently),
    multi-segment and absolute-reset resolves, all twelve arms, the non-string
    throw, and a nursery-churn loop around the two-operand window.
  • Same test byte-identical under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, compiled and run with
    PERRY_GC_MOVING_LOOP_POLLS=1; the instrument was asserted live
    ([gc-fromspace-protect] retired_set=#N, 402,214 sets).
  • No regressions: test_parity_path, test_gap_node_path,
    test_gap_6371_path_lexical_semantics, test_gap_node_fs,
    test_cli_simulation all byte-identical to node.
  • 5 new perry-runtime unit tests; cargo test -p perry-runtime --no-fail-fast
    1890 passed / 0 failed, cargo test -p perry-codegen 690 passed / 0 failed.
  • Lint: cargo fmt --all -- --check, check_file_size.sh,
    addr_class_inventory.py, raw_handle_debt.py (998, unchanged — the new code
    uses across_const, not a bare get_raw_*_ptr), class_id_collisions.py,
    check_test_registration.py, gc_store_site_inventory.py,
    workspace_architecture.py all pass.

One pre-existing failure is untouched by this PR:
perry-codegen's large_local_array_push_inbounds_store_emits_precise_slot_barrier
fails identically on pristine origin/main (A/B'd in this worktree).


Rebased onto v0.5.1357 (#7627) — semantic conflict resolved

#7627 migrated expr/instance_misc1.rs onto the Layer 1 rooting API while this
was open. Rebasing gave 4 conflicts, all in that one file; every other file
applied cleanly. The migrated form is the base and this fix layers on top:

  • Two-operand arms (PathJoin, PathWin32Join, PathBasenameExt, and the
    win32 BasenameExt / ResolveJoin / MatchesGlob methods) keep their
    with_operands_rooted wrapper and now emit no unbox at all — both operands
    go to the runtime NaN-boxed. That is strictly stronger than the migrated base:
    it deletes the window from codegen rather than protecting it, so there is no
    raw operand register in the arm for a collection to invalidate.
  • Single-operand arms emit js_path_arg_header inside the rooted region,
    immediately followed by its consumer.

On with_operands_rooted_across_call (added by #7627 for exactly this
shape): I read its doc and deliberately did not use it. Its own closing
paragraph draws the line — "Use it only when the emitted step can re-enter user
code or enumerate an arbitrary object's own properties. For a helper that merely
allocates, the project's position (#7198) is that it cannot initiate a moving
collection, so a root there would be pure cost."
js_path_arg_header only
materialises SSO bytes; it dispatches nothing and enumerates nothing. That is
also the same fact I measured independently before the rebase (a collection
reached from inside an allocation runs with GC_FLAG_IN_ALLOC, so the copying
minor is ineligible), which is why the rooting sabotage arm could not be made to
fault. Two independent routes to the same conclusion.

And after the resolution the question is moot for the two-operand arms anyway:
they emit zero js_path_arg_header calls, so there is no codegen-side window to
state.

Re-verified after the rebase, not before

  • SSO bisect re-confirmed against the new base: built origin/main
    (v0.5.1357) in this worktree — lengths 2–5 throw, 6+ work, and parse/
    matchesGlob are still silently wrong. refactor(codegen): migrate instance_misc1 + logical_collections + map_set onto the Layer 1 rooting API (#7615) #7627 did not change the bug (it is
    IR-identical by construction).

  • Gap test byte-identical to node 26.5.1, exit 0; byte-identical again under
    PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 with 402,214 copying minors
    asserted live via PERRY_GC_DIAG.

  • test_parity_path, test_gap_node_path, test_gap_6371_path_lexical_semantics,
    test_gap_node_fs, test_cli_simulation all byte-identical.

  • Root-dominance corpus, both gated modes, on the rebased build — 129/129
    sources compiled, 149 .ll:

    • dominance: 2452 functions / 149 modules / 9846 root stores, 0 violations,
      and 40/40 seeded violations caught (the checker asserted able to fail);
    • --unrooted-allocas: 7867 gc-capable allocas, 0 violations.

    Both above the CI liveness floors (--min-files 90 --min-binds 1500 --min-funcs 1200), allowlist still empty.

  • Ledger: instance_misc1.rs is in MIGRATED_MODULES now and nothing added
    here names expr::temp_root
    migrated_modules_do_not_reach_past_the_rooting_api green, alongside its
    planted-violation and clears-the-migrated-form siblings.

  • cargo test -p perry-codegen --lib 694 passed / 0 failed;
    -p perry-runtime --no-fail-fast 1890 passed / 0 failed.

  • Full lint set from .github/workflows/test.yml: cargo fmt --all -- --check,
    check_file_size.sh, addr_class_inventory.py (+--self-test),
    raw_handle_debt.py (+--self-test, 998, unchanged),
    class_id_collisions.py, check_test_registration.py (+--self-test),
    gc_store_site_inventory.py (+--self-test), workspace_architecture.py,
    gap_snapshot.py --self-test, gc_gate_wiring_check.py — all green.

Conflict-free against #7631 (git merge-tree of the two heads exits 0), and
#7626 adds no perry_hir::Class literal, so #7631's struct widening cannot break
it in either merge order.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Path codegen now passes boxed operands to SSO-safe path helpers. The runtime materializes short strings, roots operands across allocation, and preserves heap-string and non-string behavior. Tests cover POSIX and Windows APIs, SSO boundaries, errors, and allocation churn.

Changes

Path operand materialization

Layer / File(s) Summary
Runtime materialization and rooting
crates/perry-runtime/src/path.rs, crates/perry-runtime/src/path/value_args.rs
The runtime adds SSO header materialization, rooted two-operand wrappers, value-based entry points, and runtime tests.
Codegen runtime wiring
crates/perry-codegen/src/runtime_decls/strings.rs, crates/perry-codegen/src/expr/...
Path operations use js_path_arg_header for single operands and boxed value helpers for multi-operand operations across POSIX and Windows paths.
Computed operand regression coverage
test-files/test_gap_7621_path_sso_operands.ts, changelog.d/7626-path-sso-operands.md
Tests cover computed SSO operands, path variants, non-string errors, and allocation churn. The changelog records the affected operations and coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PathCodegen
  participant ValueArgs as value_args
  participant PathRuntime
  PathCodegen->>ValueArgs: Pass boxed path operands
  ValueArgs->>ValueArgs: Materialize SSO strings
  ValueArgs->>ValueArgs: Root and reread operands across allocation
  ValueArgs->>PathRuntime: Invoke pointer-based path operation
  PathRuntime-->>PathCodegen: Return path result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes fix the linked SSO handling issue across the affected path operations and include the required rooting strategy and tests.
Out of Scope Changes check ✅ Passed The code, runtime changes, documentation, and regression tests directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly identifies the path SSO bug and the affected path operations.
Description check ✅ Passed The description thoroughly covers the root cause, fix, issue link, scope, tests, validation, and known pre-existing failure.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7621-path-resolve-sso

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelog.d/7626-path-sso-operands.md`:
- Around line 50-53: Correct the test coverage statement in the changelog entry
to report the four unit tests defined in value_args.rs, unless a fifth runtime
test is actually added.

In `@crates/perry-runtime/src/path/value_args.rs`:
- Around line 101-110: Root both path operands before any materialization and
reload them after allocating operations in the runtime path flow around
value_args.rs:101-110. In instance_misc1.rs, wrap PathJoin (882-890),
PathWin32Join (902-905), Win32 BasenameExt/ResolveJoin/MatchesGlob (964-992),
and PathBasenameExt (1634-1645) lowering with rooting::with_operands_rooted so
raw operands remain valid across sequential lowering. Add the requested
GC-stress coverage for a heap first operand and allocating second operand in
test_gap_7621_path_sso_operands.ts:88-106.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a662af9-4584-4db2-9030-812d8245abff

📥 Commits

Reviewing files that changed from the base of the PR and between 38ff7ec and 34e59f6.

📒 Files selected for processing (9)
  • changelog.d/7626-path-sso-operands.md
  • crates/perry-codegen/src/expr/array_methods.rs
  • crates/perry-codegen/src/expr/arrays_finds.rs
  • crates/perry-codegen/src/expr/instance_misc1.rs
  • crates/perry-codegen/src/expr/misc_methods.rs
  • crates/perry-codegen/src/runtime_decls/strings.rs
  • crates/perry-runtime/src/path.rs
  • crates/perry-runtime/src/path/value_args.rs
  • test-files/test_gap_7621_path_sso_operands.ts

Comment on lines +50 to +53
Covered by `test-files/test_gap_7621_path_sso_operands.ts` (both sides of the
SSO boundary, computed and literal operands, absolute and relative bases,
multi-segment resolves, all twelve arms, the non-string throw) plus five
`perry-runtime` unit tests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the runtime-test count.

crates/perry-runtime/src/path/value_args.rs defines four unit tests, not five. Change this count or add the missing test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7626-path-sso-operands.md` around lines 50 - 53, Correct the test
coverage statement in the changelog entry to report the four unit tests defined
in value_args.rs, unless a fifth runtime test is actually added.

Comment on lines +101 to +110
let scope = RuntimeHandleScope::new();
let a_ptr0 = path_arg_header(a);
let a_handle = JSValue::from_bits(a.to_bits())
.is_any_string()
.then(|| scope.root_string_ptr(a_ptr0));
let (b_ptr, a_ptr) = match &a_handle {
Some(h) => h.across_const::<StringHeader, _>(|| path_arg_header(b)),
None => (path_arg_header(b), a_ptr0),
};
f(a_ptr, b_ptr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Root both path operands across the complete two-operand flow.

The current flow has two GC windows. Codegen can invalidate the first operand while it lowers the second operand. The runtime can invalidate the second heap-string operand while it materializes the first SSO operand.

  • crates/perry-runtime/src/path/value_args.rs#L101-L110: establish valid roots for both string operands before either materialization, then reload after allocation.
  • crates/perry-codegen/src/expr/instance_misc1.rs#L882-L890: lower PathJoin with rooting::with_operands_rooted.
  • crates/perry-codegen/src/expr/instance_misc1.rs#L902-L905: lower PathWin32Join with rooting::with_operands_rooted.
  • crates/perry-codegen/src/expr/instance_misc1.rs#L964-L992: root the two operands for Win32 BasenameExt, ResolveJoin, and MatchesGlob before sequential lowering can expose stale values.
  • crates/perry-codegen/src/expr/instance_misc1.rs#L1634-L1645: lower PathBasenameExt with rooting::with_operands_rooted.
  • test-files/test_gap_7621_path_sso_operands.ts#L88-L106: add a GC-stress case with a heap first operand and an allocating second operand.
Proposed lowering pattern
-        Expr::PathJoin(a, b) => {
-            let a_box = lower_expr(ctx, a)?;
-            let b_box = lower_expr(ctx, b)?;
+        Expr::PathJoin(a, b) => rooting::with_operands_rooted(ctx, &[a, b], |ctx, vals| {
+            let a_box = vals[0].clone();
+            let b_box = vals[1].clone();
             let blk = ctx.block();
             let result = blk.call(
                 I64,
                 "js_path_join_value",
                 &[(DOUBLE, &a_box), (DOUBLE, &b_box)],
             );
-            Ok(nanbox_string_inline(blk, &result))
-        }
+            Ok(nanbox_string_inline(blk, &result))
+        })

As per coding guidelines, “A GC-managed value's root store must dominate every subsequent operation that can collect.” Based on learnings, raw NaN-boxed values must be rooted and reloaded across allocating operations.

📍 Affects 3 files
  • crates/perry-runtime/src/path/value_args.rs#L101-L110 (this comment)
  • crates/perry-codegen/src/expr/instance_misc1.rs#L882-L890
  • crates/perry-codegen/src/expr/instance_misc1.rs#L902-L905
  • crates/perry-codegen/src/expr/instance_misc1.rs#L964-L992
  • crates/perry-codegen/src/expr/instance_misc1.rs#L1634-L1645
  • test-files/test_gap_7621_path_sso_operands.ts#L88-L106
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/path/value_args.rs` around lines 101 - 110, Root
both path operands before any materialization and reload them after allocating
operations in the runtime path flow around value_args.rs:101-110. In
instance_misc1.rs, wrap PathJoin (882-890), PathWin32Join (902-905), Win32
BasenameExt/ResolveJoin/MatchesGlob (964-992), and PathBasenameExt (1634-1645)
lowering with rooting::with_operands_rooted so raw operands remain valid across
sequential lowering. Add the requested GC-stress coverage for a heap first
operand and allocating second operand in
test_gap_7621_path_sso_operands.ts:88-106.

Sources: Coding guidelines, Learnings

Ralph Küpper added 2 commits August 8, 2026 11:10
… header

`path.resolve("/root", computedShortString)` threw ERR_INVALID_ARG_TYPE where
node returns the path. Every `path.*` codegen arm unboxed its operand with
`unbox_to_i64` (`bitcast double -> i64; and POINTER_MASK`) and handed the low 48
bits to a runtime entry that dereferences them as `*const StringHeader`. That is
the header for a HEAP string and the CHARACTERS for a small-string-optimized one
(SHORT_STRING_TAG, <= SHORT_STRING_MAX_LEN = 5 inline bytes) — so a literal
worked (interned onto the heap) and a computed short string did not. Bisected by
length: 5 bytes throws, 6 works.

Twelve arms were affected, not the five the issue named: resolve, resolve's fold
step, join, win32.join, normalize, extname, dirname, basename, basename(p, ext),
isAbsolute, parse, matchesGlob, plus the win32 sub-namespace equivalents. `parse`
and `matchesGlob` were SILENTLY wrong rather than throwing.

Single-operand arms now call `js_path_arg_header`, which materializes only the
SSO case and reproduces the old mask bit for bit for heap strings AND every
non-string — so each entry point keeps its own established non-string behaviour
(throw, or `unwrap_or_default`). It is deliberately not
`js_get_string_pointer_unified`, which coerces numbers to strings and would turn
`path.isAbsolute(5)` from a throw into `false`.

Two-operand arms take both operands NaN-boxed and unbox inside one runtime call
(`js_path_*_value`). Codegen cannot close that window itself: materializing the
first operand allocates, and `rooting::with_operands_rooted` hands the lowering
registers rather than slots, so the second operand's register is stale with no
re-read to reach for. The runtime entry roots the first operand across the
second's materialization via `RuntimeHandle::across_const`.

Validated locally: the new gap test is byte-identical to node 26.5.1 and exits 0
(it threw at line 2 before the fix); byte-identical again under PERRY_GC_ZEAL=1 +
PERRY_GC_PROTECT_FROMSPACE=1 with 402k copying minors observed; test_parity_path,
test_gap_node_path, test_gap_6371_path_lexical_semantics, test_gap_node_fs and
test_cli_simulation unchanged.
@proggeramlug
proggeramlug merged commit bce1045 into main Aug 8, 2026
@proggeramlug
proggeramlug deleted the fix/7621-path-resolve-sso branch August 8, 2026 09:38
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1358

Reproduced the bug on main and the fix on this branch, bisecting the SSO
boundary with computed strings:

main (pre-fix):   2 parse.base: ""      <- silently WRONG, node says "aa"
                  3 parse.base: "aaa"   <- correct
this PR:          identical to node 26.5.1 across lengths 2..7

That silent-empty case is the reason this went unreported for so long — it
never threw, it just returned the wrong string. Twelve arms rather than the
five filed.

The resolution of the #7627 collision is better than what I asked for. I
said "layer the fix inside the rooted region"; for the two-operand arms you
instead made them emit no unbox at all — both operands travel NaN-boxed to
one runtime call. That deletes the window from codegen rather than protecting
it, which is strictly stronger: there is no raw operand register in the arm to
be stale.

And declining with_operands_rooted_across_call was right. Its own doc
draws the line — use it when the emitted step can re-enter user code or
enumerate arbitrary own properties; js_path_arg_header only materialises SSO
bytes and dispatches nothing. Two independent routes to that conclusion (the
doc's rule, and your measured GC_FLAG_IN_ALLOC finding that made the rooting
sabotage unfaultable) is the right amount of evidence for a not-doing-it
decision.

Gates re-run here: runtime 1,890/0, codegen 694/0, all six lint scripts +
file-size + fmt clean.

Also worth keeping: your post-rebase sweep initially showed pre-fix behaviour
because it ran against a binary the background build was still writing. Catching
that rather than filing a phantom regression is the same class of care as the
$?-after-pipe and login-shell findings elsewhere today — and it is worth
noting that #7625 removed the other reason to re-run, so this one is now the
main reason left.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

path.resolve/extname/normalize/matchesGlob read an SSO string's inline bytes as a StringHeader pointer

1 participant