nono-cli 0.64.0

CLI for nono capability-based sandbox
use crate::audit_session::audit_root;
use crate::state_paths;
use nix::fcntl::{Flock, FlockArg};
use nono::audit::{
    LedgerRecord, LedgerVerificationResult, append_session_to_ledger_file,
    missing_ledger_verification_result, validate_ledger_session_id,
    verify_session_in_ledger_reader,
};
use nono::undo::SessionMetadata;
use nono::{NonoError, Result};
use std::fs::OpenOptions;
use std::io::BufReader;
use std::path::{Path, PathBuf};

const AUDIT_LEDGER_FILENAME: &str = "ledger.ndjson";
const AUDIT_LEDGER_LOCK_FILENAME: &str = "ledger.lock";

pub(crate) fn append_session(metadata: &SessionMetadata) -> Result<LedgerRecord> {
    validate_ledger_session_id(&metadata.session_id)?;

    let root = audit_root()?;
    std::fs::create_dir_all(&root).map_err(|e| {
        NonoError::Snapshot(format!(
            "Failed to create audit root {}: {e}",
            root.display()
        ))
    })?;

    let path = root.join(AUDIT_LEDGER_FILENAME);
    let _lock = LedgerLock::acquire(root.join(AUDIT_LEDGER_LOCK_FILENAME))?;
    state_paths::maybe_migrate_legacy_audit_ledger()?;
    let mut file = OpenOptions::new()
        .create(true)
        .read(true)
        .write(true)
        .truncate(false)
        .open(&path)
        .map_err(|e| {
            NonoError::Snapshot(format!(
                "Failed to open audit ledger {}: {e}",
                path.display()
            ))
        })?;
    append_session_to_ledger_file(&mut file, metadata)
}

pub(crate) fn verify_session_in_ledger(
    metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
    let primary = audit_root()?;
    let result = verify_session_in_ledger_at_root(&primary, metadata)?;
    if result.session_found {
        return Ok(result);
    }

    if let Ok(legacy) = state_paths::legacy_audit_root()
        && legacy != primary
    {
        let legacy_ledger = legacy.join(AUDIT_LEDGER_FILENAME);
        if legacy_ledger.exists() {
            state_paths::warn_legacy_audit_path(&legacy);
            return verify_session_in_ledger_at_root(&legacy, metadata);
        }
    }

    Ok(result)
}

fn verify_session_in_ledger_at_root(
    root: &Path,
    metadata: &SessionMetadata,
) -> Result<LedgerVerificationResult> {
    let path = root.join(AUDIT_LEDGER_FILENAME);
    if !path.exists() {
        return missing_ledger_verification_result(metadata);
    }

    let file = OpenOptions::new().read(true).open(&path).map_err(|e| {
        NonoError::Snapshot(format!(
            "Failed to open audit ledger {}: {e}",
            path.display()
        ))
    })?;
    verify_session_in_ledger_reader(BufReader::new(file), metadata)
}

struct LedgerLock {
    _file: Flock<std::fs::File>,
}

impl LedgerLock {
    fn acquire(path: PathBuf) -> Result<Self> {
        let file = OpenOptions::new()
            .create(true)
            .read(true)
            .write(true)
            .truncate(false)
            .open(&path)
            .map_err(|e| {
                NonoError::Snapshot(format!(
                    "Failed to open audit ledger lock {}: {e}",
                    path.display()
                ))
            })?;
        let file = Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| {
            NonoError::Snapshot(format!(
                "Failed to acquire audit ledger lock {}: {e}",
                path.display()
            ))
        })?;
        Ok(Self { _file: file })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::test_env::{ENV_LOCK, EnvVarGuard};
    use nono::audit::compute_session_digest;
    use nono::undo::{
        AuditAttestationSummary, AuditIntegritySummary, ContentHash, ExecutableIdentity,
        NetworkAuditDecision, NetworkAuditEvent, NetworkAuditMode,
    };
    #[cfg(unix)]
    use std::ffi::OsString;
    #[cfg(unix)]
    use std::os::unix::ffi::OsStringExt;

    fn sample_metadata(id: &str) -> SessionMetadata {
        SessionMetadata {
            session_id: id.to_string(),
            started: "2026-04-21T20:00:00Z".to_string(),
            ended: Some("2026-04-21T20:00:01Z".to_string()),
            command: vec!["/bin/pwd".to_string()],
            executable_identity: None,
            tracked_paths: vec![PathBuf::from("/tmp/work")],
            snapshot_count: 0,
            exit_code: Some(0),
            merkle_roots: Vec::new(),
            network_events: Vec::new(),
            audit_event_count: 2,
            audit_integrity: None,
            audit_attestation: None,
        }
    }

    #[test]
    fn ledger_appends_and_verifies_session_digest() {
        let _env_lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let state = tmp.path().join("state");
        std::fs::create_dir_all(&state).unwrap();
        let home = tmp.path().to_string_lossy().to_string();
        let state_str = state.to_string_lossy().to_string();
        let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);

