runglass-core 0.3.0

Core command observation, reporting, storage, and revert logic for RunGlass.
Documentation
use std::collections::BTreeSet;

use crate::storage::slugify;
use crate::{
    DockerSummary, FileChange, FileChangeType, NetworkDirection, NetworkEvent, RiskEvidence,
    RiskLevel, RiskNote, RunStatus, Severity, Summary,
};

pub(crate) fn empty_summary() -> Summary {
    Summary {
        files_changed: 0,
        files_created: 0,
        files_modified: 0,
        files_deleted: 0,
        processes_seen: 0,
        network_hosts: 0,
        ports_opened: 0,
        docker_containers_created: 0,
        docker_images_pulled: 0,
        docker_volumes_created: 0,
        risk_level: RiskLevel::None,
    }
}

pub(crate) fn build_summary(
    files: &[FileChange],
    processes_seen: usize,
    network: &[NetworkEvent],
    docker: Option<&DockerSummary>,
    risk_level: RiskLevel,
) -> Summary {
    Summary {
        files_changed: files.len(),
        files_created: files
            .iter()
            .filter(|file| matches!(file.change_type, FileChangeType::Created))
            .count(),
        files_modified: files
            .iter()
            .filter(|file| matches!(file.change_type, FileChangeType::Modified))
            .count(),
        files_deleted: files
            .iter()
            .filter(|file| matches!(file.change_type, FileChangeType::Deleted))
            .count(),
        processes_seen,
        network_hosts: unique_hosts(network),
        ports_opened: network
            .iter()
            .filter(|event| matches!(event.direction, NetworkDirection::Listening))
            .count(),
        docker_containers_created: docker
            .map(|docker| docker.containers_created.len())
            .unwrap_or(0),
        docker_images_pulled: docker.map(|docker| docker.images_pulled.len()).unwrap_or(0),
        docker_volumes_created: docker
            .map(|docker| docker.volumes_created.len())
            .unwrap_or(0),
        risk_level,
    }
}

pub(crate) fn derive_risk_level(risks: &[RiskNote], files: &[FileChange]) -> RiskLevel {
    if risks
        .iter()
        .any(|risk| matches!(risk.severity, Severity::Danger))
    {
        RiskLevel::High
    } else if risks
        .iter()
        .any(|risk| matches!(risk.severity, Severity::Warning))
    {
        RiskLevel::Medium
    } else if !files.is_empty() {
        RiskLevel::Low
    } else {
        RiskLevel::None
    }
}

