malwaredb 0.3.5

Service for storing malicious, benign, or unknown files and related metadata and relationships.
// SPDX-License-Identifier: Apache-2.0

use malwaredb_server::State;
use malwaredb_server::db::admin::Source;

use std::collections::HashSet;
use std::sync::Arc;

use anyhow::Result;
use eframe::egui::{self, Id};

const COLUMN_HEADERS: [&str; 7] = [
    "ID",
    "Name",
    "Description",
    "Parent",
    "URL",
    "Files",
    "Groups",
];

/// Sources table
#[derive(Debug, Clone)]
pub struct SourceTable {
    #[allow(dead_code)]
    state: Arc<State>,

    /// Committed baseline; Cancel reverts draft to this.
    sources: Vec<Source>,

    /// Live edits displayed in the table cells.
    draft: Vec<Source>,

    /// IDs of sources that have unsaved changes, for use by the save operation.
    changed_ids: HashSet<usize>,
}

impl SourceTable {
    pub async fn new(state: Arc<State>) -> Result<Self> {
        let sources = state.db_type.list_sources().await?;
        let draft = sources.clone();
        Ok(Self {
            state,
            sources,
            draft,
            changed_ids: HashSet::new(),
        })
    }

    fn all_valid(&self) -> bool {
        self.draft
            .iter()
            .all(|s| is_valid_name(&s.name) && s.url.as_deref().is_none_or(is_valid_url))
    }

    pub fn ui(&mut self, ui: &mut egui::Ui) {
        if !self.changed_ids.is_empty() {
            ui.horizontal(|ui| {
                if ui
                    .add_enabled(self.all_valid(), egui::Button::new("Save"))
                    .clicked()
                {
                    // TODO: persist to database
                    eprintln!("Saving source information to database not yet implemented");
                    self.sources.clone_from(&self.draft);
                    self.changed_ids.clear();
                }
                if ui.button("Cancel").clicked() {
                    self.draft.clone_from(&self.sources);
                    self.changed_ids.clear();
                }
            });
        }

        let id_salt = Id::new("source_table");
        let table = egui_table::Table::new()
            .id_salt(id_salt)
            .auto_size_mode(egui_table::AutoSizeMode::OnParentResize)
            .num_rows(self.draft.len() as u64)
            .columns(
                (0..COLUMN_HEADERS.len())
                    .map(|_| egui_table::Column::new(100.0).resizable(true))
                    .collect::<Vec<_>>(),
            );
        table.show(ui, self);
    }
}

/// Name may only contain ASCII alphanumeric characters, hyphens, and underscores.
fn is_valid_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}

/// URL must begin with http:// or https://.
fn is_valid_url(url: &str) -> bool {
    url.starts_with("http://") || url.starts_with("https://")
}

/// Returns a static error message for the given column if the field is invalid.
fn field_error(source: &Source, col_nr: usize) -> Option<&'static str> {
    match col_nr {
        1 if source.name.is_empty() => Some("Name cannot be blank"),
        1 if !is_valid_name(&source.name) => {
            Some("Name may only contain letters, digits, hyphens, and underscores")
        }
        4 if source.url.as_deref().is_some_and(|u| !is_valid_url(u)) => {
            Some("URL must start with http:// or https://")
        }
        _ => None,
    }
}

impl egui_table::TableDelegate for SourceTable {
    fn header_cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::HeaderCellInfo) {
        if let Some(header) = COLUMN_HEADERS.get(cell.group_index) {
            ui.label(*header);
        }
    }

    fn cell_ui(&mut self, ui: &mut egui::Ui, cell: &egui_table::CellInfo) {
        let row = match usize::try_from(cell.row_nr).ok() {
            Some(r) if r < self.draft.len() => r,
            _ => return,
        };

        // Collect parent name options before taking a mutable borrow on the row.
        let parent_options: Vec<String> = self
            .draft
            .iter()
            .enumerate()
            .filter(|(i, _)| *i != row)
            .map(|(_, s)| s.name.clone())
            .collect();

        let error = field_error(&self.draft[row], cell.col_nr);
        let changed = {
            let source = &mut self.draft[row];
            match cell.col_nr {
                // Read-only columns
                0 => {
                    ui.label(source.id.to_string());
                    false
                }
                5 => {
                    ui.label(source.files.to_string());
                    false
                }
                6 => {
                    ui.label(source.groups.to_string());
                    false
                }

                // Name: no spaces or symbols
                1 => {
                    if error.is_some() {
                        ui.style_mut().visuals.extreme_bg_color = super::INVALID_BG;
                    }
                    let response = ui.text_edit_singleline(&mut source.name);
                    let changed = response.changed();
                    if let Some(msg) = error {
                        response.on_hover_text(msg);
                    }
                    changed
                }

                // Description: optional free text
                2 => {
                    let mut text = source.description.clone().unwrap_or_default();
                    let changed = ui.text_edit_singleline(&mut text).changed();
                    if changed {
                        source.description = if text.is_empty() { None } else { Some(text) };
                    }
                    changed
                }

                // Parent: dropdown of other source names, with a "(none)" option
                3 => {
                    let selected = source.parent.as_deref().unwrap_or("(none)");
                    let inner = egui::ComboBox::from_id_salt(("source_parent", row))
                        .selected_text(selected)
                        .show_ui(ui, |ui| {
                            let mut changed = ui
                                .selectable_value(&mut source.parent, None, "(none)")
                                .changed();
                            for name in &parent_options {
                                changed |= ui
                                    .selectable_value(
                                        &mut source.parent,
                                        Some(name.clone()),
                                        name.as_str(),
                                    )
                                    .changed();
                            }
                            changed
                        });
                    inner.inner.unwrap_or(false)
                }

                // URL: optional, but must start with http:// or https:// if provided
                4 => {
                    if error.is_some() {
                        ui.style_mut().visuals.extreme_bg_color = super::INVALID_BG;
                    }
                    let mut text = source.url.clone().unwrap_or_default();
                    let response = ui.text_edit_singleline(&mut text);
                    let changed = response.changed();
                    if changed {
                        source.url = if text.is_empty() { None } else { Some(text) };
                    }
                    if let Some(msg) = error {
                        response.on_hover_text(msg);
                    }
                    changed
                }

                _ => false,
            }
        };

        if changed {
            self.changed_ids.insert(row);
        } else if self.draft[row] == self.sources[row] {
            self.changed_ids.remove(&row);
        }
    }
}