        let meta = sample_metadata("20260421-200000-11111");
        append_session(&meta).unwrap();

        let verified = verify_session_in_ledger(&meta).unwrap();
        assert!(verified.session_found);
        assert!(verified.session_digest_matches);
        assert!(verified.ledger_chain_verified);
        assert_eq!(verified.entry_count, 1);
    }

    #[test]
    fn ledger_rejects_malformed_session_id() {
        let _env_lock = ENV_LOCK.lock().unwrap();
        let tmp = tempfile::tempdir().unwrap();
        let state = tmp.path().join("state");
        std::fs::create_dir_all(&state).unwrap();
        let home = tmp.path().to_string_lossy().to_string();
        let state_str = state.to_string_lossy().to_string();
        let _env = EnvVarGuard::set_all(&[("HOME", &home), ("XDG_STATE_HOME", &state_str)]);

        let meta = sample_metadata("real-token\\|real-key");
        let err = match append_session(&meta) {
            Ok(_) => panic!("malformed session id should be rejected"),
            Err(err) => err,
        };

        assert!(err.to_string().contains("invalid audit session id"));
    }

    #[test]
    fn session_digest_changes_when_any_protected_field_changes() {
        let base = SessionMetadata {
            session_id: "20260421-200000-11111".to_string(),
            started: "2026-04-21T20:00:00Z".to_string(),
            ended: Some("2026-04-21T20:00:01Z".to_string()),
            command: vec!["/bin/pwd".to_string()],
            executable_identity: Some(ExecutableIdentity {
                resolved_path: PathBuf::from("/bin/pwd"),
                sha256: ContentHash::from_bytes([9; 32]),
            }),
            tracked_paths: vec![PathBuf::from("/tmp/work")],
            snapshot_count: 3,
            exit_code: Some(7),
            merkle_roots: vec![ContentHash::from_bytes([1; 32])],
            network_events: vec![NetworkAuditEvent {
                timestamp_unix_ms: 5,
                mode: NetworkAuditMode::Connect,
                decision: NetworkAuditDecision::Allow,
                route_id: None,
                auth_mechanism: None,
                auth_outcome: None,
                managed_credential_active: None,
                injection_mode: None,
                denial_category: None,
                target: "example.com".to_string(),
                port: Some(443),
                method: Some("GET".to_string()),
                path: Some("/".to_string()),
                status: Some(200),
                reason: None,
            }],
            audit_event_count: 9,
            audit_integrity: Some(AuditIntegritySummary {
                hash_algorithm: "sha256".to_string(),
                event_count: 9,
                chain_head: ContentHash::from_bytes([2; 32]),
                merkle_root: ContentHash::from_bytes([3; 32]),
            }),
            audit_attestation: None,
        };
        let base_digest = compute_session_digest(&base).unwrap();

        let mut changed = base.clone();
        changed.session_id.push('x');
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.started.push('x');
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.ended = Some("2026-04-21T20:00:02Z".to_string());
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.command.push("--debug".to_string());
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.executable_identity = Some(ExecutableIdentity {
            resolved_path: PathBuf::from("/usr/bin/pwd"),
            sha256: ContentHash::from_bytes([9; 32]),
        });
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.tracked_paths.push(PathBuf::from("/tmp/other"));
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.snapshot_count = changed.snapshot_count.saturating_add(1);
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.exit_code = Some(0);
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.merkle_roots.push(ContentHash::from_bytes([4; 32]));
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.audit_attestation = Some(AuditAttestationSummary {
            predicate_type: "https://nono.sh/attestation/audit-session/alpha".to_string(),
            key_id: "test-key".to_string(),
            public_key: "Zm9v".to_string(),
            bundle_filename: "audit-attestation.bundle".to_string(),
        });
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.network_events[0].target = "other.example.com".to_string();
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.audit_event_count = changed.audit_event_count.saturating_add(1);
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());

        let mut changed = base.clone();
        changed.audit_integrity = Some(AuditIntegritySummary {
            hash_algorithm: "sha256".to_string(),
            event_count: 9,
            chain_head: ContentHash::from_bytes([8; 32]),
            merkle_root: ContentHash::from_bytes([3; 32]),
        });
        assert_ne!(base_digest, compute_session_digest(&changed).unwrap());
    }

    #[cfg(unix)]
    #[test]
    fn session_digest_distinguishes_non_utf8_paths() {
        let mut base = sample_metadata("20260421-200000-11111");
        base.tracked_paths = vec![PathBuf::from(OsString::from_vec(vec![
            b'/', b't', b'm', b'p', b'/', 0xff,
        ]))];
        let mut changed = base.clone();
        changed.tracked_paths = vec![PathBuf::from(OsString::from_vec(vec![
            b'/', b't', b'm', b'p', b'/', 0xfe,
        ]))];

        assert_ne!(
            compute_session_digest(&base).unwrap(),
            compute_session_digest(&changed).unwrap()
        );
    }
}