pub(crate) fn derive_risks(
    files: &[FileChange],
    network: &[NetworkEvent],
    docker: Option<&DockerSummary>,
    run_status: &RunStatus,
    exit_code: Option<i32>,
) -> Vec<RiskNote> {
    let mut risks = risks_from_files(files);
    if matches!(run_status, RunStatus::Interrupted) {
        risks.push(RiskNote {
            id: "command-interrupted".to_string(),
            title: "Command was interrupted".to_string(),
            detail: "The command stopped before it completed, so this receipt only reflects partial side effects.".to_string(),
            severity: Severity::Warning,
            evidence: vec![RiskEvidence {
                kind: "exit_code".to_string(),
                value: exit_code.unwrap_or(-1).to_string(),
                path: None,
                event_id: None,
            }],
            recommendation: Some(
                "Review partial file, process, network, and Docker effects before retrying."
                    .to_string(),
            ),
        });
    } else if !matches!(run_status, RunStatus::Running) && exit_code.is_some_and(|code| code != 0) {
        risks.push(RiskNote {
            id: "command-failed".to_string(),
            title: "Command exited nonzero".to_string(),
            detail: "The command returned a nonzero exit code. It may have applied some side effects before failing.".to_string(),
            severity: Severity::Warning,
            evidence: vec![RiskEvidence {
                kind: "exit_code".to_string(),
                value: exit_code.unwrap_or(-1).to_string(),
                path: None,
                event_id: None,
            }],
            recommendation: Some(
                "Inspect stdout, stderr, and the receipt before rerunning the command."
                    .to_string(),
            ),
        });
    }

    if network.iter().any(|event| {
        matches!(event.direction, NetworkDirection::Listening)
            && matches!(event.ip.as_str(), "0.0.0.0" | "::")
    }) {
        risks.push(RiskNote {
            id: "public-port-observed".to_string(),
            title: "Observed listening port on a public interface".to_string(),
            detail: "A process in the observed command tree listened on 0.0.0.0 or :: during the receipt window."
                .to_string(),
            severity: Severity::Warning,
            evidence: network
                .iter()
                .filter(|event| {
                    matches!(event.direction, NetworkDirection::Listening)
                        && matches!(event.ip.as_str(), "0.0.0.0" | "::")
                })
                .map(|event| RiskEvidence {
                    kind: "network_port".to_string(),
                    value: format!("{}:{}/{}", event.ip, event.port, event.protocol),
                    path: None,
                    event_id: None,
                })
                .collect(),
            recommendation: Some(
                "Confirm whether this listening socket should be reachable outside localhost."
                    .to_string(),
            ),
        });
    }

    if let Some(docker) = docker {
        if !docker.ports_published.is_empty() {
            let evidence: Vec<RiskEvidence> = docker
                .ports_published
                .iter()
                .filter(|port| matches!(port.host_ip.as_str(), "0.0.0.0" | "::" | ""))
                .map(|port| RiskEvidence {
                    kind: "docker_port".to_string(),
                    value: format!(
                        "{}:{}->{}/{}",
                        if port.host_ip.is_empty() {
                            "0.0.0.0"
                        } else {
                            port.host_ip.as_str()
                        },
                        port.host_port,
                        port.container_port,
                        port.protocol
                    ),
                    path: None,
                    event_id: None,
                })
                .collect();
            if !evidence.is_empty() {
                risks.push(RiskNote {
                    id: "docker-public-port".to_string(),
                    title: "Docker published a public port".to_string(),
                    detail:
                        "A Docker change exposed a container port on a host interface beyond localhost."
                            .to_string(),
                    severity: Severity::Warning,
                    evidence,
                    recommendation: Some(
                        "Confirm the published port should be reachable outside localhost."
                            .to_string(),
                    ),
                });
            }
        }
    }

    risks
}

pub fn unique_hosts(events: &[NetworkEvent]) -> usize {
    let mut hosts = BTreeSet::new();
    for event in events {
        if !matches!(event.direction, NetworkDirection::Outbound) {
            continue;
        }
        hosts.insert(
            event
                .host
                .as_deref()
                .unwrap_or(event.ip.as_str())
                .to_string(),
        );
    }
    hosts.len()
}

pub(crate) fn risk_tags(path: &str, executable: bool) -> Vec<String> {
    let mut tags = Vec::new();
    let lower = path.to_ascii_lowercase();

    if lower.ends_with(".zshrc")
        || lower.ends_with(".bashrc")
        || lower.ends_with(".profile")
        || lower.contains("config.fish")
    {
        tags.push("shell-startup".to_string());
    }
    if lower.contains(".config") || lower.ends_with(".toml") || lower.ends_with(".service") {
        tags.push("config".to_string());
    }
    if lower.contains(".ssh") || lower.contains(".env") || lower.contains("credentials") {
        tags.push("sensitive".to_string());
    }
    if executable || lower.contains("/bin/") || lower.starts_with("bin/") {
        tags.push("executable".to_string());
    }

    tags
}

