max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
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