1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! #621 — the wire carries what the engine legitimately holds.
//!
//! Every fixture here starts from a real [`Engine`] and goes out through `feed`, because
//! that is the only starting point that can observe this defect. `tests/robustness.rs`'s
//! round-trip property is driven from a *decoded* frame, so it proves
//! `decode ∘ encode ∘ decode == decode` — a fixed point of the decoder's own output, which
//! by construction cannot see an encode-side asymmetry. Measured on the sibling defect
//! (#531): the buggy build satisfied that property while failing the assertion below.
//!
//! The four cases are not one defect in four costumes; they fail differently, and the
//! difference is the whole reason this issue is not #582's:
//!
//! - a cluster or URI longer than the old `u16` length prefix → `Err(BadTag)`, loud;
//! - a viewport holding more combining cells than the old `u16` table count → the count
//! wraps, `encode` writes more records than it declared, and `decode` returns **`Ok`**
//! on a frame whose combining references point past the end of the table;
//! - more distinct URIs in one frame than the old `u16` frame-local index → a **panic**
//! inside `Term::frame` itself, before any byte reaches the wire.
//!
//! None of that is malformed input. The engine parsed it correctly and stores it
//! correctly; the fields were simply too small for values it legitimately holds. That is
//! why the fix is capacity (widen what is rare, delete what is per-cell) and not
//! validation. **#721 adds a third capacity answer to that pair — bound the producer**:
//! the two marker group counts stay `u16` and `MAX_MARKERS` caps the population they
//! count, because those marks are allocated by an untrusted stream and the wire field is
//! the only limit anyone can name. Widening was available and is the wrong answer there
//! (it would entrench ADR-0020's R3 violation, #490's to remove). **The validation half was #582's, and it is now answered** in
//! `tests/span_bounds.rs`: a group key outside its span is rejected on decode and dropped
//! on encode. The split still holds — a capacity defect makes the engine unable to
//! *describe* what it holds, a validation defect makes the decoder accept what no frame
//! could contain — and #582's own residue was on this file's side of it, not that one:
//! `ScrollOp.count` narrowing to `i16` from an uncapped accumulation, **closed by #661**
//! (`Term::scroll_delta` caps at the region height). Corrected in passing while working
//! #721; this sentence had outlived its own fix.
use justerm_core::{Engine, decode, encode};
/// Just past `u16::MAX`, so a fixture crosses the old ceiling without paying for the
/// distance beyond it. Every count below is derived from this rather than restated, so
/// the fixtures cannot drift apart from the threshold they exist to cross.
const PAST_U16: usize = u16::MAX as usize + 1;
#[test]
fn a_combining_cluster_longer_than_the_old_prefix_survives_the_wire() {
// One cell, one very long grapheme cluster. `Row::push_combining` caps nothing — and
// no reference caps it either (xterm.js appends to a JS string, alacritty pushes onto
// an unbounded `Vec<char>`, ghostty's `GraphemeAllocOutOfMemory` is an allocator
// failure that its own caller resolves by *growing*), so the engine is right to hold
// this and the wire was wrong to be unable to describe it.
let mut e = Engine::new(10, 2);
let mut stream = String::from("a");
for _ in 0..PAST_U16 {
stream.push('\u{0301}');
}
e.feed(stream.as_bytes());
let frame = e.frame();
assert_eq!(
decode(&encode(&frame)).expect("a long cluster must round-trip, not fail to decode"),
frame,
);
}
#[test]
fn a_hyperlink_uri_longer_than_the_old_prefix_survives_the_wire() {
// The OSC 8 half of the same shape. A URI this long is unusual, not invalid: nothing
// in the OSC 8 path bounds it, so the engine stores what the stream gave it.
let mut e = Engine::new(10, 2);
let uri = "h".repeat(PAST_U16);
e.feed(format!("\x1b]8;;{uri}\x07A").as_bytes());
let frame = e.frame();
assert_eq!(
decode(&encode(&frame)).expect("a long URI must round-trip, not fail to decode"),
frame,
);
assert_eq!(
frame.link_table[0].len(),
PAST_U16,
"the engine stores the whole URI — if this shrinks, the fix capped the input \
instead of widening the field, which is the one option prior art ruled out",
);
}
#[test]
fn a_viewport_with_more_combining_cells_than_the_old_count_survives_the_wire() {
// The dangerous one, and the reason this is not merely a tight field. The old table
// count was `u16` while the header's `cols` and `rows` are `u16` *each*, so a legal
// viewport can hold far more cells than the count could describe. Measured before the
// fix, at 400x200 with one mark per cell: the count wrapped 80000 -> 14464, `encode`
// wrote all 80000 records anyway, and `decode` returned **`Ok`** with combining
// references reaching index 79999 into a 14464-entry table.
//
// So the assertion that matters is not `is_ok()` — that was already true while the
// frame was wrong. It is equality with the frame the engine actually holds.
//
// **Read this as a shape regression pin, not as evidence about the v14 code.** Measured
// with a single-point mutation matrix over the whole fix: this test reddens under
// exactly one reversion (dropping `decode`'s `set_combined` re-arm), and the much
// cheaper cluster-length test above catches that same one. It cannot discriminate more,
// because after v14 there is no viewport-scaled count left in the combining path to
// overflow — the count is per span. What it still buys is a guard against *reintroducing*
// a frame-wide table, which no cheaper test would notice. It is also the most expensive
// test in this file (~66 000 cells), so if it ever needs to be dropped for time, drop it
// knowing that and not on the belief that it is load-bearing for the current code.
let cols = 300;
let rows = 220;
assert!(
cols * rows > PAST_U16,
"the fixture must cross the old count"
);
let mut e = Engine::new(cols, rows);
let mut stream = String::new();
for _ in 0..rows {
for _ in 0..cols {
stream.push('e');
stream.push('\u{0301}');
}
}
e.feed(stream.as_bytes());
let frame = e.frame();
let combining_cells: usize = frame.spans.iter().map(|s| s.combining.len()).sum();
assert!(
combining_cells > PAST_U16,
"fixture must actually carry more than u16::MAX combining cells, got {combining_cells}",
);
// `assert!` rather than `assert_eq!` on purpose: these fixtures are ~66 000 cells, and
// a failing `assert_eq!` Debug-prints both frames — 19.6 MB of output, measured. The
// claim is identical; only the failure report is survivable.
assert!(
decode(&encode(&frame)).expect("must decode") == frame,
"more combining cells than the old count fit — the frame must survive intact, \
not decode Ok into fabricated references",
);
}
#[test]
fn more_highlight_spans_than_the_old_count_survives_the_wire() {
// The fifth case, and the one the first `as u16` sweep missed: it classified the
// overlay groups' `(row, left, right)` *triples* as bounded — which they are — and
// never looked at the count above them.
//
// Measured before the fix, with a one-character search over a large viewport:
//
// 1000x132 65 500 spans -> 65 500 Ok, equal
// 1000x133 66 000 spans -> 464 Ok, *** and 928 marker-lines + 3 active
// spans FABRICATED from nothing ***
// 1000x134 66 500 spans -> Err(BadTag)
//
// The fabrication is the tell, and it is worse than the truncation: a wrapped count
// leaves the reader mid-group, so every group *after* it decodes from the wrong
// offset. The engine had zero markers.
let (cols, rows) = (1000, 133);
let mut e = Engine::new(cols, rows);
let line = "ax".repeat(cols / 2);
for _ in 0..rows {
e.feed(line.as_bytes());
e.feed(b"\r\n");
}
e.set_search_highlights(e.search("a"));
let frame = e.frame();
assert!(
frame.overlay.matches.len() > PAST_U16,
"fixture must cross the old count, got {}",
frame.overlay.matches.len(),
);
let decoded = decode(&encode(&frame)).expect("must decode");
// Named separately from the whole-frame equality below: these two were invented by
// the desync rather than merely lost, and a bare `!=` would not say so.
assert!(
decoded.overlay.markers.is_empty() && decoded.overlay.active_match.is_empty(),
"the engine had no markers and no active match — decoding any is the reader \
walking out of the highlight group and into the next one",
);
assert!(decoded == frame, "the highlight group must survive intact");
}
#[test]
fn more_distinct_links_than_the_old_index_does_not_panic_the_engine() {
// This one never reached the wire at all: `Term::frame` renumbers each referenced
// pool entry to a frame-local index with `link_remap[l] = link_table.len() as u16`,
// then wraps it in `NonZeroU32::new(…).expect("link_remap just set, nonzero")`. At the
// 65536th distinct URI the cast yields 0 and the `expect` fires — a panic in the
// engine, on input it parsed correctly.
//
// Absorbed into this issue rather than filed separately: that `u16` exists *because*
// the wire's link field was `u16`, so removing the narrowing removes the panic. One
// root, one fix.
let cols = 300;
let rows = 220;
let mut e = Engine::new(cols, rows);
let mut stream = String::new();
for i in 0..PAST_U16 + 1 {
// A distinct URI per cell, each one referenced exactly once.
stream.push_str(&format!("\x1b]8;;h://{i}\x07x"));
}
e.feed(stream.as_bytes());
let frame = e.frame();
assert!(
frame.link_table.len() > PAST_U16,
"fixture must carry more than u16::MAX distinct URIs, got {}",
frame.link_table.len(),
);
assert!(
decode(&encode(&frame)).expect("must decode") == frame,
"a frame with more distinct links than the old index could number must survive",
);
}