phink_lib/cli/ui/
ratatui.rs

1use crate::{
2    cli::{
3        ui::{
4            chart::ChartManager,
5            logger::LogDisplayer,
6            monitor::{
7                corpus::CorpusWatcher,
8                logs::{
9                    AFLDashboard,
10                    AFLProperties,
11                },
12            },
13            traits::{
14                FromPath,
15                Paint,
16            },
17        },
18        ziggy::ZiggyConfig,
19    },
20    instrumenter::instrumentation::Instrumenter,
21    EmptyResult,
22    ResultOf,
23};
24use anyhow::Context;
25use backend::CrosstermBackend;
26use contract_transcode::ContractMessageTranscoder;
27use ratatui::{
28    backend,
29    layout::{
30        Alignment,
31        Constraint,
32        Direction,
33        Layout,
34        Margin,
35        Rect,
36    },
37    style::{
38        Color,
39        Modifier,
40        Style,
41        Stylize,
42    },
43    symbols,
44    text::{
45        Line,
46        Span,
47        Text,
48    },
49    widgets::{
50        Block,
51        Borders,
52        Paragraph,
53        Sparkline,
54        SparklineBar,
55    },
56    Frame,
57};
58use std::{
59    borrow::Borrow,
60    collections::VecDeque,
61    fmt::Write,
62    io,
63    process::Child,
64    sync::{
65        atomic::{
66            AtomicBool,
67            Ordering,
68        },
69        Arc,
70        OnceLock,
71    },
72    thread::sleep,
73    time::Duration,
74};
75
76#[derive(Clone, Debug)]
77pub struct CustomUI {
78    ziggy_config: ZiggyConfig,
79    afl_dashboard: AFLDashboard,
80    corpus_watcher: CorpusWatcher,
81    fuzzing_speed: VecDeque<u64>,
82}
83
84pub static CTOR_VALUE: OnceLock<String> = OnceLock::new();
85
86impl CustomUI {
87    pub fn new(ziggy_config: &ZiggyConfig) -> ResultOf<CustomUI> {
88        CTOR_VALUE.get_or_init(|| {
89            if let Ok(maybe_metadata) = Instrumenter::new(ziggy_config.clone()).find() {
90                if let Ok(transcoder) = ContractMessageTranscoder::load(maybe_metadata.specs_path) {
91                    if let Some(ctor) = &ziggy_config.clone().config().constructor_payload {
92                        return if let Ok(encoded_bytes) = hex::decode(ctor) {
93                            if let Ok(str) =
94                                transcoder.decode_contract_constructor(&mut &encoded_bytes[..])
95                            {
96                                str.to_string()
97                            } else {
98                                format!("Couldn't decode {:?}", encoded_bytes)
99                            }
100                        } else {
101                            "Double check your constructor in your `phink.toml`".to_string()
102                        }
103                    }
104                } else {
105                    return "Couldn't load the JSON specs".parse().unwrap()
106                }
107            }
108            "-".into()
109        });
110
111        let output = ziggy_config.clone().fuzz_output();
112
113        Ok(Self {
114            ziggy_config: ziggy_config.clone(),
115            afl_dashboard: AFLDashboard::from_output(output.clone())
116                .context("Couldn't create AFL dashboard")?,
117            corpus_watcher: CorpusWatcher::from_output(output)
118                .context("Couldn't create the corpus watcher")?,
119            fuzzing_speed: VecDeque::new(),
120        })
121    }
122
123    fn ui(&mut self, f: &mut Frame) -> EmptyResult {
124        let chunks = Layout::default()
125            .direction(Direction::Vertical)
126            .margin(0)
127            .constraints(
128                [
129                    Constraint::Length(7),
130                    Constraint::Percentage(20),
131                    Constraint::Percentage(50),
132                    Constraint::Percentage(30),
133                ]
134                .as_ref(),
135            )
136            .split(f.area());
137
138        self.render_title(f, chunks[0]);
139        self.render_stats(f, chunks[1]);
140        self.render_chart_and_config(f, chunks[2]);
141        self.render_bottom(f, chunks[3])
142            .context("Couldn't render the bottom span")?;
143        Ok(())
144    }
145
146    fn render_chart_and_config(&mut self, f: &mut Frame, area: Rect) {
147        let chunks = Layout::default()
148            .direction(Direction::Horizontal)
149            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
150            .split(area);
151
152        self.render_chart(f, chunks[0]);
153        self.ziggy_config.config().render(f, chunks[1]);
154    }
155
156    fn render_octopus(&self, f: &mut Frame, area: Rect) {
157        let ascii_art = r#"
158,---.
159( @ @ )
160 ).-.(
161'/|||\`
162  '|`
163  "#;
164
165        let octopus = Paragraph::new(ascii_art)
166            .style(Style::default())
167            .alignment(Alignment::Center);
168        f.render_widget(octopus, area);
169    }
170    fn render_title(&self, f: &mut Frame, area: Rect) {
171        self.render_octopus(f, area);
172        let title = Paragraph::new("Phink Fuzzing Dashboard")
173            .style(
174                Style::default()
175                    .fg(Color::White)
176                    .add_modifier(Modifier::BOLD),
177            )
178            .alignment(Alignment::Center);
179        f.render_widget(title, area);
180    }
181
182    fn render_stats(&mut self, f: &mut Frame, area: Rect) {
183        let data = self.afl_dashboard.read_properties();
184
185        if let Ok(afl) = data {
186            self.update_fuzzing_speed(afl.exec_speed.into());
187            let chunks = Layout::default()
188                .direction(Direction::Horizontal)
189                .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
190                .split(area);
191
192            let left_chunks = Layout::default()
193                .direction(Direction::Vertical)
194                .constraints([Constraint::Min(0)].as_ref())
195                .split(chunks[0]);
196
197            let right_chunk = Layout::default()
198                .direction(Direction::Vertical)
199                .constraints([Constraint::Min(0)].as_ref())
200                .split(chunks[1]);
201
202            self.stats_left(f, afl.borrow(), left_chunks[0]);
203            self.speed_right(f, right_chunk[0]);
204        }
205    }
206
207    fn stats_left(&self, frame: &mut Frame, data: &AFLProperties, area: Rect) {
208        let chunks = Layout::default()
209            .direction(Direction::Vertical)
210            .margin(1)
211            .constraints([Constraint::Percentage(100)].as_ref())
212            .split(area);
213
214        let paragraph = Paragraph::new(Vec::from([
215            Line::from(vec![
216                Span::raw("Running for: "),
217                Span::styled(
218                    data.run_time.clone(),
219                    Style::default().add_modifier(Modifier::BOLD),
220                ),
221            ]),
222            Line::from(vec![
223                Span::raw("Last new find: "),
224                Span::styled(
225                    data.last_new_find.clone(),
226                    Style::default().add_modifier(Modifier::BOLD),
227                ),
228            ]),
229            Line::from(vec![
230                Span::raw("Last saved crash: "),
231                Span::styled(
232                    data.last_saved_crash.clone(),
233                    Style::default().add_modifier(Modifier::BOLD),
234                ),
235            ]),
236            Line::from(vec![
237                Span::raw("Corpus count: "),
238                Span::styled(
239                    data.corpus_count.to_string(),
240                    Style::default().add_modifier(Modifier::BOLD),
241                ),
242            ]),
243            Line::from(vec![
244                Span::raw("Execution speed: "),
245                Span::styled(
246                    format!("{} execs/sec", data.exec_speed),
247                    Style::default().add_modifier(Modifier::BOLD),
248                ),
249            ]),
250            Line::from(vec![Span::raw("Stability: "), data.span_if_bad_stability()]),
251            Line::from(vec![Span::raw("Bug found: "), data.span_if_crash()]),
252        ]))
253        .block(
254            Block::default()
255                .borders(Borders::ALL)
256                .title("Statistics")
257                .bold()
258                .title_alignment(Alignment::Center),
259        );
260
261        frame.render_widget(paragraph, chunks[0]);
262    }
263
264    fn update_fuzzing_speed(&mut self, new_speed: u64) {
265        const MAX_POINTS: usize = 100; // Adjust as needed
266
267        self.fuzzing_speed.push_back(new_speed);
268        if self.fuzzing_speed.len() > MAX_POINTS {
269            self.fuzzing_speed.pop_front();
270        }
271    }
272    fn speed_right(&mut self, frame: &mut Frame, area: Rect) {
273        let chunks = Layout::default()
274            .direction(Direction::Vertical)
275            .margin(1)
276            .constraints([Constraint::Percentage(100)].as_ref())
277            .split(area);
278
279        let speed_vec = &self.fuzzing_speed.make_contiguous();
280
281        let sparkline = Sparkline::default()
282            .block(
283                Block::new()
284                    .borders(Borders::ALL)
285                    .title("Execution speed evolution (execs/s)")
286                    .bold()
287                    .title_alignment(Alignment::Center),
288            )
289            .data(
290                speed_vec
291                    .iter()
292                    .map(|&value| SparklineBar::from(Some(value))),
293            )
294            .style(Style::default().fg(Color::White))
295            .bar_set(symbols::bar::NINE_LEVELS);
296
297        let stats_chunk = chunks[0].inner(Margin {
298            vertical: 1,
299            horizontal: 1,
300        });
301
302        frame.render_widget(sparkline, chunks[0]);
303
304        let stats = [
305            format!(
306                "Max: {:.2}",
307                self.fuzzing_speed
308                    .iter()
309                    .max_by(|a, b| a.partial_cmp(b).unwrap())
310                    .unwrap_or(&0)
311            ),
312            format!(
313                "Avg: {:.2}",
314                self.fuzzing_speed.iter().sum::<u64>() / self.fuzzing_speed.len() as u64
315            ),
316        ];
317        for (i, stat) in stats.iter().enumerate() {
318            let stat_layout = Layout::default()
319                .direction(Direction::Horizontal)
320                .constraints([Constraint::Percentage(100)])
321                .split(Rect {
322                    x: stats_chunk.x,
323                    y: stats_chunk.y + i as u16,
324                    width: stats_chunk.width,
325                    height: 1,
326                });
327
328            let paragraph = Paragraph::new(stat.as_str()).style(
329                Style::default()
330                    .fg(Color::White)
331                    .add_modifier(Modifier::ITALIC),
332            );
333            frame.render_widget(paragraph, stat_layout[0]);
334        }
335    }
336
337    fn render_chart(&mut self, f: &mut Frame, area: Rect) {
338        let chunks = Layout::default()
339            .direction(Direction::Vertical)
340            .constraints([Constraint::Percentage(100)].as_ref())
341            .split(area);
342
343        let corpus_counter: &[(f64, f64)] = &self.corpus_watcher.as_tuple_slice();
344
345        let chart_manager = ChartManager::new(corpus_counter);
346        f.render_widget(chart_manager.create_chart(), chunks[0]);
347    }
348
349    fn render_bottom(&mut self, f: &mut Frame, area: Rect) -> EmptyResult {
350        let bottom_parts = Layout::default()
351            .direction(Direction::Horizontal)
352            .constraints([Constraint::Percentage(100)].as_ref())
353            .split(area);
354
355        let seed_info = self.display_fuzzed_seed();
356        f.render_widget(seed_info, bottom_parts[0]);
357
358        Ok(())
359    }
360
361    // Keeps ASCII graphic characters and whitespace as-is.
362    // Replaces other characters with a caret (^) followed by the corresponding ASCII character
363    // (similar to how bat does it). A null byte (\0) would be displayed as ^@
364    // A carriage return (\r) would be displayed as ^M
365    // Other control characters would be displayed as ^A, ^B, etc.
366    fn escape_non_printable(s: &str) -> String {
367        let mut result = String::with_capacity(s.len());
368        for byte in s.bytes() {
369            match byte {
370                0x20..=0x7E | b'\n' | b'\t' => result.push(byte as char),
371                _ => write!(result, "^{}", byte.wrapping_add(64) as char).unwrap(),
372            }
373        }
374        result
375    }
376    fn display_fuzzed_seed(&mut self) -> Paragraph {
377        let mut seed_text: Text = Default::default();
378        let seed_info_text: String =
379            match LogDisplayer::new(self.clone().ziggy_config.fuzz_output()).load() {
380                None => String::new(),
381                Some(e) => e.to_string(),
382            };
383
384        if !seed_info_text.is_empty() {
385            let escaped_text = Self::escape_non_printable(&seed_info_text);
386            for line in escaped_text.lines() {
387                seed_text.push_line(Line::styled(line.to_string(), Style::default()));
388            }
389        } else {
390            seed_text.push_span(Span::styled(
391                format!(
392                    "Running the seeds, please wait until we actually start fuzzing...\n
393                 If this screen get stuck for a while, execute `tail -f {}`.",
394                    &self.afl_dashboard.get_path().to_str().unwrap()
395                ),
396                Style::default().fg(Color::Yellow),
397            ));
398            seed_text.push_span(Span::styled(
399                "Either there is a terrible bug (you'll see this in the AFL log), either we are still looking for a decodable seed.",
400                Style::default().fg(Color::Yellow),
401            ));
402        }
403
404        Paragraph::new(seed_text.clone()).block(
405            Block::default()
406                .borders(Borders::ALL)
407                .border_style(Style::default())
408                .title(Span::styled(
409                    "Last Fuzzed Messages",
410                    Style::default().add_modifier(Modifier::BOLD),
411                ))
412                .title_alignment(Alignment::Center),
413        )
414    }
415
416    pub fn initialize_tui(&mut self, mut child: Child) -> EmptyResult {
417        let backend = CrosstermBackend::new(io::stdout());
418        let mut terminal =
419            ratatui::Terminal::new(backend).context("Couldn't create the terminal backend")?;
420        terminal.clear().context("Couldn't clear the terminal")?;
421
422        let running = Arc::new(AtomicBool::new(true));
423        let r = running.clone();
424
425        ctrlc::set_handler(move || {
426            r.store(false, Ordering::SeqCst);
427        })?;
428
429        while running.load(Ordering::SeqCst) {
430            terminal.draw(|f| {
431                sleep(Duration::from_millis(500));
432                if let Err(err) = self.ui(f) {
433                    eprintln!("{:?}", err);
434                }
435            })?;
436        }
437
438        let i = child.id();
439
440        terminal.clear()?;
441        child
442            .kill()
443            .context(format!("Couldn't kill the child n°{i}"))?;
444        println!("šŸ‘‹ It was nice fuzzing with you. Killing PID {i}. Bye bye! ",);
445
446        Ok(())
447    }
448}