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
//! Property-based robustness for the untrusted-input entry points.
//!
//! `decode` parses a wire buffer a consumer hands back over its own transport, and `Engine::feed`
//! consumes a VT stream originating from a PTY/SSH peer — both are attacker-influenced bytes. The
//! decoders must answer malformed input with a typed `DecodeError` (or absorb it), never panic,
//! overflow, or read out of bounds. These properties assert that contract across the whole input
//! space, not just the hand-written vectors in the other `tests/` files. The two-lane robustness
//! decision (these properties + the CI-only fuzz lane) is ADR-0007.
use justerm_core::{Engine, WIRE_VERSION, decode, encode};
use proptest::prelude::*;
/// A wire buffer that is half fully-arbitrary (exercising the magic/version rejection paths) and
/// half prefixed with the valid `JT` magic + the *current* `WIRE_VERSION`, so the generator actually
/// reaches the length-driven span/side-table/link-table/marker body parser instead of bailing at the
/// header. The version MUST track `WIRE_VERSION` (not a literal) — a stale pin silently reroutes this
/// lane to the header-rejection path, leaving every post-bump wire feature unreached (#159 caught the
/// pin rotted at `2` while the format was at v10).
fn wire_buf() -> impl Strategy<Value = Vec<u8>> {
prop_oneof![
proptest::collection::vec(any::<u8>(), 0..=1024),
proptest::collection::vec(any::<u8>(), 0..=1024).prop_map(|body| {
let mut buf = vec![b'J', b'T', WIRE_VERSION];
buf.extend(body);
buf
}),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2048))]
/// No-panic: an arbitrary wire buffer fed to `decode` must return `Ok`/`Err`, never panic.
/// The whole buffer is attacker-controlled (magic, version, then length-prefixed spans, the
/// grapheme side-table, and the hyperlink table), so it is fully arbitrary. Reaching the end
/// without unwinding IS the assertion; proptest shrinks any failure to a minimal counterexample.
#[test]
fn decode_never_panics_on_arbitrary_input(buf in wire_buf()) {
let _ = decode(&buf);
}
/// Round-trip stability: whatever `decode` accepts must survive re-encoding unchanged — the
/// encode/decode contract ADR-0005 promises. Driven from arbitrary bytes (no `Frame` generator
/// needed): if a buffer decodes, its `Frame` must re-encode to bytes that decode back to the
/// same `Frame`. A failure here is a real encode/decode asymmetry, not a test artifact.
#[test]
fn decoded_frames_round_trip_through_encode(buf in wire_buf()) {
if let Ok(frame) = decode(&buf) {
prop_assert_eq!(decode(&encode(&frame)), Ok(frame));
}
}
/// No-panic: an arbitrary VT byte stream fed to the engine must never panic. `cols`/`rows` are
/// bounded because they come from the caller's viewport size, not the stream; the fed bytes are
/// fully arbitrary. This exercises justerm's own state machine (grid/scrollback/cursor) atop the
/// `vte` tokenizer against adversarial escape sequences.
#[test]
/// **These bounds are why no capacity defect in #621 could be found here, and that is
/// deliberate rather than a gap to widen.** 200×100 is 20 000 cells and 2048 bytes of
/// stream, so this generator cannot reach a `u16::MAX` table count, a 65 536-entry
/// highlight group, or a 65 536-character cluster — every one of #621's four cases
/// needed a directed fixture (`tests/wire_capacity.rs`) built at the threshold.
/// Raising the bounds would not fix that: the volume needed is thousands of times
/// larger, the run time scales with it, and a random stream reaches a specific
/// overflow with vanishing probability. Property tests answer "does anything panic on
/// junk"; a capacity ceiling is answered by walking to it on purpose. Each fix brings
/// its own directed test — do not read a green here as coverage of one.
fn feed_never_panics_on_arbitrary_input(
cols in 1usize..=200,
rows in 1usize..=100,
stream in proptest::collection::vec(any::<u8>(), 0..=2048),
) {
let mut engine = Engine::new(cols, rows);
engine.feed(&stream);
}
}
// ---- the verbs the lanes above never reach (#536) ---------------------------------------
//
// Both random lanes — this file's `feed_never_panics_on_arbitrary_input` and the CI fuzz target
// `fuzz/fuzz_targets/feed.rs` — stop at `feed`. Neither has ever called `resize` or `frame`, and
// that is exactly where this class of defect lives: **#536's panic was raised by `frame()`**, and
// `resize` is the operation that manufactures the odd cell states (a pair truncated through its
// middle, ADR-0025 "D4's scope"). The gap was measured, not assumed — a probe assertion on
// `damage_span`'s bound stayed silent across the whole 66-binary suite, so the fixed vectors do
// not reach it either.
//
// As in the lane above, **returning without unwinding IS the assertion**: `damage_span` carries a
// `debug_assert!` on its column bound, so any caller that computes an out-of-range span fails
// here, at the recording site, instead of downstream in `frame()` — which is the diagnosability
// #536 was filed for.
//
// **Both lanes `reset_damage()` after every `frame()`, and that is not hygiene — without it they
// stop exercising the path they exist for.** `Term::resize` ends in `mark_fully_damaged()`
// (`term.rs`), and only `reset_damage` clears the flag, so an unacked engine returns
// `FrameKind::Full` for every frame after its first resize — measured: `Partial`, then resize,
// then `Full`, `Full`, `Full`, and `Partial` again only after an ack. `Full` ships whole rows and
// never slices by a recorded span, which is exactly the code #536's panic came from.
//
// Justified by that measured coverage, **not by a mutation** — and the distinction is worth
// keeping, because two plausible mutations turn out not to discriminate it. A bad span recorded
// *inside* `resize` can never be sliced with or without the ack, since `reset_damage` resets every
// `LineBounds` before the next frame; and a bad span recorded by a *write* is already caught in the
// window before the first resize, which both lanes have. So the ack buys reach, not a red test.
proptest! {
#![proptest_config(ProptestConfig::with_cases(1024))]
/// Arbitrary bytes, with `resize` and `frame` interleaved at attacker-chosen cut points.
///
/// `cut` is a **percentage** of the stream rather than an absolute offset: drawn absolutely
/// from the same range as the stream length, roughly half of all cut points land at or past
/// the end, which degenerates the lane into the feed-only one above.
///
/// **Since #582 each frame is also driven through its own wire, and that is a second
/// assertion, not decoration.** `decode` now rejects a span, a group key or a scroll region
/// that does not fit the frame's declared `cols`/`rows`, which is only safe because
/// `Term::frame` cannot emit one. Nothing tested that: this lane called `frame()` and dropped
/// it, and `decoded_frames_round_trip_through_encode` above starts from *decoded* bytes, so it
/// is blind by construction to what the engine produces. The claim is exactly the shape a
/// random lane can falsify and a fixed vector cannot — resize is what manufactures the odd
/// geometry, and this lane already interleaves it.
///
/// **The assertion is `is_ok()`, not equality, and the difference was measured here.** The
/// first version asserted `decode(encode(f)) == f` and this lane refuted it within one run:
/// `C_LEADING_SPACER` is engine-internal by design — `cell.rs` says it *"stays in the content
/// word and never reaches `flags()` / the wire"* — so a frame holding one is decodable but not
/// a fixed point, and asserting equality would have pinned a contract the engine deliberately
/// does not hold (the ADR-0005 wording that omits the caveat was already raised in #531/PR #618
/// and settled as no-issue). What this change promises is that no engine frame is *rejected*;
/// that is what is asserted. The fixed-point property, on a frame with no leading spacer, is
/// pinned in `span_bounds.rs`.
#[test]
fn feed_resize_and_frame_never_panic(
cols in 1usize..=40,
rows in 1usize..=12,
stream in proptest::collection::vec(any::<u8>(), 0..=2048),
resizes in proptest::collection::vec((1usize..=40, 1usize..=12, 0usize..=100), 1..=6),
) {
let mut engine = Engine::new(cols, rows);
let mut at = 0usize;
for (c, r, pct) in resizes {
let end = (stream.len() * pct / 100).max(at);
engine.feed(&stream[at..end]);
at = end;
let frame = engine.frame();
prop_assert!(decode(&encode(&frame)).is_ok(), "engine frame rejected by its own decoder");
engine.reset_damage();
engine.resize(c, r);
let frame = engine.frame();
prop_assert!(decode(&encode(&frame)).is_ok(), "engine frame rejected after resize");
engine.reset_damage();
}
engine.feed(&stream[at..]);
let frame = engine.frame();
prop_assert!(decode(&encode(&frame)).is_ok(), "engine frame rejected at the tail");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(1024))]
/// The same three verbs over *this cluster's* material rather than arbitrary bytes. Random
/// bytes almost never produce a wide glyph at a column boundary under mode 2027 on the alt
/// screen inside a scroll region; these chunks do nothing else. Narrow widths are deliberate —
/// `MIN_COLUMNS = 2` is the width at which a pair only just fits (#547).
///
/// The resize varies **both** axes: a width-only lane never exercises the row-shrink half, and
/// the arbitrary-byte lane above almost never holds wide material when it shrinks rows.
#[test]
fn wide_glyphs_survive_interleaved_resizes(
cols in 2usize..=8,
rows in 1usize..=4,
chunks in proptest::collection::vec(
prop_oneof![
Just(&b"\xed\x95\x9c"[..]), // 한 — width 2
Just(&b"\xe2\x96\xb6\xef\xb8\x8f"[..]), // ▶ + VS16 — promotes to width 2
Just(&b"\xf0\x9f\x87\xb0\xf0\x9f\x87\xb7"[..]), // regional-indicator pair
Just(&b"a"[..]),
Just(&b"\r\n"[..]),
Just(&b"\x1b[?1049h"[..]), // alt screen: resizes, never reflows
Just(&b"\x1b[?1049l"[..]),
Just(&b"\x1b[?2027h"[..]), // grapheme clustering
Just(&b"\x1b[?7l"[..]), // autowrap off
Just(&b"\x1b[2;3r"[..]), // DECSTBM
Just(&b"\x1b[1;1H"[..]),
Just(&b"\x1b[K"[..]),
Just(&b"\x1b[1@"[..]), // ICH
Just(&b"\x1b[1P"[..]), // DCH
Just(&b"\x1b[L"[..]), // IL
Just(&b"\x1b[M"[..]), // DL
],
1..=40,
),
dims in proptest::collection::vec((2usize..=8, 1usize..=4), 2..=5),
) {
let mut engine = Engine::new(cols, rows);
let mut di = 0usize;
for (i, chunk) in chunks.iter().enumerate() {
engine.feed(chunk);
if i % 5 == 4 {
let _ = engine.frame();
engine.reset_damage();
let (c, r) = dims[di % dims.len()];
engine.resize(c, r);
di += 1;
let _ = engine.frame();
engine.reset_damage();
}
}
let _ = engine.frame();
}
}