assay-cli 3.26.0

Policy-as-code gate for MCP agent tool calls, with verifiable evidence and Linux kernel enforcement.
use std::collections::{BTreeMap, BTreeSet};
use std::hash::{DefaultHasher, Hasher};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use super::MAX_SNAPSHOT_HASH_BYTES;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct FileSnapshot {
    pub(super) exists: bool,
    pub(super) len: Option<u64>,
    pub(super) modified: Option<SystemTime>,
    pub(super) content_hash: Option<u64>,
}

pub(super) fn snapshot_paths(paths: &[PathBuf]) -> BTreeMap<PathBuf, FileSnapshot> {
    let mut out = BTreeMap::new();
    for path in paths {
        let snapshot = match std::fs::metadata(path) {
            Ok(meta) => {
                let len = meta.len();
                FileSnapshot {
                    exists: true,
                    len: Some(len),
                    modified: meta.modified().ok(),
                    content_hash: snapshot_content_hash(path, len),
                }
            }
            Err(_) => FileSnapshot {
                exists: false,
                len: None,
                modified: None,
                content_hash: None,
            },
        };
        out.insert(path.clone(), snapshot);
    }
    out
}

fn snapshot_content_hash(path: &Path, len: u64) -> Option<u64> {
    if len > MAX_SNAPSHOT_HASH_BYTES {
        return None;
    }

    let file = std::fs::File::open(path).ok()?;
    let mut reader = file.take(MAX_SNAPSHOT_HASH_BYTES + 1);
    let mut bytes = Vec::new();
    reader.read_to_end(&mut bytes).ok()?;
    if bytes.len() as u64 > MAX_SNAPSHOT_HASH_BYTES {
        return None;
    }

    let mut hasher = DefaultHasher::new();
    hasher.write(&bytes);
    Some(hasher.finish())
}

pub(super) fn diff_paths(
    prev: &BTreeMap<PathBuf, FileSnapshot>,
    curr: &BTreeMap<PathBuf, FileSnapshot>,
) -> Vec<PathBuf> {
    let mut changed = Vec::new();

    let all_paths: BTreeSet<PathBuf> = prev.keys().chain(curr.keys()).cloned().collect();
    for path in all_paths {
        let prev_state = prev.get(&path);
        let curr_state = curr.get(&path);
        if prev_state != curr_state {
            changed.push(path);
        }
    }

    changed
}

pub(super) fn coalesce_changed_paths(changed: &mut Vec<PathBuf>) {
    changed.sort();
    changed.dedup();
}