guard SGR 38/48 lookahead; parse colon sub-parameters (T-369)
printf '\e[38m' was a RangeError inside Terminal.write — the extended-color branches indexed params[i+1..i+4] unguarded. An emulator must never throw on hostile bytes. Both branches now share a bounds-checked helper that ignores truncated sequences. Colons were silently dropped mid-CSI, fusing 38:2:255:0:0 into one bogus parameter; the consumer now records ECMA-48 sub-parameter links, so ITU T.416 colon-form truecolor/256-color (with or without the colorspace slot) parses identically to the semicolon form, and a malformed colon group is dropped whole instead of bleeding into neighbouring SGR codes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3943,3 +3943,5 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBV0465906BY3QFAY9F1YM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:24:07', '2026-06-11 22:24:07', '2026-06-11 22:24:07', NULL, '6e0986eb4b0c15223d7b373bbbc421cb', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:24:34', '2026-06-11 22:24:34', '2026-06-11 22:24:34', NULL, '151c44c4c697f83900b1cc003c6a94f6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:26:15', '2026-06-11 22:26:15', '2026-06-11 22:26:15', NULL, '05386705854fd48ee75d7a3dfbfd5bc9', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'backlog', 'in_progress', NULL, '2026-06-11 22:26:38', '2026-06-11 22:26:38', '2026-06-11 22:26:38', NULL, '6d14a673d6733b5024726bfc227f6711', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'status', 'in_progress', 'done', NULL, '2026-06-11 22:30:24', '2026-06-11 22:30:24', '2026-06-11 22:30:24', NULL, 'aa1fe975e147be83905f24c63662254d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -3991,3 +3991,13 @@ Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing t
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHBYTJ4E7ZBY6DWWNT1S16M', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Conversation auto-scroll yanks a scrolled-up reader to the bottom on every streamed token', 'lib/builtin/claude/src/conversation_view.dart:268-277 — the _atBottom pin exists but is only consulted on viewport resize, not on new items. Anyone reading earlier output during a long streaming reply is dragged to the bottom continuously.
|
||||
|
||||
Fix: gate the new-item auto-scroll on _atBottom (one-line) and add the missing twin test: scrolled-up viewport stays put when items stream in; at-bottom viewport follows.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:33', '2026-06-11 22:26:15', NULL, '1340fb7aac3cdf06476ce74248fda6c0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
|
||||
|
||||
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
|
||||
|
||||
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:26:38', NULL, '8cc428e286b918b46744a649ab1979f0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBHC0HYRZ86CWW0DDQJ5CAQM', 'bug', '06FBHBGHNEQTAEPGNJKN42C1E8', 'Terminal throws RangeError on truncated SGR 38/48; colon sub-parameters mangled', 'lib/src/terminal/src/core/escape/parser.dart:501-516 — SGR 38/48 extended-color parsing does unguarded params[i + 1] lookahead, so a truncated sequence like printf ''\e[38m'' throws RangeError inside Terminal.write. An emulator must never throw on hostile bytes. Secondary, same code path: colon-form SGR sub-parameters (e.g. 38:2:r:g:b, emitted by modern terminfo) are not split out and get mangled into bogus params.
|
||||
|
||||
Fix: bounds-check the lookahead (ignore incomplete 38/48 sequences), and parse colon-form sub-parameters per ECMA-48/ITU T.416 — treat 38:2::r:g:b and 38;2;r;g;b equivalently. Note T-123 (parser split) touches the same file; coordinate but do not block on it.
|
||||
|
||||
Acceptance: feeding any truncated/garbled SGR byte sequence never throws (fuzz-style test over partial sequences); colon-form truecolor sets the same fg/bg as semicolon form; existing SGR tests stay green.', 'done', 'high', NULL, NULL, NULL, '2026-06-11 21:56:47', '2026-06-11 22:30:24', NULL, 'b64b55f7d40149848fc687efe1cf8f32', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -46,6 +46,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
scroll position, dragging a reader back to the bottom; it now follows only
|
||||
while already pinned there. (T-368)
|
||||
|
||||
- **The terminal no longer crashes on truncated SGR color sequences.**
|
||||
`ESC[38m` and friends threw a RangeError inside the emulator; incomplete
|
||||
38/48 sequences are now ignored, and colon-form truecolor/256-color
|
||||
sub-parameters (`38:2:r:g:b`, ITU T.416) parse like the semicolon form
|
||||
instead of being mangled. (T-369)
|
||||
|
||||
- **Accepting ExitPlanMode now leaves plan mode in the conversation panel.**
|
||||
Approving Claude's plan (the ExitPlanMode tool) transitioned the underlying
|
||||
session out of plan mode, but clide's tracked permission mode didn't follow,
|
||||
|
||||
@@ -220,6 +220,7 @@ class EscapeParser {
|
||||
}
|
||||
|
||||
_csi.params.clear();
|
||||
_csi.subParam.clear();
|
||||
|
||||
// test whether the csi is a `CSI ? Ps ...` or `CSI Ps ...`
|
||||
final prefix = _queue.peek();
|
||||
@@ -232,6 +233,11 @@ class EscapeParser {
|
||||
|
||||
var param = 0;
|
||||
var hasParam = false;
|
||||
// Whether the value being accumulated was attached to its predecessor
|
||||
// with a colon (ECMA-48 sub-parameter separator, ITU T.416 SGR colors).
|
||||
// Before T-369 colons were silently dropped mid-sequence, fusing
|
||||
// `38:2:255:0:0` into one bogus parameter.
|
||||
var linkedToPrev = false;
|
||||
while (true) {
|
||||
// The sequence isn't completed, just ignore it.
|
||||
if (_queue.isEmpty) {
|
||||
@@ -243,8 +249,21 @@ class EscapeParser {
|
||||
if (char == Ascii.semicolon) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
param = 0;
|
||||
linkedToPrev = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char == Ascii.colon) {
|
||||
// Push the current value even when empty — `38:2::r:g:b` carries an
|
||||
// empty colorspace slot that must keep its position in the group.
|
||||
_csi.params.add(hasParam ? param : 0);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
hasParam = true;
|
||||
param = 0;
|
||||
linkedToPrev = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -263,6 +282,7 @@ class EscapeParser {
|
||||
if (char >= Ascii.atSign && char <= Ascii.tilde) {
|
||||
if (hasParam) {
|
||||
_csi.params.add(param);
|
||||
_csi.subParam.add(linkedToPrev);
|
||||
}
|
||||
|
||||
_csi.finalByte = char;
|
||||
@@ -499,21 +519,7 @@ class EscapeParser {
|
||||
handler.setForegroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 38:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setForegroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setForegroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i = _csiHandleExtendedColor(i, foreground: true);
|
||||
continue;
|
||||
case 39:
|
||||
handler.resetForeground();
|
||||
@@ -544,21 +550,7 @@ class EscapeParser {
|
||||
handler.setBackgroundColor16(NamedColor.white);
|
||||
continue;
|
||||
case 48:
|
||||
final mode = params[i + 1];
|
||||
switch (mode) {
|
||||
case 2:
|
||||
final r = params[i + 2];
|
||||
final g = params[i + 3];
|
||||
final b = params[i + 4];
|
||||
handler.setBackgroundColorRgb(r, g, b);
|
||||
i += 4;
|
||||
break;
|
||||
case 5:
|
||||
final index = params[i + 2];
|
||||
handler.setBackgroundColor256(index);
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
i = _csiHandleExtendedColor(i, foreground: false);
|
||||
continue;
|
||||
case 49:
|
||||
handler.resetBackground();
|
||||
@@ -621,6 +613,61 @@ class EscapeParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extended fg/bg color (SGR 38/48), semicolon or colon form.
|
||||
///
|
||||
/// Returns the index of the last parameter consumed. Never reads past the
|
||||
/// end of the parameter list — a truncated sequence (`ESC [38m`,
|
||||
/// `ESC [38;2;255m`) is ignored instead of throwing; an emulator must never
|
||||
/// throw on hostile bytes (T-369). Colon-form sub-parameters per ITU T.416
|
||||
/// (`38:2:r:g:b`, `38:2:<colorspace>:r:g:b`, `38:5:n`) are treated as one
|
||||
/// logical group: parsed equivalently to the semicolon form, and dropped
|
||||
/// whole when malformed so they never spill into neighbouring parameters.
|
||||
int _csiHandleExtendedColor(int i, {required bool foreground}) {
|
||||
final params = _csi.params;
|
||||
final sub = _csi.subParam;
|
||||
|
||||
// End of the colon-linked group starting at params[i] (exclusive).
|
||||
var end = i + 1;
|
||||
while (end < params.length && sub[end]) {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end > i + 1) {
|
||||
// Colon form. Group is params[i..end-1]; n includes the 38/48 itself.
|
||||
final n = end - i;
|
||||
final mode = params[i + 1];
|
||||
if (mode == 5 && n >= 3) {
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
} else if (mode == 2) {
|
||||
// A 6+ element group carries the T.416 colorspace id slot — skip it.
|
||||
final base = n >= 6 ? i + 3 : i + 2;
|
||||
if (base + 2 < end) {
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[base], params[base + 1], params[base + 2])
|
||||
: handler.setBackgroundColorRgb(params[base], params[base + 1], params[base + 2]);
|
||||
}
|
||||
}
|
||||
return end - 1;
|
||||
}
|
||||
|
||||
// Semicolon form (legacy).
|
||||
if (i + 1 >= params.length) return i; // bare 38/48 — ignore
|
||||
switch (params[i + 1]) {
|
||||
case 2:
|
||||
if (i + 4 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground
|
||||
? handler.setForegroundColorRgb(params[i + 2], params[i + 3], params[i + 4])
|
||||
: handler.setBackgroundColorRgb(params[i + 2], params[i + 3], params[i + 4]);
|
||||
return i + 4;
|
||||
case 5:
|
||||
if (i + 2 >= params.length) return params.length - 1; // truncated — ignore
|
||||
foreground ? handler.setForegroundColor256(params[i + 2]) : handler.setBackgroundColor256(params[i + 2]);
|
||||
return i + 2;
|
||||
}
|
||||
// Unknown mode — consume it so it isn't re-interpreted as an SGR code.
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
/// `ESC [ Ps n` Device Status Report [Dispatch] (DSR)
|
||||
///
|
||||
/// https://terminalguide.namepad.de/seq/csi_sn/
|
||||
@@ -1121,6 +1168,10 @@ class _Csi {
|
||||
|
||||
List<int> params;
|
||||
|
||||
/// Parallel to [params]: true when that parameter was attached to its
|
||||
/// predecessor with a colon (ECMA-48 sub-parameter, ITU T.416 — T-369).
|
||||
final List<bool> subParam = [];
|
||||
|
||||
int finalByte;
|
||||
// final List<int> intermediates;
|
||||
|
||||
|
||||
@@ -676,6 +676,56 @@ void main() {
|
||||
f.parser.write('\x1b[123m');
|
||||
expect(f.h.named('unsupportedStyle').first.args, [123]);
|
||||
});
|
||||
|
||||
// T-369: an emulator must never throw on hostile bytes. The old code did
|
||||
// unguarded params[i+1] lookahead in 38/48 — `\x1b[38m` was a RangeError
|
||||
// inside Terminal.write.
|
||||
test('truncated 38/48 sequences are ignored, never throw', () {
|
||||
final f = _newParser();
|
||||
for (final s in ['\x1b[38m', '\x1b[48m', '\x1b[38;2m', '\x1b[38;2;255m', '\x1b[38;2;255;10m', '\x1b[38;5m', '\x1b[48;5m', '\x1b[38:2m', '\x1b[38:5m', '\x1b[48:2:255m']) {
|
||||
f.parser.write(s);
|
||||
}
|
||||
expect(f.h.named('setForegroundColorRgb'), isEmpty);
|
||||
expect(f.h.named('setBackgroundColorRgb'), isEmpty);
|
||||
expect(f.h.named('setForegroundColor256'), isEmpty);
|
||||
expect(f.h.named('setBackgroundColor256'), isEmpty);
|
||||
});
|
||||
|
||||
test('colon-form truecolor matches semicolon form (ITU T.416)', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[38:2:10:20:30m\x1b[48:2:100:150:200m');
|
||||
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
|
||||
expect(f.h.named('setBackgroundColorRgb').first.args, [100, 150, 200]);
|
||||
});
|
||||
|
||||
test('colon-form with empty colorspace slot — 38:2::r:g:b', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[38:2::10:20:30m');
|
||||
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
|
||||
});
|
||||
|
||||
test('colon-form 256-colour — 38:5:n', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[38:5:200m\x1b[48:5:42m');
|
||||
expect(f.h.named('setForegroundColor256').first.args, [200]);
|
||||
expect(f.h.named('setBackgroundColor256').first.args, [42]);
|
||||
});
|
||||
|
||||
test('a malformed colon group is dropped whole, neighbours still apply', () {
|
||||
final f = _newParser();
|
||||
// The bogus 38:2:255 group must not bleed into the following bold.
|
||||
f.parser.write('\x1b[38:2:255;1m');
|
||||
expect(f.h.named('setForegroundColorRgb'), isEmpty);
|
||||
expect(f.h.named('setCursorBold').length, 1);
|
||||
});
|
||||
|
||||
test('extended color followed by more SGR params keeps positions', () {
|
||||
final f = _newParser();
|
||||
f.parser.write('\x1b[1;38;2;10;20;30;4m');
|
||||
expect(f.h.named('setCursorBold').length, 1);
|
||||
expect(f.h.named('setForegroundColorRgb').first.args, [10, 20, 30]);
|
||||
expect(f.h.named('setCursorUnderline').length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('EscapeParser — OSC sequences', () {
|
||||
|
||||
Reference in New Issue
Block a user