whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
use std::path::PathBuf;

use super::*;
use crate::checks::CoreLayer;
use crate::fix::scoring;
use crate::fix::{Fix, FixAction};
use crate::operation::Operation;
use crate::test_helpers::StateBuilder;

#[test]
fn single_fix_passes_through_unchanged() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "ro")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();

    let fixes = vec![Fix {
        layer: CoreLayer::Mount,
        action: FixAction::Remount {
            mountpoint: PathBuf::from("/"),
            options: "rw".into(),
        },
        impact: scoring::REMOUNT,
        description: "remount / as read-write".into(),
    }];

    let plan = simulate_cascade(fixes, vec![], &state);
    assert_eq!(plan.fixes.len(), 1);
    assert_eq!(plan.fixes[0].layer, CoreLayer::Mount);
}

#[test]
fn independent_layers_not_pruned() {
    // remount rw does not fix DAC — they are independent layers
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/secret.txt", 0, 0, 0o600)
        .build();

    let fixes = vec![
        Fix {
            layer: CoreLayer::Mount,
            action: FixAction::Remount {
                mountpoint: PathBuf::from("/mnt"),
                options: "rw".into(),
            },
            impact: scoring::REMOUNT,
            description: "remount /mnt rw".into(),
        },
        Fix {
            layer: CoreLayer::Dac,
            action: FixAction::SetAcl {
                path: PathBuf::from("/mnt/secret.txt"),
                entry: "u:2000:w".into(),
            },
            impact: scoring::ACL_USER_GRANT,
            description: "grant write via ACL".into(),
        },
    ];

    let plan = simulate_cascade(fixes, vec![], &state);
    assert_eq!(plan.fixes.len(), 2);
    assert!(plan.fixes.iter().any(|f| f.layer == CoreLayer::Mount));
    assert!(plan.fixes.iter().any(|f| f.layer == CoreLayer::Dac));
}

#[test]
fn empty_fixes_returns_empty_plan() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();

    let plan = simulate_cascade(vec![], vec![], &state);
    assert!(plan.fixes.is_empty());
    assert!(plan.warnings.is_empty());
}

#[test]
fn same_layer_alternatives_preserved() {
    // two traversal fixes for the same ancestor are alternatives,
    // not sequential — both should be kept for user choice.
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o711)
        .component("/var", 0, 0, 0o700)
        .component_file("/var/file.txt", 0, 0, 0o644)
        .build();

    let fixes = vec![
        Fix {
            layer: CoreLayer::Traversal,
            action: FixAction::SetAcl {
                path: PathBuf::from("/var"),
                entry: "u:1000:--x".into(),
            },
            impact: scoring::ACL_USER_GRANT,
            description: "ACL execute on /var".into(),
        },
        Fix {
            layer: CoreLayer::Traversal,
            action: FixAction::Chmod {
                path: PathBuf::from("/var"),
                mode_change: "o+x".into(),
            },
            impact: scoring::CHMOD_OTHER,
            description: "chmod o+x /var".into(),
        },
    ];

    let plan = simulate_cascade(fixes, vec![], &state);
    assert_eq!(plan.fixes.len(), 2);
    assert_eq!(plan.fixes[0].impact, scoring::ACL_USER_GRANT);
    assert_eq!(plan.fixes[1].impact, scoring::CHMOD_OTHER);
}

#[test]
fn warnings_preserved_through_cascade() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "ro")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();

    let fixes = vec![Fix {
        layer: CoreLayer::Mount,
        action: FixAction::Remount {
            mountpoint: PathBuf::from("/"),
            options: "rw".into(),
        },
        impact: scoring::REMOUNT,
        description: "remount / rw".into(),
    }];

    let warnings = vec!["existing warning".to_string()];
    let plan = simulate_cascade(fixes, warnings, &state);
    assert!(plan.warnings.contains(&"existing warning".to_string()));
}