fn risks_from_files(files: &[FileChange]) -> Vec<RiskNote> {
    let mut risks = Vec::new();
    for file in files {
        if file.risk_tags.iter().any(|tag| tag == "shell-startup") {
            risks.push(RiskNote {
                id: format!("shell-startup-{}", slugify(&file.path)),
                title: "Shell startup file changed".to_string(),
                detail: "This file can run code whenever a new interactive shell session starts."
                    .to_string(),
                severity: Severity::Warning,
                evidence: vec![RiskEvidence {
                    kind: "file_change".to_string(),
                    value: file.path.clone(),
                    path: Some(file.path.clone()),
                    event_id: None,
                }],
                recommendation: Some(
                    "Review the diff carefully before keeping this startup-file change."
                        .to_string(),
                ),
            });
        }

        if file.risk_tags.iter().any(|tag| tag == "executable")
            && matches!(file.change_type, FileChangeType::Created)
        {
            risks.push(RiskNote {
                id: format!("executable-created-{}", slugify(&file.path)),
                title: "Created executable-like file".to_string(),
                detail: "A new executable or script-like file appeared during the receipt window."
                    .to_string(),
                severity: Severity::Warning,
                evidence: vec![RiskEvidence {
                    kind: "file_change".to_string(),
                    value: file.path.clone(),
                    path: Some(file.path.clone()),
                    event_id: None,
                }],
                recommendation: Some(
                    "Confirm this binary or script should be trusted before keeping it."
                        .to_string(),
                ),
            });
        }
    }
    risks
}

#[cfg(test)]
mod tests {
    use chrono::Utc;

    use super::{derive_risks, unique_hosts};
    use crate::{
        DockerPortChange, DockerSummary, FileChange, FileChangeType, NetworkDirection,
        NetworkEvent, RunStatus,
    };

    #[test]
    fn unique_hosts_counts_only_outbound_hosts() {
        let now = Utc::now();
        let events = vec![
            NetworkEvent {
                host: Some("registry.npmjs.org".to_string()),
                ip: "104.16.0.0".to_string(),
                port: 443,
                protocol: "tcp".to_string(),
                pid: Some(1),
                process_name: Some("npm".to_string()),
                first_seen: now,
                last_seen: now,
                count: 2,
                direction: NetworkDirection::Outbound,
            },
            NetworkEvent {
                host: Some("registry.npmjs.org".to_string()),
                ip: "104.16.0.1".to_string(),
                port: 443,
                protocol: "tcp".to_string(),
                pid: Some(1),
                process_name: Some("npm".to_string()),
                first_seen: now,
                last_seen: now,
                count: 1,
                direction: NetworkDirection::Outbound,
            },
            NetworkEvent {
                host: None,
                ip: "0.0.0.0".to_string(),
                port: 3000,
                protocol: "tcp".to_string(),
                pid: Some(2),
                process_name: Some("node".to_string()),
                first_seen: now,
                last_seen: now,
                count: 1,
                direction: NetworkDirection::Listening,
            },
        ];

        assert_eq!(unique_hosts(&events), 1);
    }

    #[test]
    fn derive_risks_flags_public_ports_and_failed_commands() {
        let now = Utc::now();
        let risks = derive_risks(
            &[FileChange {
                path: "bin/install.sh".to_string(),
                change_type: FileChangeType::Created,
                before_hash: None,
                after_hash: Some("after".to_string()),
                before_size: None,
                after_size: Some(12),
                is_text: true,
                diff: None,
                risk_tags: vec!["executable".to_string()],
                before_artifact_path: None,
                after_artifact_path: None,
                before_executable: None,
                after_executable: Some(true),
            }],
            &[NetworkEvent {
                host: None,
                ip: "0.0.0.0".to_string(),
                port: 8080,
                protocol: "tcp".to_string(),
                pid: Some(22),
                process_name: Some("docker-proxy".to_string()),
                first_seen: now,
                last_seen: now,
                count: 1,
                direction: NetworkDirection::Listening,
            }],
            Some(&DockerSummary {
                containers_created: Vec::new(),
                containers_removed: Vec::new(),
                containers_changed: Vec::new(),
                images_pulled: Vec::new(),
                volumes_created: Vec::new(),
                networks_created: Vec::new(),
                ports_published: vec![DockerPortChange {
                    host_ip: "0.0.0.0".to_string(),
                    host_port: 8080,
                    container_port: 8080,
                    protocol: "tcp".to_string(),
                }],
            }),
            &RunStatus::Completed,
            Some(1),
        );

        assert!(risks.iter().any(|risk| risk.id == "command-failed"));
        assert!(risks.iter().any(|risk| risk.id == "public-port-observed"));
        assert!(risks.iter().any(|risk| risk.id == "docker-public-port"));
        assert!(risks
            .iter()
            .any(|risk| risk.id.starts_with("executable-created-")));
    }
}