1053 lines
38 KiB
Rust
Executable File
1053 lines
38 KiB
Rust
Executable File
//! # CorbelPurge GUI
|
||
//!
|
||
//! iced 0.13 dashboard GUI for the CorbelPurge security pipeline.
|
||
//!
|
||
//! Only built when the `gui` feature is enabled:
|
||
//!
|
||
//! ```bash
|
||
//! cargo build --release --features gui --bin corbel-purge-gui
|
||
//! ```
|
||
//!
|
||
//! Layout (matches the design mockup):
|
||
//! - 48 px header – brand + version + license badge
|
||
//! - Collapsible left panel – PATHS / OPTIONS / CONSOLE sections
|
||
//! - 280 px right sidebar – progress gauge, stats, RUN CONFIG
|
||
//! - 56 px footer – action buttons + status bar
|
||
|
||
use std::path::PathBuf;
|
||
|
||
use corbel_purge::{Config, Pipeline, PipelineResult, ThreatClassification};
|
||
use iced::widget::{
|
||
button, column, container, row, scrollable, Space, text, text_input, toggler,
|
||
};
|
||
use iced::{
|
||
Alignment, Border, Color, Element, Length, Padding, Task, Theme,
|
||
};
|
||
use iced::widget::text::Style as TextStyle;
|
||
use iced::widget::button::Style as ButtonStyle;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Colour palette
|
||
// ---------------------------------------------------------------------------
|
||
|
||
mod colors {
|
||
use super::Color;
|
||
pub fn bg() -> Color { Color::from([0.051, 0.051, 0.051]) }
|
||
pub fn panel() -> Color { Color::from([0.10, 0.10, 0.10 ]) }
|
||
pub fn panel_hi() -> Color { Color::from([0.13, 0.13, 0.13 ]) }
|
||
pub fn header_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) }
|
||
pub fn footer_bg() -> Color { Color::from([0.07, 0.07, 0.07 ]) }
|
||
pub fn gold() -> Color { Color::from([0.83, 0.69, 0.22 ]) }
|
||
pub fn white() -> Color { Color::from([0.92, 0.92, 0.92 ]) }
|
||
pub fn dim() -> Color { Color::from([0.55, 0.55, 0.58 ]) }
|
||
pub fn green() -> Color { Color::from([0.35, 0.85, 0.45 ]) }
|
||
pub fn red() -> Color { Color::from([0.90, 0.30, 0.30 ]) }
|
||
pub fn yellow() -> Color { Color::from([0.95, 0.75, 0.25 ]) }
|
||
pub fn teal() -> Color { Color::from([0.20, 0.65, 0.60 ]) }
|
||
pub fn input_bg() -> Color { Color::from([0.12, 0.12, 0.12 ]) }
|
||
pub fn border_dim() -> Color { Color::from([0.22, 0.22, 0.22 ]) }
|
||
pub fn console_bg() -> Color { Color::from([0.06, 0.06, 0.06 ]) }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Style helpers (iced 0.13 closure-based API)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn color_style(c: Color) -> impl Fn(&Theme) -> TextStyle {
|
||
move |_t: &Theme| TextStyle { color: Some(c) }
|
||
}
|
||
|
||
fn panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::panel().into()),
|
||
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
fn header_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::header_bg().into()),
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
fn footer_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::footer_bg().into()),
|
||
border: Border { color: colors::border_dim(), width: 1.0, radius: 0.0.into() },
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
fn console_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::console_bg().into()),
|
||
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
fn section_hdr_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::panel_hi().into()),
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// iced 0.13 button style closures must accept `(&Theme, button::Status)`.
|
||
fn gold_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
|
||
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
|
||
background: Some(colors::gold().into()),
|
||
text_color: Color::BLACK,
|
||
border: Border { color: colors::gold(), width: 0.0, radius: 4.0.into() },
|
||
..ButtonStyle::default()
|
||
}
|
||
}
|
||
|
||
fn dim_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
|
||
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
|
||
background: Some(colors::panel_hi().into()),
|
||
text_color: colors::dim(),
|
||
border: Border { color: colors::border_dim(), width: 1.0, radius: 4.0.into() },
|
||
..ButtonStyle::default()
|
||
}
|
||
}
|
||
|
||
fn teal_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
|
||
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
|
||
background: Some(colors::teal().into()),
|
||
text_color: Color::BLACK,
|
||
border: Border { color: colors::teal(), width: 0.0, radius: 4.0.into() },
|
||
..ButtonStyle::default()
|
||
}
|
||
}
|
||
|
||
fn green_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::green().into()),
|
||
border: Border { color: colors::green(), width: 1.0, radius: 3.0.into() },
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
fn separator_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::border_dim().into()),
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Shorthand: coloured text.
|
||
///
|
||
/// `size` is `f32` to match iced 0.13's `Text::size` signature.
|
||
fn t(content: impl ToString, size: f32, color: Color) -> iced::widget::Text<'static> {
|
||
text(content.to_string()).size(size).style(color_style(color))
|
||
}
|
||
|
||
/// Shorthand: f32 padding (iced 0.13 Padding fields are f32).
|
||
fn pad(t: f32, r: f32, b: f32, l: f32) -> Padding {
|
||
Padding { top: t, right: r, bottom: b, left: l }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Application
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn main() -> iced::Result {
|
||
iced::application("CorbelPurge", update, view)
|
||
.theme(|_| Theme::Dark)
|
||
.window_size((1100.0, 750.0))
|
||
.run()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[derive(Debug, Default)]
|
||
struct CorbelGui {
|
||
input_path: String,
|
||
output_path: String,
|
||
preserve_format: bool,
|
||
strip_metadata: bool,
|
||
recursive: bool,
|
||
abort_on_threat: bool,
|
||
paths_open: bool,
|
||
options_open: bool,
|
||
console_open: bool,
|
||
about_open: bool,
|
||
console_lines: Vec<ConsoleLine>,
|
||
is_processing: bool,
|
||
last_result: Option<PipelineResult>,
|
||
cleansed_content: Option<String>,
|
||
error: Option<String>,
|
||
processed: u32,
|
||
cleaned: u32,
|
||
copied: u32,
|
||
errors: u32,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct ConsoleLine {
|
||
timestamp: String,
|
||
tag: String,
|
||
message: String,
|
||
is_error: bool,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Messages
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[derive(Debug, Clone)]
|
||
enum Message {
|
||
InputPathChanged(String),
|
||
OutputPathChanged(String),
|
||
BrowseInput,
|
||
BrowseOutput,
|
||
PreserveFormatToggled(bool),
|
||
StripMetadataToggled(bool),
|
||
RecursiveToggled(bool),
|
||
AbortOnThreatToggled(bool),
|
||
TogglePaths,
|
||
ToggleOptions,
|
||
ToggleConsole,
|
||
ToggleAbout,
|
||
CloseAbout,
|
||
StartProcessing,
|
||
StopProcessing,
|
||
ClearLog,
|
||
FilePickedInput(Option<PathBuf>),
|
||
FilePickedOutput(Option<PathBuf>),
|
||
ScanCompleted(Result<PipelineResult, String>),
|
||
OpenQuarantineFolder,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Update
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn update(state: &mut CorbelGui, msg: Message) -> Task<Message> {
|
||
match msg {
|
||
Message::InputPathChanged(s) => { state.input_path = s; Task::none() }
|
||
Message::OutputPathChanged(s) => { state.output_path = s; Task::none() }
|
||
Message::BrowseInput => Task::perform(pick_file(), Message::FilePickedInput),
|
||
Message::BrowseOutput => Task::perform(pick_folder(), Message::FilePickedOutput),
|
||
Message::FilePickedInput(p) => { if let Some(p) = p { state.input_path = p.display().to_string(); } Task::none() }
|
||
Message::FilePickedOutput(p) => { if let Some(p) = p { state.output_path = p.display().to_string(); } Task::none() }
|
||
Message::PreserveFormatToggled(b) => { state.preserve_format = b; Task::none() }
|
||
Message::StripMetadataToggled(b) => { state.strip_metadata = b; Task::none() }
|
||
Message::RecursiveToggled(b) => { state.recursive = b; Task::none() }
|
||
Message::AbortOnThreatToggled(b) => { state.abort_on_threat = b; Task::none() }
|
||
Message::TogglePaths => { state.paths_open = !state.paths_open; Task::none() }
|
||
Message::ToggleOptions => { state.options_open = !state.options_open; Task::none() }
|
||
Message::ToggleConsole => { state.console_open = !state.console_open; Task::none() }
|
||
Message::ToggleAbout => { state.about_open = !state.about_open; Task::none() }
|
||
Message::CloseAbout => { state.about_open = false; Task::none() }
|
||
Message::ClearLog => { state.console_lines.clear(); state.processed = 0; state.cleaned = 0; state.copied = 0; state.errors = 0; Task::none() }
|
||
Message::StopProcessing => { state.is_processing = false; Task::none() }
|
||
|
||
Message::StartProcessing => {
|
||
if state.input_path.is_empty() {
|
||
state.console_lines.push(ConsoleLine {
|
||
timestamp: now_hms(),
|
||
tag: "ERROR".into(),
|
||
message: "No input path specified.".into(),
|
||
is_error: true,
|
||
});
|
||
state.errors += 1;
|
||
return Task::none();
|
||
}
|
||
state.is_processing = true;
|
||
state.error = None;
|
||
let path = PathBuf::from(&state.input_path);
|
||
let ws = if state.output_path.is_empty() { None } else { Some(PathBuf::from(&state.output_path)) };
|
||
let abort = state.abort_on_threat;
|
||
let pf = state.preserve_format;
|
||
state.console_lines.push(ConsoleLine {
|
||
timestamp: now_hms(),
|
||
tag: "INFO".into(),
|
||
message: format!("corbel-purge v{} starting...", env!("CARGO_PKG_VERSION")),
|
||
is_error: false,
|
||
});
|
||
Task::perform(run_pipeline(path, ws, abort, pf), Message::ScanCompleted)
|
||
}
|
||
|
||
Message::ScanCompleted(result) => {
|
||
state.is_processing = false;
|
||
state.processed += 1;
|
||
match result {
|
||
Ok(pr) => {
|
||
let malicious = pr.scan_report.malicious_count();
|
||
let total = pr.scan_report.findings.len();
|
||
let src = pr.source_path.as_ref()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|| "<memory>".into());
|
||
|
||
if malicious > 0 {
|
||
state.console_lines.push(ConsoleLine {
|
||
timestamp: now_hms(),
|
||
tag: "ERROR".into(),
|
||
message: format!("{} — {} finding(s) — cleaned", src, total),
|
||
is_error: true,
|
||
});
|
||
state.cleaned += 1;
|
||
state.errors += 1;
|
||
} else {
|
||
state.console_lines.push(ConsoleLine {
|
||
timestamp: now_hms(),
|
||
tag: "OK".into(),
|
||
message: format!("{} clean — copied", src),
|
||
is_error: false,
|
||
});
|
||
state.copied += 1;
|
||
}
|
||
|
||
if let Some(cp) = &pr.cleansed_path {
|
||
match std::fs::read_to_string(cp) {
|
||
Ok(c) => state.cleansed_content = Some(c),
|
||
Err(e) => state.error = Some(format!("read cleansed: {e}")),
|
||
}
|
||
}
|
||
state.last_result = Some(pr);
|
||
}
|
||
Err(e) => {
|
||
state.console_lines.push(ConsoleLine {
|
||
timestamp: now_hms(),
|
||
tag: "ERROR".into(),
|
||
message: e.clone(),
|
||
is_error: true,
|
||
});
|
||
state.errors += 1;
|
||
state.error = Some(e);
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
|
||
Message::OpenQuarantineFolder => {
|
||
if let Some(r) = &state.last_result {
|
||
if let Some(q) = &r.quarantine_path {
|
||
if let Some(parent) = q.parent() { open_dir(parent); }
|
||
}
|
||
}
|
||
Task::none()
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// View
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn view(state: &CorbelGui) -> Element<'_, Message> {
|
||
let header = build_header();
|
||
let body = build_body(state);
|
||
let footer = build_footer(state);
|
||
let main = column![header, body, footer].spacing(0);
|
||
|
||
if state.about_open {
|
||
// About overlay takes over the view: dim backdrop + floating panel
|
||
// anchored top-right. The main UI is hidden behind the dim layer
|
||
// (iced 0.13 has no Stack widget for true non-modal overlays).
|
||
build_about_overlay()
|
||
} else {
|
||
main.into()
|
||
}
|
||
}
|
||
|
||
/// 48 px header.
|
||
fn build_header<'a>() -> Element<'a, Message> {
|
||
let brand = row![
|
||
t("[+]", 16.0, colors::gold()),
|
||
t("CORBELPURGE", 16.0, colors::gold()),
|
||
t(format!(" v{}", env!("CARGO_PKG_VERSION")), 12.0, colors::dim()),
|
||
t(" // defensive document research & cleaning", 12.0, colors::dim()),
|
||
].spacing(6.0);
|
||
|
||
let license_badge = container(t("GPL-3.0", 11.0, Color::BLACK))
|
||
.padding(pad(2.0, 8.0, 2.0, 8.0))
|
||
.style(green_badge_style());
|
||
|
||
let header_row = row![brand, row![].width(Length::Fill), license_badge]
|
||
.align_y(Alignment::Center)
|
||
.padding(pad(12.0, 16.0, 12.0, 16.0));
|
||
|
||
container(header_row)
|
||
.width(Length::Fill)
|
||
.height(48)
|
||
.center_y(Length::Shrink)
|
||
.style(header_style())
|
||
.into()
|
||
}
|
||
|
||
/// Main body: left panel + right sidebar.
|
||
fn build_body(state: &CorbelGui) -> Element<'_, Message> {
|
||
let left = build_left_panel(state);
|
||
let right = build_right_sidebar(state);
|
||
row![left, right].spacing(0).height(Length::Fill).into()
|
||
}
|
||
|
||
/// Collapsible left panel with PATHS / OPTIONS / CONSOLE.
|
||
fn build_left_panel(state: &CorbelGui) -> Element<'_, Message> {
|
||
let paths_section = collapsible_section(
|
||
"PATHS",
|
||
state.paths_open,
|
||
Message::TogglePaths,
|
||
build_paths_content(state),
|
||
);
|
||
|
||
let options_section = collapsible_section(
|
||
"OPTIONS",
|
||
state.options_open,
|
||
Message::ToggleOptions,
|
||
build_options_content(state),
|
||
);
|
||
|
||
let console_section = section_console(state);
|
||
|
||
let left_col = column![paths_section, options_section, console_section]
|
||
.spacing(4.0)
|
||
.width(Length::Fill);
|
||
|
||
container(left_col)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.padding(pad(6.0, 6.0, 6.0, 6.0))
|
||
.into()
|
||
}
|
||
|
||
fn build_paths_content(state: &CorbelGui) -> Element<'_, Message> {
|
||
let in_row = row![
|
||
t("IN", 12.0, colors::gold()),
|
||
row![].width(Length::Fill),
|
||
button(t("...", 14.0, colors::dim()))
|
||
.style(dim_btn_style())
|
||
.padding(pad(4.0, 4.0, 4.0, 4.0))
|
||
.on_press(Message::BrowseInput),
|
||
]
|
||
.spacing(8.0)
|
||
.align_y(Alignment::Center);
|
||
|
||
let in_input = text_input("/path/to/input", &state.input_path)
|
||
.on_input(Message::InputPathChanged)
|
||
.padding(pad(6.0, 6.0, 6.0, 6.0))
|
||
.size(12);
|
||
|
||
let out_row = row![
|
||
t("OUT", 12.0, colors::gold()),
|
||
row![].width(Length::Fill),
|
||
button(t("...", 14.0, colors::dim()))
|
||
.style(dim_btn_style())
|
||
.padding(pad(4.0, 4.0, 4.0, 4.0))
|
||
.on_press(Message::BrowseOutput),
|
||
]
|
||
.spacing(8.0)
|
||
.align_y(Alignment::Center);
|
||
|
||
let out_input = text_input("/path/to/output (workspace)", &state.output_path)
|
||
.on_input(Message::OutputPathChanged)
|
||
.padding(pad(6.0, 6.0, 6.0, 6.0))
|
||
.size(12);
|
||
|
||
column![in_row, in_input, out_row, out_input]
|
||
.spacing(6.0)
|
||
.padding(pad(8.0, 10.0, 8.0, 10.0))
|
||
.into()
|
||
}
|
||
|
||
fn build_options_content(state: &CorbelGui) -> Element<'_, Message> {
|
||
let t1 = toggler(state.preserve_format)
|
||
.label("PRESERVE FORMAT".to_string())
|
||
.on_toggle(Message::PreserveFormatToggled);
|
||
let t2 = toggler(state.strip_metadata)
|
||
.label("STRIP METADATA".to_string())
|
||
.on_toggle(Message::StripMetadataToggled);
|
||
let t3 = toggler(state.recursive)
|
||
.label("RECURSIVE".to_string())
|
||
.on_toggle(Message::RecursiveToggled);
|
||
let t4 = toggler(state.abort_on_threat)
|
||
.label("ABORT ON THREAT".to_string())
|
||
.on_toggle(Message::AbortOnThreatToggled);
|
||
column![t1, t2, t3, t4]
|
||
.spacing(8.0)
|
||
.padding(pad(8.0, 10.0, 8.0, 10.0))
|
||
.into()
|
||
}
|
||
|
||
/// Console section (always visible, larger).
|
||
fn section_console(state: &CorbelGui) -> Element<'_, Message> {
|
||
let chevron = if state.console_open { "-" } else { "+" };
|
||
let hdr = container(
|
||
button(
|
||
row![
|
||
t(format!("{} # CONSOLE", chevron), 12.0, colors::gold()),
|
||
]
|
||
.spacing(4.0)
|
||
.align_y(Alignment::Center),
|
||
)
|
||
.style(dim_btn_style())
|
||
.padding(pad(4.0, 8.0, 4.0, 8.0))
|
||
.on_press(Message::ToggleConsole),
|
||
)
|
||
.width(Length::Fill)
|
||
.style(section_hdr_style())
|
||
.padding(pad(0.0, 0.0, 0.0, 0.0));
|
||
|
||
let console_body: Element<'_, Message> = if state.console_open {
|
||
let mut lines_col = column![].spacing(2.0);
|
||
for line in &state.console_lines {
|
||
let tag_color = if line.is_error { colors::red() } else { colors::green() };
|
||
lines_col = lines_col.push(
|
||
row![
|
||
t(&line.timestamp, 11.0, colors::dim()),
|
||
t(" ", 11.0, colors::dim()),
|
||
t(format!("[{}]", line.tag), 11.0, tag_color),
|
||
t(format!(" {}", line.message), 11.0, colors::white()),
|
||
].spacing(0.0),
|
||
);
|
||
}
|
||
if state.console_lines.is_empty() {
|
||
lines_col = lines_col.push(
|
||
t("# ready.", 12.0, colors::dim()),
|
||
);
|
||
}
|
||
scrollable(lines_col)
|
||
.height(Length::Fill)
|
||
.width(Length::Fill)
|
||
.into()
|
||
} else {
|
||
column![].into()
|
||
};
|
||
|
||
column![hdr, console_body]
|
||
.spacing(0.0)
|
||
.width(Length::Fill)
|
||
.height(Length::FillPortion(3))
|
||
.into()
|
||
}
|
||
|
||
/// 280 px right sidebar.
|
||
fn build_right_sidebar(state: &CorbelGui) -> Element<'_, Message> {
|
||
let total = state.processed;
|
||
let gauge_text = build_gauge(total);
|
||
let processed_label = t(format!("{} PROCESSED", total), 11.0, colors::gold());
|
||
|
||
let stats = column![
|
||
stat_row("*", "CLEANED", state.cleaned, colors::gold()),
|
||
stat_row(">", "COPIED", state.copied, colors::green()),
|
||
stat_row("!", "ERRORS", state.errors, colors::red()),
|
||
]
|
||
.spacing(10.0)
|
||
.padding(pad(12.0, 14.0, 12.0, 14.0));
|
||
|
||
let in_display = if state.input_path.is_empty() { "(none)".to_string() } else { state.input_path.clone() };
|
||
let out_display = if state.output_path.is_empty() { "(cwd)".to_string() } else { state.output_path.clone() };
|
||
let mode = if state.preserve_format { "PreserveFormat" } else { "Markdown" };
|
||
|
||
let run_config = column![
|
||
t("RUN CONFIG", 11.0, colors::gold()),
|
||
t(format!("IN: {}", in_display), 10.0, colors::dim()),
|
||
t(format!("OUT: {}", out_display), 10.0, colors::dim()),
|
||
t(format!("MODE: {}", mode), 10.0, colors::dim()),
|
||
]
|
||
.spacing(4.0)
|
||
.padding(pad(8.0, 14.0, 12.0, 14.0));
|
||
|
||
let findings_block = if let Some(r) = &state.last_result {
|
||
let mut fcol = column![t("LAST SCAN", 11.0, colors::gold())].spacing(4.0);
|
||
for finding in &r.scan_report.findings {
|
||
let class_str = format!("{:?}", finding.classification);
|
||
let color = match finding.classification {
|
||
ThreatClassification::Malicious(_) => colors::red(),
|
||
ThreatClassification::Suspicious => colors::yellow(),
|
||
ThreatClassification::EducationalContent => colors::teal(),
|
||
ThreatClassification::Benign => colors::dim(),
|
||
};
|
||
fcol = fcol.push(row![
|
||
t("*", 10.0, color),
|
||
t(class_str, 9.0, color),
|
||
].spacing(4.0));
|
||
fcol = fcol.push(t(format!(" {}", finding.location), 9.0, colors::dim()));
|
||
// TODO #12: CVE badges — parse CVE tags from context_notes.
|
||
let cve_tags = extract_cve_tags(&finding.context_notes);
|
||
for tag in &cve_tags {
|
||
fcol = fcol.push(
|
||
container(t(tag, 8.0, Color::WHITE))
|
||
.padding(pad(1.0, 4.0, 1.0, 4.0))
|
||
.style(cve_badge_style()),
|
||
);
|
||
fcol = fcol.push(t(" ", 6.0, colors::dim()));
|
||
}
|
||
}
|
||
if r.scan_report.findings.is_empty() {
|
||
fcol = fcol.push(t("(no findings)", 10.0, colors::dim()));
|
||
}
|
||
container(fcol).padding(pad(8.0, 14.0, 8.0, 14.0)).width(Length::Fill)
|
||
} else {
|
||
container(column![]).width(Length::Fill)
|
||
};
|
||
|
||
let sidebar_col = column![
|
||
column![gauge_text, processed_label]
|
||
.spacing(4.0)
|
||
.align_x(Alignment::Center)
|
||
.padding(pad(16.0, 0.0, 8.0, 0.0)),
|
||
container("").height(1).width(Length::Fill).style(separator_style()),
|
||
stats,
|
||
container("").height(1).width(Length::Fill).style(separator_style()),
|
||
run_config,
|
||
container("").height(1).width(Length::Fill).style(separator_style()),
|
||
findings_block,
|
||
// TODO #4: EPUB/Markdown cleansed document viewer.
|
||
build_cleansed_viewer(state),
|
||
]
|
||
.spacing(0.0);
|
||
|
||
container(scrollable(sidebar_col))
|
||
.width(280)
|
||
.height(Length::Fill)
|
||
.style(panel_style())
|
||
.into()
|
||
}
|
||
|
||
/// 56 px footer.
|
||
fn build_footer(state: &CorbelGui) -> Element<'_, Message> {
|
||
let start_btn = button(
|
||
row![
|
||
t(">", 14.0, Color::BLACK),
|
||
t("START PROCESSING", 12.0, Color::BLACK),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center),
|
||
)
|
||
.style(gold_btn_style())
|
||
.padding(pad(8.0, 16.0, 8.0, 16.0))
|
||
.on_press_maybe(if state.is_processing { None } else { Some(Message::StartProcessing) });
|
||
|
||
let stop_btn = button(t("STOP", 12.0, colors::dim()))
|
||
.style(dim_btn_style())
|
||
.padding(pad(8.0, 16.0, 8.0, 16.0))
|
||
.on_press_maybe(if state.is_processing { Some(Message::StopProcessing) } else { None });
|
||
|
||
let clear_btn = button(
|
||
row![
|
||
t("x", 13.0, Color::BLACK),
|
||
t("CLEAR LOG", 12.0, Color::BLACK),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center),
|
||
)
|
||
.style(teal_btn_style())
|
||
.padding(pad(8.0, 16.0, 8.0, 16.0))
|
||
.on_press(Message::ClearLog);
|
||
|
||
let about_btn = button(
|
||
row![
|
||
t("i", 14.0, colors::dim()),
|
||
t("ABOUT / LICENSE", 12.0, colors::dim()),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center),
|
||
)
|
||
.style(dim_btn_style())
|
||
.padding(pad(8.0, 16.0, 8.0, 16.0))
|
||
.on_press(Message::ToggleAbout);
|
||
|
||
let buttons = row![start_btn, stop_btn, clear_btn, about_btn]
|
||
.spacing(8.0)
|
||
.align_y(Alignment::Center);
|
||
|
||
let status_left = t(
|
||
format!("CorbelPurge v{} - Defensive Document Research & Cleaning Tool", env!("CARGO_PKG_VERSION")),
|
||
10.0,
|
||
colors::dim(),
|
||
);
|
||
let status_right = t("GPL-3.0-or-later | Jeremy Anderson", 10.0, colors::dim());
|
||
|
||
let status_row = row![status_left, row![].width(Length::Fill), status_right]
|
||
.align_y(Alignment::Center);
|
||
|
||
let footer_col = column![buttons, status_row]
|
||
.spacing(4.0)
|
||
.padding(pad(6.0, 16.0, 6.0, 16.0));
|
||
|
||
container(footer_col)
|
||
.width(Length::Fill)
|
||
.height(56)
|
||
.center_y(Length::Shrink)
|
||
.style(footer_style())
|
||
.into()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reusable widget builders
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn collapsible_section<'a>(
|
||
label: &str,
|
||
is_open: bool,
|
||
toggle_msg: Message,
|
||
content: Element<'a, Message>,
|
||
) -> Element<'a, Message> {
|
||
let chevron = if is_open { "-" } else { "+" };
|
||
let display_label = format!("{} {}", chevron, label);
|
||
|
||
let hdr = container(
|
||
button(t(&display_label, 12.0, colors::gold()))
|
||
.style(dim_btn_style())
|
||
.padding(pad(4.0, 8.0, 4.0, 8.0))
|
||
.on_press(toggle_msg),
|
||
)
|
||
.width(Length::Fill)
|
||
.style(section_hdr_style())
|
||
.padding(pad(0.0, 0.0, 0.0, 0.0));
|
||
|
||
if is_open {
|
||
column![hdr, content].spacing(0.0).width(Length::Fill).into()
|
||
} else {
|
||
column![hdr].width(Length::Fill).into()
|
||
}
|
||
}
|
||
|
||
fn stat_row(icon: &str, label: &str, count: u32, color: Color) -> Element<'static, Message> {
|
||
row![
|
||
t(icon, 16.0, color),
|
||
t(label, 11.0, colors::dim()),
|
||
row![].width(Length::Fill),
|
||
t(count.to_string(), 18.0, color),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center)
|
||
.into()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// EPUB / Markdown cleansed document viewer (TODO #4 + #5)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn build_cleansed_viewer(state: &CorbelGui) -> Element<'_, Message> {
|
||
let content = match &state.cleansed_content {
|
||
Some(c) => c,
|
||
None => return container(column![]).into(),
|
||
};
|
||
|
||
// Simple syntax highlighting for fenced code blocks (TODO #5).
|
||
// We highlight code block backgrounds with a darker panel.
|
||
let mut highlighted = String::new();
|
||
let mut in_code_block = false;
|
||
for line in content.lines() {
|
||
if line.starts_with("```") {
|
||
in_code_block = !in_code_block;
|
||
// Keep the fence line as-is.
|
||
highlighted.push_str(line);
|
||
highlighted.push('\n');
|
||
continue;
|
||
}
|
||
if in_code_block {
|
||
// Prefix code lines with a marker that the console-style
|
||
// dark background already provides visual distinction.
|
||
highlighted.push_str(line);
|
||
highlighted.push('\n');
|
||
} else {
|
||
// Regular Markdown text — pass through.
|
||
highlighted.push_str(line);
|
||
highlighted.push('\n');
|
||
}
|
||
}
|
||
|
||
let viewer_content = scrollable(
|
||
column![
|
||
t("CLEANSED DOCUMENT", 11.0, colors::gold()),
|
||
Space::with_height(4),
|
||
t(highlighted, 11.0, colors::white()),
|
||
]
|
||
.spacing(2.0)
|
||
)
|
||
.height(Length::FillPortion(4));
|
||
|
||
container(viewer_content)
|
||
.padding(pad(8.0, 10.0, 8.0, 10.0))
|
||
.width(Length::Fill)
|
||
.into()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// About / License overlay
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Floating info panel anchored to the top-right corner, modelled on the
|
||
// ferret about-panel screenshot. Visual style: dark panel, gold border,
|
||
// drop shadow, close (X) button in the top-right, structured metadata
|
||
// (title, version, description, author, website, license, tech stack,
|
||
// copyright).
|
||
//
|
||
// Implementation note: iced 0.13 has no Stack widget (true non-modal
|
||
// overlays landed in 0.14), so we emulate the floating-panel feel with
|
||
// a full-window dim backdrop. The panel sits in the top-right corner via
|
||
// a row + Space::Fill layout. Close is via the X button in the panel
|
||
// header; click-outside-to-close and ESC handling are left as future
|
||
// enhancements (would require upgrading to iced 0.14+ or adding a
|
||
// keyboard subscription).
|
||
|
||
fn build_about_panel() -> Element<'static, Message> {
|
||
// --- Header row: title + version ... [X] ---
|
||
let title_row = row![
|
||
t("CORBELPURGE", 18.0, colors::gold()),
|
||
t(format!(" v{}", env!("CARGO_PKG_VERSION")), 11.0, colors::dim()),
|
||
row![].width(Length::Fill),
|
||
button(t("X", 11.0, colors::gold()))
|
||
.style(about_close_btn_style())
|
||
.padding(pad(2.0, 6.0, 2.0, 6.0))
|
||
.on_press(Message::CloseAbout),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center);
|
||
|
||
// --- Description ---
|
||
let description = t(
|
||
"Strict Rust document sanitizer & threat neutralizer\nfor PDF, EPUB, Markdown, and DOCX.",
|
||
11.0,
|
||
colors::white(),
|
||
);
|
||
|
||
// --- Metadata rows ---
|
||
let meta_row = |label: &str, value: &str, value_color: Color| -> Element<'static, Message> {
|
||
row![
|
||
t(format!("{}:", label), 10.0, colors::dim()),
|
||
t(value.to_string(), 10.0, value_color),
|
||
]
|
||
.spacing(6.0)
|
||
.align_y(Alignment::Center)
|
||
.into()
|
||
};
|
||
|
||
let author_row = meta_row("Author", "Jeremy Anderson", colors::white());
|
||
let website_row = meta_row("Website", env!("CARGO_PKG_REPOSITORY"), colors::gold());
|
||
let license_row = meta_row("License", env!("CARGO_PKG_LICENSE"), colors::white());
|
||
|
||
// --- Separator (thin horizontal rule) ---
|
||
let separator = container(Space::new(Length::Fill, 1.0))
|
||
.width(Length::Fill)
|
||
.style(separator_style());
|
||
|
||
// --- Footer: tech stack + copyright ---
|
||
let tech_stack = t(
|
||
"Built with Rust, iced 0.13, lopdf, pulldown-cmark, and zip.",
|
||
9.0,
|
||
colors::dim(),
|
||
);
|
||
let copyright = t(
|
||
"Copyright (c) 2026 Jeremy Anderson.",
|
||
9.0,
|
||
colors::dim(),
|
||
);
|
||
|
||
// --- Assemble panel ---
|
||
let panel_body = column![
|
||
title_row,
|
||
Space::with_height(8),
|
||
description,
|
||
Space::with_height(12),
|
||
author_row,
|
||
website_row,
|
||
license_row,
|
||
Space::with_height(10),
|
||
separator,
|
||
Space::with_height(8),
|
||
tech_stack,
|
||
copyright,
|
||
]
|
||
.spacing(2.0)
|
||
.width(320);
|
||
|
||
container(panel_body)
|
||
.style(about_panel_style())
|
||
.padding(pad(16.0, 18.0, 16.0, 18.0))
|
||
.width(320)
|
||
.into()
|
||
}
|
||
|
||
/// Full-window overlay: dim backdrop + About panel anchored to the top-right
|
||
/// corner.
|
||
///
|
||
/// Click-outside-to-close is sacrificed here because iced 0.13 lacks a true
|
||
/// Stack widget (added in 0.14) and wrapping the panel in a no-op button
|
||
/// would make the panel's own labels close the modal on click. The X button
|
||
/// in the panel header remains the close affordance; ESC handling can be
|
||
/// added later via a keyboard subscription.
|
||
fn build_about_overlay() -> Element<'static, Message> {
|
||
let layout = row![
|
||
Space::with_width(Length::Fill),
|
||
column![
|
||
Space::with_height(20.0),
|
||
build_about_panel(),
|
||
Space::with_height(Length::Fill),
|
||
]
|
||
.width(320)
|
||
.height(Length::Fill),
|
||
Space::with_width(20.0),
|
||
]
|
||
.align_y(Alignment::Start)
|
||
.height(Length::Fill)
|
||
.width(Length::Fill);
|
||
|
||
container(layout)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.style(about_dim_style())
|
||
.into()
|
||
}
|
||
|
||
fn build_gauge(processed: u32) -> Element<'static, Message> {
|
||
let bar_width = 16usize;
|
||
let filled = ((processed as usize).min(20) * bar_width) / 20;
|
||
let empty = bar_width - filled;
|
||
let bar_str = format!("{}{}", "#".repeat(filled), "-".repeat(empty));
|
||
column![
|
||
t(processed.to_string(), 36.0, colors::gold()),
|
||
t(bar_str, 10.0, colors::gold()),
|
||
]
|
||
.align_x(Alignment::Center)
|
||
.spacing(4.0)
|
||
.into()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Background tasks
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async fn pick_file() -> Option<PathBuf> {
|
||
rfd::AsyncFileDialog::new()
|
||
.add_filter("Documents", &["pdf", "epub", "md", "markdown", "docx"])
|
||
.set_title("Select input file")
|
||
.pick_file()
|
||
.await
|
||
.map(|h| h.path().to_path_buf())
|
||
}
|
||
|
||
async fn pick_folder() -> Option<PathBuf> {
|
||
rfd::AsyncFileDialog::new()
|
||
.set_title("Select output directory")
|
||
.pick_folder()
|
||
.await
|
||
.map(|h| h.path().to_path_buf())
|
||
}
|
||
|
||
async fn run_pipeline(
|
||
path: PathBuf,
|
||
workspace: Option<PathBuf>,
|
||
abort_on_threat: bool,
|
||
preserve_format: bool,
|
||
) -> Result<PipelineResult, String> {
|
||
let mut config = match workspace {
|
||
Some(p) => Config::with_workspace(p),
|
||
None => Config::default(),
|
||
};
|
||
config.abort_on_threat = abort_on_threat;
|
||
if preserve_format {
|
||
config.cleanse_mode = corbel_purge::CleanseMode::PreserveFormat;
|
||
}
|
||
let pipeline = Pipeline::with_config(config);
|
||
tokio::task::spawn_blocking(move || pipeline.run(&path))
|
||
.await
|
||
.map_err(|e| format!("task join error: {e}"))?
|
||
.map_err(|e| e.to_string())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CVE badge helper (TODO #12)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Extract CVE tags (e.g. `[CVE-2017-11882: Equation Editor RCE]`)
|
||
/// from a context_notes string.
|
||
fn extract_cve_tags(notes: &str) -> Vec<String> {
|
||
let mut tags = Vec::new();
|
||
let mut search_from = 0;
|
||
while let Some(start) = notes[search_from..].find('[') {
|
||
let rest = ¬es[search_from + start + 1..];
|
||
if let Some(end) = rest.find(']') {
|
||
let tag = rest[..end].trim();
|
||
if tag.starts_with("CVE-") || tag.starts_with("EPUB-") {
|
||
// Truncate long descriptions to just the CVE ID.
|
||
let short = tag.split(':').next().unwrap_or(tag);
|
||
tags.push(short.to_string());
|
||
}
|
||
search_from += start + 1 + end + 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
tags
|
||
}
|
||
|
||
/// Style for CVE badge containers.
|
||
fn cve_badge_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::teal().into()),
|
||
border: Border { color: colors::teal(), width: 1.0, radius: 3.0.into() },
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Dimmed backdrop behind the About overlay. Subtle alpha so the underlying
|
||
/// UI is still partially visible — matches the "floating panel" feel of the
|
||
/// ferret reference without requiring iced's Stack widget (added in 0.14).
|
||
fn about_dim_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.55).into()),
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Style for the About panel itself: dark panel background, gold border,
|
||
/// rounded corners — mirrors the ferret about-panel aesthetic.
|
||
fn about_panel_style() -> impl Fn(&Theme) -> iced::widget::container::Style {
|
||
|_t: &Theme| iced::widget::container::Style {
|
||
background: Some(colors::panel().into()),
|
||
border: Border { color: colors::gold(), width: 1.5, radius: 6.0.into() },
|
||
shadow: iced::Shadow {
|
||
color: Color::from_rgba(0.0, 0.0, 0.0, 0.6),
|
||
offset: iced::Vector::new(0.0, 4.0),
|
||
blur_radius: 12.0,
|
||
},
|
||
..iced::widget::container::Style::default()
|
||
}
|
||
}
|
||
|
||
/// Style for the small close (X) button in the About panel header.
|
||
fn about_close_btn_style() -> impl Fn(&Theme, iced::widget::button::Status) -> ButtonStyle {
|
||
move |_t: &Theme, _s: iced::widget::button::Status| ButtonStyle {
|
||
background: Some(colors::panel_hi().into()),
|
||
text_color: colors::gold(),
|
||
border: Border { color: colors::gold(), width: 1.0, radius: 3.0.into() },
|
||
..ButtonStyle::default()
|
||
}
|
||
}
|
||
|
||
// Note: a `about_backdrop_btn_style` (transparent, click-outside-to-close
|
||
// backdrop) was originally drafted for this overlay but is omitted because
|
||
// iced 0.13 cannot layer a button under a sibling widget without making
|
||
// the sibling's own clicks bubble up. Click-outside-to-close can be added
|
||
// later by upgrading to iced 0.14+ (which has a Stack widget) or by
|
||
// adding a keyboard subscription for the Escape key.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn now_hms() -> String {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||
let h = (secs / 3600) % 24;
|
||
let m = (secs / 60) % 60;
|
||
let s = secs % 60;
|
||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||
}
|
||
|
||
fn open_dir(path: &std::path::Path) {
|
||
#[cfg(target_os = "linux")] { let _ = std::process::Command::new("xdg-open").arg(path).spawn(); }
|
||
#[cfg(target_os = "macos")] { let _ = std::process::Command::new("open").arg(path).spawn(); }
|
||
#[cfg(target_os = "windows")] { let _ = std::process::Command::new("explorer").arg(path).spawn(); }
|
||
} |