#[test]
fn max_iterations_bounded() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/data.txt", 0, 0, 0o600)
        .build();

    let fixes = vec![Fix {
        layer: CoreLayer::Dac,
        action: FixAction::SetAcl {
            path: PathBuf::from("/data.txt"),
            entry: "u:2000:w".into(),
        },
        impact: scoring::ACL_USER_GRANT,
        description: "grant write".into(),
    }];

    let plan = simulate_cascade(fixes, vec![], &state);
    assert_eq!(plan.fixes.len(), 1);
}

#[test]
fn duplicate_plan_hash_triggers_cycle_warning() {
    // A real cycle occurs when the failure set is identical before and after
    // applying a fix — meaning the fix changed nothing. Here, the Mount fix
    // targets a mountpoint that does not exist in the state, so it has no effect.
    // The failure set is [Mount, Dac] before iteration 0 and still [Mount, Dac]
    // before iteration 1; the cycle guard fires on the second iteration.
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/data.txt", 0, 0, 0o600)
        .build();

    let fixes = vec![
        Fix {
            layer: CoreLayer::Mount,
            action: FixAction::Remount {
                mountpoint: PathBuf::from("/does-not-exist"),
                options: "rw".into(),
            },
            impact: scoring::REMOUNT,
            description: "remount nonexistent".into(),
        },
        Fix {
            layer: CoreLayer::Dac,
            action: FixAction::SetAcl {
                path: PathBuf::from("/data.txt"),
                entry: "u:2000:w".into(),
            },
            impact: scoring::ACL_USER_GRANT,
            description: "grant write via ACL".into(),
        },
    ];

    let plan = simulate_cascade(fixes, vec![], &state);
    // cycle guard fires because the no-op Mount fix leaves the failure set
    // unchanged; plan must not be empty and must not loop forever.
    assert!(!plan.fixes.is_empty());
    assert!(
        plan.warnings.iter().any(|w| w.contains("cycle")),
        "expected cycle warning when fix has no effect on failure set; warnings: {:?}",
        plan.warnings,
    );
}

#[test]
fn multi_layer_fix_does_not_trigger_spurious_cycle_warning() {
    // Mount (ro) and Dac both fail. Fixing mount changes the failure set each
    // iteration — hashing fixes (constant) would fire a false cycle on iteration 2.
    // Correct hash-of-failure-set must NOT emit a cycle warning here.
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "ro")
        .component("/", 0, 0, 0o755)
        .component_file("/data.txt", 0, 0, 0o600)
        .build();

    let fixes = vec![
        Fix {
            layer: CoreLayer::Mount,
            action: FixAction::Remount {
                mountpoint: std::path::PathBuf::from("/"),
                options: "rw".into(),
            },
            impact: scoring::REMOUNT,
            description: "remount / rw".into(),
        },
        Fix {
            layer: CoreLayer::Dac,
            action: FixAction::SetAcl {
                path: std::path::PathBuf::from("/data.txt"),
                entry: "u:2000:w".into(),
            },
            impact: scoring::ACL_USER_GRANT,
            description: "grant write via ACL".into(),
        },
    ];

    let plan = simulate_cascade(fixes, vec![], &state);
    let cycle_warning = "fix cascade cycle detected \u{2014} manual review required";
    assert!(
        !plan.warnings.iter().any(|w| w.contains("cycle")),
        "spurious cycle warning emitted; warnings: {:?}; cycle_warning: {cycle_warning}",
        plan.warnings,
    );
}

#[test]
fn hash_fix_plan_deterministic() {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let fixes = vec![Fix {
        layer: CoreLayer::Dac,
        action: FixAction::Chmod {
            path: PathBuf::from("/tmp/test"),
            mode_change: "o+r".into(),
        },
        impact: scoring::CHMOD_OTHER,
        description: "chmod o+r /tmp/test".into(),
    }];

    let hash1 = {
        let mut h = DefaultHasher::new();
        fixes.hash(&mut h);
        h.finish()
    };
    let hash2 = {
        let mut h = DefaultHasher::new();
        fixes.hash(&mut h);
        h.finish()
    };
    assert_eq!(hash1, hash2, "same fixes must produce same hash");
}