Files
ghostty-rs/tools/difftest/src/inputgen.rs
T
ddidderr d13dcc8a7f test: differential fuzzing of the terminal core against Ghostty's Zig code
Adds tools/difftest: a Zig driver built against a copy of Ghostty
1.3.1's own src/terminal (Zig 0.15.2, set up under target/ by
tools/difftest/setup.sh) and a Rust twin on ghostty-terminal, both
printing a canonical dump of every scrollback/active row (cells,
graphemes, styles, hyperlinks, row flags), cursor, modes, regions,
tabstops and colors. A seeded generator produces escape-heavy streams
(UTF-8/wide/combining/emoji, C0, CSI with edge params, SGR, modes, ESC,
OSC, DCS, margins, insert/delete); `just difftest` runs it with Zig
assertions on (ReleaseSafe vs debug Rust) and off (ReleaseFast vs
production Rust), with minimizers and a triage script.

Divergences found and fixed in Rust (each with a regression test):
- ill-formed UTF-8 decoding follows the SIMD path a default Ghostty
  build uses (not Zig's scalar fallback)
- PageList::erase_rows keeps Zig's row-flag behavior on regrow
- grapheme move on wrap with left/right margins uses the original cell

Upstream bugs found and deliberately NOT replicated (per the project
rule): a stale cell pointer after page growth in Terminal.zig:482/539
that drops a grapheme, plus several Zig assertion failures/segfaults;
documented with minimized repros in docs/rewrite/notes/difftest-DF.md.

Final run: 390k cases across many geometries, 0 divergences (Zig-abort
cases, ~0.1-0.5%, are not compared).

Test Plan:
- just difftest 20000 "1 2 3"
- cargo test -p ghostty-terminal (regression tests)
2026-10-12 13:24:12 +02:00

656 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deterministic, escape-heavy random byte-stream generator.
/// splitmix64 PRNG (deterministic, dependency-free).
pub struct Rng(pub u64);
impl Rng {
pub fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// Uniform in `0..n` (n > 0).
pub fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
pub fn chance(&mut self, num: usize, den: usize) -> bool {
self.below(den) < num
}
pub fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
&xs[self.below(xs.len())]
}
}
const WORDS: &[&str] = &[
"hello",
"world",
"The quick brown fox",
"x",
"ab",
"0123456789",
"~~~",
" ",
"foo bar baz",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
];
/// Interesting non-ASCII text: wide, combining, emoji, ZWJ, VS, regional
/// indicators, Hangul jamo, Indic, zero-width, ambiguous.
const UNICODE: &[&str] = &[
"é",
"e\u{301}",
"\u{301}",
"中",
"文字",
"ア",
"A",
"한",
"ᄀ\u{1161}\u{11a8}",
"😀",
"👍🏽",
"👨\u{200d}👩\u{200d}👧",
"🇺🇸",
"🇺",
"\u{fe0f}",
"\u{fe0e}",
"❤\u{fe0f}",
"❤\u{fe0e}",
"☺",
"\u{200b}",
"\u{200d}",
"\u{200c}",
"\u{feff}",
"\u{ad}",
"ä\u{308}\u{323}",
"क्षि",
"ก็",
"\u{1f3f4}\u{e0067}\u{e0062}\u{e007f}",
"®",
"©",
"™",
"\u{2764}",
"\u{1f600}\u{fe0f}",
"ß",
"─",
"│",
"█",
"\u{1b000}",
"\u{10ffff}",
"\u{e000}",
"\u{ffff}",
"\u{3000}",
"〜",
"\u{2060}",
"\u{0600}",
"\u{2028}",
"\u{85}",
];
const MODES_DEC: &[u32] = &[
1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 25, 40, 41, 45, 47, 66, 67, 69, 80, 95, 1000, 1002, 1003, 1004,
1005, 1006, 1007, 1015, 1016, 1035, 1036, 1039, 1045, 1047, 1048, 1049, 2004, 2026, 2027, 2031,
2048, 7727, 0, 9999, 65535, 12345,
];
const MODES_ANSI: &[u32] = &[2, 4, 12, 20, 0, 1, 3, 25, 99];
const CSI_FINALS: &[u8] = b"@ABCDEFGHIJKLMPSTXZ^`abcdefghijklmnpqrstuvwxyz{|}~";
const EDGE_NUMS: &[&str] = &[
"",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"24",
"25",
"80",
"100",
"255",
"256",
"999",
"1000",
"32767",
"32768",
"65534",
"65535",
"65536",
"65537",
"99999",
"2147483647",
"2147483648",
"4294967295",
"4294967296",
"18446744073709551616",
"-1",
];
fn num(r: &mut Rng, out: &mut Vec<u8>) {
if r.chance(3, 5) {
out.extend_from_slice(r.below(30).to_string().as_bytes());
} else {
out.extend_from_slice(r.pick(EDGE_NUMS).as_bytes());
}
}
fn params(r: &mut Rng, out: &mut Vec<u8>, max: usize) {
let n = r.below(max + 1);
for i in 0..n {
if i > 0 {
out.push(if r.chance(1, 12) { b':' } else { b';' });
}
num(r, out);
}
}
fn sgr(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
let n = 1 + r.below(6);
for i in 0..n {
if i > 0 {
out.push(b';');
}
match r.below(14) {
0 => out.extend_from_slice(b"0"),
1 => out.extend_from_slice(
r.pick(&[
"1", "2", "3", "4", "5", "7", "8", "9", "21", "53", "22", "23", "24", "25",
"27", "28", "29", "55",
])
.as_bytes(),
),
2 => out.extend_from_slice(
r.pick(&[
"30", "31", "32", "37", "39", "40", "41", "47", "49", "90", "97", "100", "107",
"59",
])
.as_bytes(),
),
3 => {
out.extend_from_slice(b"38;2;");
for k in 0..3 {
if k > 0 {
out.push(b';');
}
num(r, out);
}
}
4 => {
out.extend_from_slice(r.pick(&["48", "58", "38"]).as_bytes());
out.extend_from_slice(b";5;");
num(r, out);
}
5 => {
out.extend_from_slice(
r.pick(&["4:0", "4:1", "4:2", "4:3", "4:4", "4:5", "4:9", "4:"])
.as_bytes(),
);
}
6 => {
out.extend_from_slice(b"38:2::");
for k in 0..3 {
if k > 0 {
out.push(b':');
}
num(r, out);
}
}
7 => {
out.extend_from_slice(b"48:2:");
for k in 0..4 {
if k > 0 {
out.push(b':');
}
num(r, out);
}
}
8 => out.extend_from_slice(b"58:5:"),
9 => num(r, out),
10 => out.extend_from_slice(
r.pick(&["38", "48", "58", "38;2", "38;5", "48;2;1", "58;2;1;2"])
.as_bytes(),
),
_ => out.extend_from_slice(r.below(110).to_string().as_bytes()),
}
}
out.push(b'm');
}
fn osc(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b]");
let colors = [
"rgb:ff/00/80",
"#ff0000",
"red",
"rgb:1/2/3",
"?",
"#abc",
"rgbi:0.5/0.5/0.5",
"#12345",
"blue;green",
"",
];
let c = |r: &mut Rng| *r.pick(&colors);
match r.below(18) {
0 => out.extend_from_slice(format!("0;title{}", r.below(100)).as_bytes()),
1 => out.extend_from_slice(format!("2;{}", c(r)).as_bytes()),
2 => out.extend_from_slice(format!("4;{};{}", r.below(260), c(r)).as_bytes()),
3 => out.extend_from_slice(
format!("4;{};{};{};{}", r.below(256), c(r), r.below(256), c(r)).as_bytes(),
),
4 => out.extend_from_slice(
format!(
"{};{}",
r.pick(&[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]),
c(r)
)
.as_bytes(),
),
5 => out.extend_from_slice(
r.pick(&["104", "110", "111", "112", "104;1;2", "104;300"])
.as_bytes(),
),
6 => out.extend_from_slice(format!("7;file://host/tmp/{}", r.below(10)).as_bytes()),
7 => out.extend_from_slice(
r.pick(&[
"8;;http://example.com",
"8;id=1;http://a.b",
"8;;",
"8;id=x:y=z;u",
])
.as_bytes(),
),
8 => out.extend_from_slice(
r.pick(&[
"133;A",
"133;B",
"133;C",
"133;D;0",
"133;A;k=s",
"133;L",
"133;P;k=c",
"133;N",
])
.as_bytes(),
),
9 => out.extend_from_slice(r.pick(&["52;c;aGVsbG8=", "52;c;?", "52;;"]).as_bytes()),
10 => out.extend_from_slice(
r.pick(&[
"9;4;1;50",
"9;hello",
"777;notify;a;b",
"1337;File=x",
"22;pointer",
"66;s=2;x",
"5522;x",
])
.as_bytes(),
),
11 => out.extend_from_slice(format!("{};", r.below(2000)).as_bytes()),
12 => out.extend_from_slice(
r.pick(&[
"21;foreground=?",
"21;foreground=red;background=#fff",
"21;palette=1=?",
])
.as_bytes(),
),
_ => {
let n = r.below(20);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
}
}
match r.below(6) {
0..=2 => out.push(0x07),
3 | 4 => out.extend_from_slice(b"\x1b\\"),
_ => {} // unterminated
}
}
fn dcs(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1bP");
out.extend_from_slice(
r.pick(&[
"$qm",
"$q\"p",
"$qr",
"+q544e",
"1000p",
"1000p%begin 1 2 3\n",
"p",
"0;1|",
"1;2;3q",
"$q r",
"$t",
"=1s",
"garbage",
])
.as_bytes(),
);
let n = r.below(12);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
if !r.chance(1, 8) {
out.extend_from_slice(b"\x1b\\");
}
}
fn apc(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b_");
out.extend_from_slice(
r.pick(&[
"Ga=T,f=24,s=1,v=1;AAAA",
"Ga=d",
"Gi=1,a=q;AAAA",
"Ga=p,i=1",
"Gq=2,a=T,f=100;",
"x",
"Ga=T,t=s,i=3;/x",
])
.as_bytes(),
);
out.extend_from_slice(b"\x1b\\");
}
fn csi(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
match r.below(10) {
0 => out.push(b'?'),
1 => out.push(*r.pick(b">=<!")),
_ => {}
}
params(r, out, 5);
if r.chance(1, 10) {
out.push(*r.pick(b" $\"!#'*+%,"));
}
let f = *r.pick(CSI_FINALS);
out.push(f);
}
fn csi_common(r: &mut Rng, out: &mut Vec<u8>) {
out.extend_from_slice(b"\x1b[");
let finals = b"HABCDEFGdJKLMP@XSTIZbg`aef";
let f = *r.pick(finals);
let n = r.below(3);
for i in 0..n {
if i > 0 {
out.push(b';');
}
if r.chance(3, 4) {
out.extend_from_slice(r.below(12).to_string().as_bytes());
} else {
num(r, out);
}
}
out.push(f);
}
fn margins(r: &mut Rng, out: &mut Vec<u8>) {
let fin = if r.chance(1, 3) { b's' } else { b'r' };
if fin == b's' && r.chance(2, 3) {
out.extend_from_slice(b"\x1b[?69h");
}
out.extend_from_slice(b"\x1b[");
if r.chance(9, 10) {
out.extend_from_slice(r.below(10).to_string().as_bytes());
out.push(b';');
out.extend_from_slice(r.below(14).to_string().as_bytes());
}
out.push(fin);
}
fn mode(r: &mut Rng, out: &mut Vec<u8>) {
let set = r.chance(1, 2);
if r.chance(3, 4) {
out.extend_from_slice(b"\x1b[?");
let n = 1 + r.below(3);
for i in 0..n {
if i > 0 {
out.push(b';');
}
out.extend_from_slice(r.pick(MODES_DEC).to_string().as_bytes());
}
out.push(match r.below(8) {
0 => b's',
1 => b'r',
_ => {
if set {
b'h'
} else {
b'l'
}
}
});
} else {
out.extend_from_slice(b"\x1b[");
out.extend_from_slice(r.pick(MODES_ANSI).to_string().as_bytes());
out.push(if set { b'h' } else { b'l' });
}
}
fn esc(r: &mut Rng, out: &mut Vec<u8>) {
out.push(0x1b);
match r.below(12) {
0 => out.push(*r.pick(b"78DEMHc=>NOno|}~Z\\69VWFlm")),
1 => {
out.push(*r.pick(b"()*+-./"));
out.push(*r.pick(b"B0AUK<>12456789=%7HCEQRYZ`afi"));
}
2 => {
out.push(b'#');
out.push(*r.pick(b"3456789"));
}
3 => {
out.push(b'%');
out.push(*r.pick(b"G@8"));
}
4 => {
out.push(b' ');
out.push(*r.pick(b"FGLMN"));
}
_ => out.push(32 + r.below(95) as u8),
}
}
fn text(r: &mut Rng, out: &mut Vec<u8>) {
match r.below(12) {
0..=3 => out.extend_from_slice(r.pick(WORDS).as_bytes()),
4..=7 => out.extend_from_slice(r.pick(UNICODE).as_bytes()),
8 => {
let n = 1 + r.below(30);
let c = 33 + r.below(90) as u8;
out.extend(std::iter::repeat_n(c, n));
}
9 => {
// random unicode scalar
let cp = match r.below(5) {
0 => r.below(0x80) as u32,
1 => 0x80 + r.below(0x780) as u32,
2 => 0x800 + r.below(0xf000) as u32,
_ => 0x10000 + r.below(0x10000) as u32,
};
if let Some(ch) = char::from_u32(cp) {
let mut b = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut b).as_bytes());
}
}
10 => {
// invalid / truncated utf8 and C1 bytes
let n = 1 + r.below(3);
for _ in 0..n {
out.push(*r.pick(&[
0x80, 0x9b, 0x90, 0x9d, 0x9c, 0xc0, 0xc3, 0xe4, 0xe4, 0xb8, 0xf0, 0x9f, 0xf8,
0xfe, 0xff, 0xa9, 0x85, 0xed, 0xa0,
]));
}
}
_ => {
let n = 1 + r.below(8);
for _ in 0..n {
out.push(32 + r.below(95) as u8);
}
}
}
}
fn token(r: &mut Rng, out: &mut Vec<u8>) {
match r.below(100) {
0..=29 => text(r, out),
30..=39 => out.push(*r.pick(&[
b'\r', b'\n', b'\n', b'\t', b'\t', 0x08, 0x0b, 0x0c, 0x0e, 0x0f, 0x07, 0x00, 0x05,
0x7f, 0x1a, 0x18, 0x1f, 0x01, 0x11, 0x13,
])),
40..=47 => sgr(r, out),
48..=57 => csi_common(r, out),
58..=64 => csi(r, out),
65..=71 => mode(r, out),
72..=75 => margins(r, out),
76..=81 => esc(r, out),
82..=85 => osc(r, out),
86..=87 => dcs(r, out),
88 => apc(r, out),
89..=91 => {
// tabs stuff
out.extend_from_slice(
r.pick(&[
"\x1b[3g",
"\x1b[0g",
"\x1bH",
"\t\t",
"\x1b[2I",
"\x1b[3Z",
"\x1b[?5W",
"\x1b[2 W",
"\x1b[0;5;9 W",
])
.as_bytes(),
);
}
92..=94 => {
// fill screen to provoke scrolling
let n = 1 + r.below(40);
for _ in 0..n {
out.extend_from_slice(b"line text\r\n");
}
}
95 => out.extend_from_slice(
r.pick(&[
"\x1b[?1049h",
"\x1b[?1049l",
"\x1b[?47h",
"\x1b[?47l",
"\x1b[?1047h",
"\x1b[?1048h",
"\x1b[?1048l",
"\x1bc",
"\x1b[!p",
"\x1b[2J",
"\x1b[3J",
"\x1b[H\x1b[J",
"\x1b[?25l",
])
.as_bytes(),
),
96 => out.extend_from_slice(
r.pick(&[
"\x1b[1\"q",
"\x1b[2\"q",
"\x1b[0\"q",
"\x1bV",
"\x1bW",
"\x1b[?1J",
"\x1b[?2K",
"\x1b[1;5r\x1b[?6h",
"\x1b[0 q",
"\x1b[5 q",
"\x1b[>1u",
"\x1b[=5;1u",
"\x1b[<u",
"\x1b[?u",
"\x1b[>4;2m",
])
.as_bytes(),
),
97 if r.chance(1, 2) => {
// hyperlink span with text, sometimes left open
let uri = r.pick(&["http://a.example/x", "file:///tmp/y", "", "https://z"]);
let id = r.pick(&["", "id=1", "id=abc", "foo=bar:id=q"]);
out.extend_from_slice(format!("\x1b]8;{id};{uri}\x1b\\").as_bytes());
text(r, out);
if r.chance(3, 4) {
out.extend_from_slice(b"\x1b]8;;\x1b\\");
}
}
97 => out.extend_from_slice(
b"\x1b[?7h\x1b[?7l\x1b[?45h\x1b[?1045h\x1b[?4h\x1b[4l\x1b[?2027h\x1b[?2027l",
),
98 => {
// grapheme clustering + margins + right-edge cursor
out.extend_from_slice(b"\x1b[?2027h\x1b[?69h");
out.extend_from_slice(
format!("\x1b[{};{}s", 1 + r.below(5), 3 + r.below(12)).as_bytes(),
);
out.extend_from_slice(
format!("\x1b[{};{}H", 1 + r.below(30), 1 + r.below(200)).as_bytes(),
);
text(r, out);
text(r, out);
}
_ => {
// move cursor to a random edge before writing
out.extend_from_slice(format!("\x1b[{};{}H", r.below(60), r.below(120)).as_bytes());
}
}
}
/// Generate a byte stream of roughly `tokens` tokens.
pub fn generate(r: &mut Rng, tokens: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..tokens {
token(r, &mut out);
}
// Occasionally mutate raw bytes to hit odd parser paths.
if r.chance(1, 8) && !out.is_empty() {
for _ in 0..=r.below(4) {
let i = r.below(out.len());
out[i] = r.below(256) as u8;
}
}
out
}
/// Choose terminal geometry and scrollback for a case.
pub fn config(r: &mut Rng) -> (u16, u16, usize) {
let sizes: &[(u16, u16)] = &[
(1, 1),
(2, 1),
(1, 2),
(2, 2),
(3, 3),
(5, 3),
(8, 4),
(10, 5),
(16, 6),
(20, 8),
(40, 10),
(80, 24),
(132, 12),
(7, 30),
(255, 5),
(300, 3),
];
let (c, rr) = *r.pick(sizes);
let sb = *r.pick(&[0usize, 1, 100, 10_000, 10_000, 100_000]);
(c, rr, sb)
}