render: the bidi marker fires on Fluent's routine FSI/PDI isolates #258

Open
opened 2026-09-12 01:31:32 +09:00 by natsukium · 0 comments
Owner

Summary

The REQ-909 bidi marker fires on every U+2066–U+2069 occurrence, including the well-formed
FSI … PDI pair that fluent-bundle wraps around every placeable. Fluent's FluentBundle has
use_isolating = true by default, so any Fluent-localized program emits these on ordinary output —
fj does it on most of its messages. The result is a yellow marker on routine, non-hostile text.

This is a scope question about the flag rule, not a detection error. Split out of #257, which covers
the separate rendering defect (the marker overwrites the preceding character).

Why this is worth revisiting

The nine codepoints really are the Trojan Source primitives, and bidi::is_override flagging all
nine matches what rustc's deny-by-default text_direction_codepoint_in_literal /
text_direction_codepoint_in_comment lints do. But rustc lints source code, where a bidi control
is almost never legitimate. A terminal renders program output, where a balanced FSI … PDI around
an LTR span is routine and produces no visual reordering at all.

What makes Trojan Source an attack is reordering the reader actually sees: an unbalanced isolate or
embedding, or an explicit override (LRO / RLO). A closed FSI … PDI pair whose content holds no
RTL characters and no other bidi control changes nothing on screen.

So the current rule marks a large volume of text that carries no attack, and a marker that appears on
ordinary output stops being read as a warning. That erodes the reader protection REQ-909 exists to
provide.

Constraint

felis performs no UAX#9 paragraph reordering, and that is a recorded non-goal
(docs/explanation/non-goals.md:103, docs/explanation/rendering/text-shaping.md:305). Any
narrowing must not smuggle paragraph bidi in through the back door. Today's check is per-cell and
stateless (cell_contains_bidi_override, crates/felis-render-wgpu/src/instances.rs:1070).

Directions to weigh

  • Narrow the rule to a line scan. Do not flag an FSI/LRI/RLIPDI pair that closes on
    the same line and whose enclosed span contains no RTL character and no further bidi control. Flag
    everything else, including every U+202AU+202E and every unbalanced isolate. This needs line
    state, not paragraph reordering, so it stays inside the non-goal.
  • Keep the rule, add the opt-out. security-model.md "Text rendering" and REQ-909 both say the
    marker renders "by default", but no config key turns it off — the wording promises a knob the
    implementation does not have. Either add the key or drop the words.
  • Keep the rule as is and record why the noise is acceptable, with a "Revisit if …" trigger.

Whatever lands

The decision belongs inline in docs/explanation/security-model.md "Text rendering" (rationale,
rejected alternatives, revisit trigger), with REQ-909 in docs/reference/spec.md restated to match.
The felis-vt unit tests fix the accept-set at exactly nine codepoints
(exactly_nine_codepoints_are_overrides), so a narrowed rule needs its own tests at whichever layer
gains the line context.

## Summary The REQ-909 bidi marker fires on every U+2066–U+2069 occurrence, including the well-formed `FSI … PDI` pair that `fluent-bundle` wraps around every placeable. Fluent's `FluentBundle` has `use_isolating = true` by default, so any Fluent-localized program emits these on ordinary output — `fj` does it on most of its messages. The result is a yellow marker on routine, non-hostile text. This is a scope question about the flag rule, not a detection error. Split out of #257, which covers the separate rendering defect (the marker overwrites the preceding character). ## Why this is worth revisiting The nine codepoints really are the Trojan Source primitives, and `bidi::is_override` flagging all nine matches what rustc's deny-by-default `text_direction_codepoint_in_literal` / `text_direction_codepoint_in_comment` lints do. But rustc lints *source code*, where a bidi control is almost never legitimate. A terminal renders *program output*, where a balanced `FSI … PDI` around an LTR span is routine and produces no visual reordering at all. What makes Trojan Source an attack is reordering the reader actually sees: an unbalanced isolate or embedding, or an explicit override (`LRO` / `RLO`). A closed `FSI … PDI` pair whose content holds no RTL characters and no other bidi control changes nothing on screen. So the current rule marks a large volume of text that carries no attack, and a marker that appears on ordinary output stops being read as a warning. That erodes the reader protection REQ-909 exists to provide. ## Constraint felis performs no UAX#9 paragraph reordering, and that is a recorded non-goal (`docs/explanation/non-goals.md:103`, `docs/explanation/rendering/text-shaping.md:305`). Any narrowing must not smuggle paragraph bidi in through the back door. Today's check is per-cell and stateless (`cell_contains_bidi_override`, `crates/felis-render-wgpu/src/instances.rs:1070`). ## Directions to weigh - **Narrow the rule to a line scan.** Do not flag an `FSI`/`LRI`/`RLI` … `PDI` pair that closes on the same line and whose enclosed span contains no RTL character and no further bidi control. Flag everything else, including every `U+202A`–`U+202E` and every unbalanced isolate. This needs line state, not paragraph reordering, so it stays inside the non-goal. - **Keep the rule, add the opt-out.** `security-model.md` "Text rendering" and REQ-909 both say the marker renders "by default", but no config key turns it off — the wording promises a knob the implementation does not have. Either add the key or drop the words. - **Keep the rule as is** and record why the noise is acceptable, with a "Revisit if …" trigger. ## Whatever lands The decision belongs inline in `docs/explanation/security-model.md` "Text rendering" (rationale, rejected alternatives, revisit trigger), with REQ-909 in `docs/reference/spec.md` restated to match. The `felis-vt` unit tests fix the accept-set at exactly nine codepoints (`exactly_nine_codepoints_are_overrides`), so a narrowed rule needs its own tests at whichever layer gains the line context.
Sign in to join this conversation.
No description provided.