Skip to main content

max / shop

Add soak oracles and fuzz targets for shop-grid and kitty-graphics Two libFuzzer targets, each driven by a plain public `oracle` module rather than a `fuzzing`-gated one, so the committed regression replay on stable and the nightly target assert the same properties instead of drifting apart. shop-grid's target drives shop-vt with the grid as its Perform, because that is where the bytes reach an unchecked store: the oracle asserts the exact preconditions place_char's SAFETY comment names, plus row totality, whole wide pairs, damage range and resize. Three bugs the oracle found on 2026-08-29, each with a regression: - delete_lines underflowed `scroll_bottom - n` when the delete covered the whole region from its top row, handing copy_row rows off the end of the ring in release. - DECSTBM accepted an inverted or single-row region, so region_size underflowed in the scroll paths to about 65,000 rows. - A narrow write over a right-edge pad reached back two columns and blanked the spacer of a real pair, leaving the half pair heal_pair exists to prevent. Both oracles' MAX_RETAINED_PER_INPUT_BYTE ceilings sit deliberately above today's behaviour, since a ceiling below it reports the same known allocation finding on every input and never reaches anything new. Those findings are filed; the ceilings drop when the caps land. Corpora and artifacts are gitignored; the seeds are not.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-30 12:38 UTC
Signed with PGP, not checked
Commit: 4f78be59a46b8744b4a4312cccf3ce402b8a4de9
Parent: 1444382
173 files changed, +22701 insertions, -6 deletions
@@ -25,6 +25,8 @@
25 25
26 26 #![deny(unsafe_code)]
27 27
28 + pub mod oracle;
29 +
28 30 use std::collections::HashMap;
29 31
30 32 use base64::{Engine, engine::general_purpose::STANDARD as B64};
@@ -166,6 +168,35 @@
166 168 Self::default()
167 169 }
168 170
171 + /// Transmissions this parser is holding chunks for.
172 + ///
173 + /// Nothing evicts a partial: a client that starts a chunked transmission
174 + /// and never finishes it leaves one here for the life of the terminal, and
175 + /// a client that starts one per `i=` leaves as many as it sends. Exposing
176 + /// the count is what lets the soak oracle assert a ceiling instead of
177 + /// waiting for libFuzzer's RSS limit to notice.
178 + #[must_use]
179 + pub fn pending_transmissions(&self) -> usize {
180 + self.partial.len()
181 + }
182 +
183 + /// Bytes held across all in-flight transmissions: the decoded payloads and
184 + /// the map that keys them.
185 + ///
186 + /// The map's own spine is counted because that is where an attack on the
187 + /// id space lands. A client sending one unfinished chunk per `i=` retains
188 + /// almost no payload and an entry per id, so a payload-only measure would
189 + /// report a few bytes while the process grew by a gigabyte.
190 + #[must_use]
191 + pub fn pending_bytes(&self) -> usize {
192 + self.partial.capacity() * std::mem::size_of::<(PartialKey, Partial)>()
193 + + self
194 + .partial
195 + .values()
196 + .map(|p| p.payload.capacity())
197 + .sum::<usize>()
198 + }
199 +
169 200 /// Consume one APC body (the bytes between `\e_G` and the ST/BEL
170 201 /// terminator). Bodies have the form `<control>;<base64-payload>` or
171 202 /// `<control>` alone (place/delete/compose with no payload).
@@ -7,6 +7,7 @@
7 7 //! Implements [`vte::Perform`], so the binary can pipe PTY bytes through a
8 8 //! `vte::Parser` straight into the grid.
9 9
10 + pub mod oracle;
10 11 mod selection;
11 12 mod text;
12 13 pub use selection::{Point, Selection, SelectionMode, SelectionSpan};
@@ -1703,7 +1704,15 @@
1703 1704 // The pair the write starts inside: either this cell is a spacer whose
1704 1705 // lead sits behind it, or it is a lead whose spacer the write does not
1705 1706 // reach.
1706 - if cells[start + col].is_spacer() && col > 0 {
1707 + //
1708 + // The `is_wide` test on the cell behind is what makes the first case
1709 + // safe. Not every spacer has a lead: `write_pad` leaves one at the
1710 + // right edge for a wide character that did not fit, and without the
1711 + // test a narrow write over that pad blanks whatever sits two columns
1712 + // back — a real character, and if it is itself a wide lead the row is
1713 + // left holding exactly the half pair this function exists to prevent.
1714 + // Found by the soak oracle, 2026-08-29.
1715 + if cells[start + col].is_spacer() && col > 0 && cells[start + col - 1].is_wide() {
1707 1716 cells[start + col - 1] = Cell::default();
1708 1717 } else if width == 1 && cells[start + col].is_wide() && col + 1 < cols {
1709 1718 cells[start + col + 1] = Cell::default();
@@ -2115,10 +2124,19 @@
2115 2124 if n == 0 {
2116 2125 return;
2117 2126 }
2118 - for r in top..=self.scroll_bottom - n {
2127 + // Written as one exclusive boundary rather than two inclusive ranges,
2128 + // because `scroll_bottom - n` underflows when the delete covers the
2129 + // whole region from its top row: `n` is clamped to the region size, so
2130 + // n == scroll_bottom + 1 is reachable with top == 0. A panic in debug,
2131 + // and in release a range running to about 65,000 that hands `copy_row`
2132 + // rows off the end of the ring. `scroll_bottom + 1 - n` cannot
2133 + // underflow, since n is at most scroll_bottom - top + 1. Found by the
2134 + // soak oracle, 2026-08-29.
2135 + let keep_end = self.scroll_bottom + 1 - n;
2136 + for r in top..keep_end {
2119 2137 self.copy_row(r + n, r);
2120 2138 }
2121 - for r in self.scroll_bottom + 1 - n..=self.scroll_bottom {
2139 + for r in keep_end..=self.scroll_bottom {
2122 2140 self.blank_screen_row(r);
2123 2141 }
2124 2142 if top > 0 {
@@ -2605,10 +2623,24 @@
2605 2623 // logical physical positions. Unroll preserves logical
2606 2624 // contents so the renderer's per-row cache stays valid — no
2607 2625 // need to mark rows dirty here.
2608 - self.unroll_region();
2609 2626 let (top, bot) = param2(params, (1, self.rows));
2610 - self.scroll_top = top.saturating_sub(1).min(self.rows - 1);
2611 - self.scroll_bottom = bot.saturating_sub(1).min(self.rows - 1);
2627 + let new_top = top.saturating_sub(1).min(self.rows - 1);
2628 + let new_bottom = bot.saturating_sub(1).min(self.rows - 1);
2629 + // A region needs at least two rows, and its top has to be
2630 + // above its bottom. DEC and xterm both drop the whole request
2631 + // when it does not, cursor move included, and so does this:
2632 + // `region_size` is computed as `bottom - top + 1` in the scroll
2633 + // paths, so an inverted pair underflows there — a panic in
2634 + // debug and a region of ~65,000 rows in release, which is a
2635 + // row index off the end of the ring feeding the unchecked
2636 + // store in `place_char`. Found by the soak oracle on
2637 + // `ESC [ 20 ; 3 r`, 2026-08-29.
2638 + if new_top >= new_bottom {
2639 + return;
2640 + }
2641 + self.unroll_region();
2642 + self.scroll_top = new_top;
2643 + self.scroll_bottom = new_bottom;
2612 2644 if self.is_partial_region() {
2613 2645 self.unroll_active_ring();
2614 2646 }
@@ -2758,6 +2790,39 @@
2758 2790 p.advance(grid, bytes);
2759 2791 }
2760 2792
2793 + /// A pad at the right edge is a spacer with no lead, and a narrow write
2794 + /// over it must not reach back for one.
2795 + ///
2796 + /// The row here ends `..日日<pad>`: the last wide character did not fit in
2797 + /// the final column, so `write_pad` left a spacer there and the character
2798 + /// went to the next row. Writing a narrow character over that pad used to
2799 + /// blank the cell two columns back, which is the spacer of a real pair, and
2800 + /// left its lead on screen claiming a width it no longer had.
2801 + #[test]
2802 + fn a_pad_at_the_right_edge_is_not_half_a_pair() {
2803 + let mut g = Grid::new(10, 4);
2804 + // The first wide character takes columns 7 and 8. The second has only
2805 + // column 9 left, so it goes to the next row and leaves a pad behind.
2806 + feed(&mut g, "\x1b[1;8H日日".as_bytes());
2807 + assert!(g.row(0)[7].is_wide());
2808 + assert!(g.row(0)[8].is_spacer());
2809 + assert!(
2810 + g.row(0)[9].is_spacer(),
2811 + "the column the wide character could not use"
2812 + );
2813 + assert!(!g.row(0)[9].is_wide());
2814 +
2815 + feed(&mut g, b"\x1b[1;10Hx");
2816 + assert_eq!(g.row(0)[9].c(), 'x');
2817 + assert_eq!(
2818 + g.row(0)[7].c(),
2819 + '日',
2820 + "the pair two columns back was blanked"
2821 + );
2822 + assert!(g.row(0)[7].is_wide());
2823 + assert!(g.row(0)[8].is_spacer(), "its spacer went with it");
2824 + }
2825 +
2761 2826 fn row_str(grid: &Grid, r: u16) -> String {
2762 2827 grid.row(r)
2763 2828 .iter()
@@ -124,6 +124,20 @@
124 124 fn new_param(&mut self) {
125 125 self.push_new();
126 126 }
127 +
128 + /// Heap bytes this parameter list is holding.
129 + ///
130 + /// One `Vec` per parameter is the storage shape the module header admits
131 + /// to, and this is what makes the cost of that choice observable to
132 + /// [`Parser::buffered_bytes`] instead of only to the machine.
133 + fn footprint(&self) -> usize {
134 + self.inner.capacity() * std::mem::size_of::<Vec<u16>>()
135 + + self
136 + .inner
137 + .iter()
138 + .map(|g| g.capacity() * std::mem::size_of::<u16>())
139 + .sum::<usize>()
140 + }
127 141 }
128 142
129 143 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
@@ -590,6 +604,31 @@
590 604 }
591 605 }
592 606
607 + /// Is the parser between sequences, holding no partial state?
608 + ///
609 + /// Ground is the only state in which a stream can be cut without losing
610 + /// something, so this is what a caller checks to know a chunk boundary is
611 + /// safe and what the fuzz oracle checks after a terminated sequence.
612 + #[must_use]
613 + pub fn in_ground(&self) -> bool {
614 + matches!(self.state, State::Ground)
615 + }
616 +
617 + /// Heap bytes held on behalf of sequences that have not completed.
618 + ///
619 + /// The parser accumulates three unbounded things — OSC body, APC body and
620 + /// the CSI parameter list — and none of them is capped by the state
621 + /// machine. Exposing the total is what lets the soak oracle assert an
622 + /// amplification ceiling instead of waiting for libFuzzer's RSS limit to
623 + /// notice a machine-sized allocation.
624 + #[must_use]
625 + pub fn buffered_bytes(&self) -> usize {
626 + self.osc_buf.capacity()
627 + + self.apc_buf.capacity()
628 + + self.osc_params.capacity() * std::mem::size_of::<(usize, usize)>()
629 + + self.params.footprint()
630 + }
631 +
593 632 fn collect(&mut self, b: u8) {
594 633 if self.intermediates_idx < self.intermediates.len() {
595 634 self.intermediates[self.intermediates_idx] = b;
@@ -1,0 +1,4 @@
1 + target
2 + corpus
3 + artifacts
4 + coverage
@@ -1,0 +1,250 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "arbitrary"
7 + version = "1.4.2"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
10 +
11 + [[package]]
12 + name = "base64"
13 + version = "0.23.1"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
16 +
17 + [[package]]
18 + name = "cc"
19 + version = "1.4.4"
20 + source = "registry+https://github.com/rust-lang/crates.io-index"
21 + checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
22 + dependencies = [
23 + "find-msvc-tools",
24 + "jobserver",
25 + "libc",
26 + "shlex",
27 + ]
28 +
29 + [[package]]
30 + name = "cfg-if"
31 + version = "1.0.4"
32 + source = "registry+https://github.com/rust-lang/crates.io-index"
33 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
34 +
35 + [[package]]
36 + name = "find-msvc-tools"
37 + version = "0.1.11"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
40 +
41 + [[package]]
42 + name = "getrandom"
43 + version = "0.4.3"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
46 + dependencies = [
47 + "cfg-if",
48 + "libc",
49 + "r-efi",
50 + ]
51 +
52 + [[package]]
53 + name = "jobserver"
54 + version = "0.1.35"
55 + source = "registry+https://github.com/rust-lang/crates.io-index"
56 + checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
57 + dependencies = [
58 + "getrandom",
59 + "libc",
60 + ]
61 +
62 + [[package]]
63 + name = "kittygfx"
64 + version = "0.1.0"
65 + dependencies = [
66 + "base64",
67 + "tracing",
68 + ]
69 +
70 + [[package]]
71 + name = "kittygfx-fuzz"
72 + version = "0.0.0"
73 + dependencies = [
74 + "kittygfx",
75 + "libfuzzer-sys",
76 + ]
77 +
78 + [[package]]
79 + name = "libc"
80 + version = "0.2.189"
81 + source = "registry+https://github.com/rust-lang/crates.io-index"
82 + checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
83 +
84 + [[package]]
85 + name = "libfuzzer-sys"
86 + version = "0.4.13"
87 + source = "registry+https://github.com/rust-lang/crates.io-index"
88 + checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
89 + dependencies = [
90 + "arbitrary",
91 + "cc",
92 + ]
93 +
94 + [[package]]
95 + name = "once_cell"
96 + version = "1.21.4"
97 + source = "registry+https://github.com/rust-lang/crates.io-index"
98 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
99 +
100 + [[package]]
101 + name = "pin-project-lite"
102 + version = "0.2.17"
103 + source = "registry+https://github.com/rust-lang/crates.io-index"
104 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
105 +
106 + [[package]]
107 + name = "proc-macro2"
108 + version = "1.0.107"
109 + source = "registry+https://github.com/rust-lang/crates.io-index"
110 + checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
111 + dependencies = [
112 + "unicode-ident",
113 + ]
114 +
115 + [[package]]
116 + name = "quote"
117 + version = "1.0.47"
118 + source = "registry+https://github.com/rust-lang/crates.io-index"
119 + checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
120 + dependencies = [
121 + "proc-macro2",
122 + ]
123 +
124 + [[package]]
125 + name = "r-efi"
126 + version = "6.0.0"
127 + source = "registry+https://github.com/rust-lang/crates.io-index"
128 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
129 +
130 + [[package]]
131 + name = "shlex"
132 + version = "2.0.1"
133 + source = "registry+https://github.com/rust-lang/crates.io-index"
134 + checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
135 +
136 + [[package]]
137 + name = "syn"
138 + version = "2.0.119"
139 + source = "registry+https://github.com/rust-lang/crates.io-index"
140 + checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
141 + dependencies = [
142 + "proc-macro2",
143 + "quote",
144 + "unicode-ident",
145 + ]
146 +
147 + [[package]]
148 + name = "tracing"
149 + version = "0.1.44"
150 + source = "registry+https://github.com/rust-lang/crates.io-index"
151 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
152 + dependencies = [
153 + "pin-project-lite",
154 + "tracing-attributes",
155 + "tracing-core",
156 + ]
157 +
158 + [[package]]
159 + name = "tracing-attributes"
160 + version = "0.1.31"
161 + source = "registry+https://github.com/rust-lang/crates.io-index"
162 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
163 + dependencies = [
164 + "proc-macro2",
165 + "quote",
166 + "syn",
167 + ]
168 +
169 + [[package]]
170 + name = "tracing-core"
171 + version = "0.1.36"
172 + source = "registry+https://github.com/rust-lang/crates.io-index"
173 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
174 + dependencies = [
175 + "once_cell",
176 + ]
177 +
178 + [[package]]
179 + name = "unicode-ident"
180 + version = "1.0.24"
181 + source = "registry+https://github.com/rust-lang/crates.io-index"
182 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
183 +
184 + [[patch.unused]]
185 + name = "quasi-type"
186 + version = "0.1.3"
187 +
188 + [[patch.unused]]
189 + name = "kberg"
190 + version = "0.1.0"
191 +
192 + [[patch.unused]]
193 + name = "ops-status"
194 + version = "0.1.0"
195 +
196 + [[patch.unused]]
197 + name = "painhours"
198 + version = "0.1.0"
199 +
200 + [[patch.unused]]
201 + name = "tagtree"
202 + version = "0.4.1"
203 +
204 + [[patch.unused]]
205 + name = "quasi-axum"
206 + version = "0.81.0"
207 +
208 + [[patch.unused]]
209 + name = "quasi-basics"
210 + version = "0.81.0"
211 +
212 + [[patch.unused]]
213 + name = "quasi-http"
214 + version = "0.81.0"
215 +
216 + [[patch.unused]]
217 + name = "quasi-immediate"
218 + version = "0.81.0"
219 +
220 + [[patch.unused]]
221 + name = "quasi-notifs"
222 + version = "0.81.0"
223 +
224 + [[patch.unused]]
225 + name = "quasi-router"
226 + version = "0.81.0"
227 +
228 + [[patch.unused]]
229 + name = "quasi-store"
230 + version = "0.1.0"
231 +
232 + [[patch.unused]]
233 + name = "quasi-tauri"
234 + version = "0.81.0"
235 +
236 + [[patch.unused]]
237 + name = "quasi-webview"
238 + version = "0.81.0"
239 +
240 + [[patch.unused]]
241 + name = "synckit-client"
242 + version = "0.10.0"
243 +
244 + [[patch.unused]]
245 + name = "synckit-config"
246 + version = "0.2.0"
247 +
248 + [[patch.unused]]
249 + name = "docengine"
250 + version = "0.7.0"
@@ -1,0 +1,27 @@
1 + [package]
2 + name = "kittygfx-fuzz"
3 + version = "0.0.0"
4 + publish = false
5 + edition = "2024"
6 +
7 + # shop has a root workspace, and without this table cargo resolves this crate
8 + # into it. `cargo fuzz` only emits it when `--fuzzing-workspace` is passed, so
9 + # a fuzz crate created the default way inside a workspace repo silently joins
10 + # the parent and drags libfuzzer-sys into every ordinary build.
11 + [workspace]
12 +
13 + [package.metadata]
14 + cargo-fuzz = true
15 +
16 + [dependencies]
17 + libfuzzer-sys = "0.4"
18 +
19 + [dependencies.kittygfx]
20 + path = ".."
21 +
22 + [[bin]]
23 + name = "apc"
24 + path = "fuzz_targets/apc.rs"
25 + test = false
26 + doc = false
27 + bench = false
@@ -1,0 +1,35 @@
1 + //! Structured fuzz over the kitty graphics protocol's APC bodies.
2 + //!
3 + //! The second half of the shop soak pair, and a separate target from `vt`
4 + //! rather than a mode of it: these are two grammars with no shared code and no
5 + //! dependency edge between their crates. `shop-vt` reads a byte stream with
6 + //! embedded state; this reads a body someone else has already delimited. The
7 + //! seam between them is in the shop binary, which is the only crate that links
8 + //! both.
9 + //!
10 + //! What is at stake here is not the parser's own memory, which is safe by
11 + //! construction (`#![deny(unsafe_code)]`), but what it hands the host: shop
12 + //! indexes a transmitted payload as pixels using dimensions the same untrusted
13 + //! body supplied. So the oracle is about payloads arriving intact and about
14 + //! answers a client can act on, not about not panicking.
15 + //!
16 + //! ## The oracle lives in the crate, not here
17 + //!
18 + //! Everything asserted is `kittygfx::oracle::check_bodies`. The committed
19 + //! regression replay in `tests/regressions.rs` calls the same function on
20 + //! stable, so a crash found here becomes a unit test by copying one file, and
21 + //! neither side can drift into checking less than the other.
22 + //!
23 + //! ## Input shape
24 + //!
25 + //! The input is split on ESC, with a leading `_` and a trailing `\` stripped
26 + //! from each piece, so a capture of what a real client sends is a seed as it
27 + //! stands.
28 +
29 + #![no_main]
30 +
31 + use libfuzzer_sys::fuzz_target;
32 +
33 + fuzz_target!(|data: &[u8]| {
34 + kittygfx::oracle::check_bodies(data);
35 + });
@@ -1,0 +1,8 @@
1 + # Regressions
2 +
3 + One file per input that once found a bug. `tests/regressions.rs` replays every
4 + file here through `kittygfx::oracle::check_bodies` on stable, so anything
5 + landing in this directory is a permanent test by virtue of the directory, with
6 + no test function to write and no chance of forgetting one.
7 +
8 + None yet.
@@ -1,0 +1,62 @@
1 + # Seed corpora
2 +
3 + Hand-written and real starting inputs, one directory per fuzz target. These are
4 + committed; `corpus/` is not.
5 +
6 + The split follows `astra-soak-overview`, which calls a minimized corpus
7 + "accumulated compute, not a build artifact":
8 +
9 + - **These seeds are human intent.** The `doc-` files are the protocol
10 + documentation's own examples. The `real-` files are genuine transmissions: a
11 + 48x48 PNG off this machine sent both single-shot and in the 4096-character
12 + chunks a client actually uses, and raw RGB and RGBA frames whose dimensions
13 + agree with their payloads. Three of them are transmissions long enough to be
14 + chunked in earnest, added 2026-08-29 because the set had none: a 64x64 RGBA
15 + frame in twenty-two 1024-character chunks, two chunked transmissions under
16 + different `i=` values sent back to back, and an animation that transmits a
17 + root frame, appends to it, composes and deletes. Chunk equivalence is the
18 + property with teeth in this oracle, and a corpus whose every payload fits in
19 + one body never reaches it. The rest is one file per shape worth reaching in
20 + the first second rather than the first hour: each medium a query can ask
21 + about, each way a chunked transfer is keyed, and the malformed bodies that
22 + sit one mutation away from a valid one.
23 + - **`corpus/` is machine output and lives on astra**, under the soak runner's
24 + persistent directory. Minimize with `cargo +nightly fuzz cmin apc` and commit
25 + it once it represents real soak hours, not before.
26 +
27 + ## Input format
28 +
29 + The input is split on ESC and each piece has a leading `_` and a trailing `\`
30 + stripped, so a seed is what a client writes to the terminal, verbatim. A file
31 + holding several APCs is one session through one parser, which is how chunked
32 + transfers and the never-evicted partial state get reached at all.
33 +
34 + Run against these on a machine with no corpus:
35 +
36 + mkdir -p fuzz/corpus/apc
37 + cargo +nightly fuzz run apc fuzz/corpus/apc fuzz/seeds/apc
38 +
39 + The `mkdir` is needed once. `cargo fuzz` creates the default corpus directory
40 + for you only when you name no directories at all; pass them explicitly and
41 + libFuzzer requires every one to exist already.
42 +
43 + **Name the corpus directory first and this one second.** libFuzzer writes new
44 + inputs into whichever directory it is given first and treats the rest as
45 + read-only. Passing `fuzz/seeds/apc` alone dumps thousands of machine-generated
46 + files in here and buries the hand-written ones, which is exactly the split this
47 + directory exists to keep.
48 +
49 + ## Where real payloads come from
50 +
51 + Not from `kitten icat`: headless it cannot open a controlling terminal, and
52 + under `script(1)` it refuses because the terminal reports no pixel size. There
53 + is no kitty graphics test corpus in the Debian package either. The two routes
54 + that work are building the payloads from a real image, which is what the
55 + `real-` seeds are, and recording a live client with `shop --record PATH`, which
56 + tees the PTY byte stream through `Pty::set_recorder`.
57 +
58 + ## Crash seeds
59 +
60 + An input that once found a bug stays here forever, and also becomes a file
61 + under `fuzz/regressions/`, which `tests/regressions.rs` replays on stable. None
62 + yet.
Binary file
Binary file