execsurface 1.0.0

Runtime execution-surface drift detection for Linux CI and developer workflows
#![cfg(all(target_os = "linux", target_arch = "x86_64"))]

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};

use sha2::{Digest, Sha256};

static NEXT_ID: AtomicU64 = AtomicU64::new(1);

const V3_ALLOW_TRUNCATE_BLOCK_POLICY: &str = r#"{
  "schema_version": 3,
  "default_action": "allow",
  "rules": [{
    "id": "block-truncating-open",
    "action": "block",
    "match": {
      "effect": "file_open",
      "open_intent": { "truncate": true }
    }
  }]
}
"#;

const V3_ALLOW_POLICY: &str = r#"{
  "schema_version": 3,
  "default_action": "allow",
  "rules": []
}
"#;

const V3_BLOCK_POLICY: &str = r#"{
  "schema_version": 3,
  "default_action": "block",
  "rules": []
}
"#;

fn cli() -> &'static str {
    env!("CARGO_BIN_EXE_execsurface")
}

fn temp_dir(name: &str) -> PathBuf {
    let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
    let path = std::env::temp_dir().join(format!(
        "execsurface-stage2-r8-{name}-{}-{id}",
        std::process::id()
    ));
    fs::create_dir_all(&path).expect("create R8 temp dir");
    path
}

fn learn(dir: &Path, baseline: &Path) {
    let output = Command::new(cli())
        .current_dir(dir)
        .args(["learn", "--output"])
        .arg(baseline)
        .args(["--", "/bin/sh", "-c", "true"])
        .output()
        .expect("learn R8 baseline");
    assert!(
        output.status.success(),
        "baseline learn failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn baseline_digest(path: &Path) -> String {
    let value: serde_json::Value =
        serde_json::from_slice(&fs::read(path).expect("read baseline")).expect("baseline JSON");
    value["baseline_digest"]
        .as_str()
        .expect("baseline digest")
        .to_owned()
}

fn policy_digest(bytes: &[u8]) -> String {
    format!("sha256:{:x}", Sha256::digest(bytes))
}

fn run_check(
    dir: &Path,
    baseline: &Path,
    policy: &Path,
    expected_baseline: &str,
    expected_policy: &str,
    json_output: Option<&Path>,
    target: &str,
) -> std::process::Output {
    let mut command = Command::new(cli());
    command
        .current_dir(dir)
        .args(["check", "--baseline"])
        .arg(baseline)
        .args(["--policy"])
        .arg(policy)
        .args(["--expect-baseline-digest", expected_baseline])
        .args(["--expect-policy-sha256", expected_policy]);

    if let Some(path) = json_output {
        command.args(["--json-output"]).arg(path);
    }

    command
        .args(["--", "/bin/sh", "-c", target])
        .output()
        .expect("run R8 check")
}

fn report_verdict(path: &Path) -> String {
    let value: serde_json::Value =
        serde_json::from_slice(&fs::read(path).expect("read verdict report"))
            .expect("verdict report JSON");
    value["verdict"]
        .as_str()
        .expect("verdict string")
        .to_owned()
}

#[test]
fn r8_c1_custody_v3_truncate_policy_and_safe_report_materialization_compose() {
    let dir = temp_dir("c1-v3-block");
    let baseline = dir.join("baseline.lock.json");
    let policy = dir.join("policy-v3.json");
    let target_file = dir.join("target.bin");
    let report = dir.join("report.json");

    learn(&dir, &baseline);
    fs::write(&policy, V3_ALLOW_TRUNCATE_BLOCK_POLICY).expect("write v3 policy");

    let expected_baseline = baseline_digest(&baseline);
    let expected_policy = policy_digest(V3_ALLOW_TRUNCATE_BLOCK_POLICY.as_bytes());
    let target = format!("printf x > '{}'", target_file.display());

    let output = run_check(
        &dir,
        &baseline,
        &policy,
        &expected_baseline,
        &expected_policy,
        Some(&report),
        &target,
    );

    assert_eq!(
        output.status.code(),
        Some(20),
        "v3 truncate rule must survive custody + observation + verdict materialization: stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(fs::read(&target_file).expect("target bytes"), b"x");
    assert_eq!(report_verdict(&report), "block");

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn r8_c2_unauthorized_baseline_pin_dominates_policy_and_target_execution() {
    let dir = temp_dir("c2-custody-dominates");
    let baseline = dir.join("baseline.lock.json");
    let policy = dir.join("policy-v3.json");
    let marker = dir.join("TARGET_RAN");
    let report = dir.join("report.json");

    learn(&dir, &baseline);
    fs::write(&policy, V3_ALLOW_POLICY).expect("write v3 allow policy");

    let wrong_baseline = format!("sha256:{}", "0".repeat(64));
    let expected_policy = policy_digest(V3_ALLOW_POLICY.as_bytes());
    let target = format!("touch '{}'", marker.display());

    let output = run_check(
        &dir,
        &baseline,
        &policy,
        &wrong_baseline,
        &expected_policy,
        Some(&report),
        &target,
    );

    assert_eq!(output.status.code(), Some(2));
    assert!(
        !marker.exists(),
        "custody mismatch must reject before target execution"
    );
    assert!(
        !report.exists(),
        "custody failure must not be laundered into a policy verdict report"
    );

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn r8_c3_policy_output_alias_is_rejected_before_target_under_v3_custody() {
    let dir = temp_dir("c3-policy-output-alias");
    let baseline = dir.join("baseline.lock.json");
    let policy = dir.join("policy-v3.json");
    let marker = dir.join("TARGET_RAN");

    learn(&dir, &baseline);
    fs::write(&policy, V3_ALLOW_POLICY).expect("write v3 allow policy");

    let expected_baseline = baseline_digest(&baseline);
    let expected_policy = policy_digest(V3_ALLOW_POLICY.as_bytes());
    let before = fs::read(&policy).expect("policy bytes before");
    let target = format!("touch '{}'", marker.display());

    let output = run_check(
        &dir,
        &baseline,
        &policy,
        &expected_baseline,
        &expected_policy,
        Some(&policy),
        &target,
    );

    assert_eq!(output.status.code(), Some(2));
    assert!(
        !marker.exists(),
        "protected policy/output alias must fail before target execution"
    );
    assert_eq!(
        fs::read(&policy).expect("policy bytes after"),
        before,
        "trusted policy bytes must remain unchanged"
    );

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn r8_c4_moved_trusted_baseline_inode_cannot_become_v3_report_output() {
    let dir = temp_dir("c4-moved-baseline");
    let baseline = dir.join("baseline.lock.json");
    let policy = dir.join("policy-v3.json");
    let report = dir.join("report.json");

    learn(&dir, &baseline);
    fs::write(&policy, V3_ALLOW_POLICY).expect("write v3 allow policy");

    let expected_baseline = baseline_digest(&baseline);
    let expected_policy = policy_digest(V3_ALLOW_POLICY.as_bytes());
    let baseline_before = fs::read(&baseline).expect("baseline bytes before");
    let target = format!("mv '{}' '{}'", baseline.display(), report.display());

    let output = run_check(
        &dir,
        &baseline,
        &policy,
        &expected_baseline,
        &expected_policy,
        Some(&report),
        &target,
    );

    assert_eq!(
        output.status.code(),
        Some(2),
        "trusted-object move must fail before report materialization"
    );
    assert!(
        !baseline.exists(),
        "target must have moved the baseline pathname"
    );
    assert_eq!(
        fs::read(&report).expect("moved trusted object"),
        baseline_before,
        "ExecSurface must not overwrite the moved trusted baseline inode"
    );

    let _ = fs::remove_dir_all(dir);
}

#[test]
fn r8_c5_target_policy_mutation_cannot_change_the_verified_policy_already_consumed() {
    let dir = temp_dir("c5-policy-mutation");
    let baseline = dir.join("baseline.lock.json");
    let policy = dir.join("policy-v3.json");
    let report = dir.join("report.json");

    learn(&dir, &baseline);
    fs::write(&policy, V3_BLOCK_POLICY).expect("write blocking v3 policy");

    let expected_baseline = baseline_digest(&baseline);
    let expected_policy = policy_digest(V3_BLOCK_POLICY.as_bytes());
    let replacement = V3_ALLOW_POLICY.replace('\n', "");
    let target = format!("printf '%s' '{}' > '{}'", replacement, policy.display());

    let output = run_check(
        &dir,
        &baseline,
        &policy,
        &expected_baseline,
        &expected_policy,
        Some(&report),
        &target,
    );

    assert_eq!(
        output.status.code(),
        Some(20),
        "post-preflight policy-file mutation must not replace the verified in-memory policy: stderr={}",
        String::from_utf8_lossy(&output.stderr)
    );
    assert_eq!(
        fs::read_to_string(&policy).expect("mutated policy"),
        replacement,
        "target must actually replace the on-disk policy to exercise the temporal boundary"
    );
    assert_eq!(report_verdict(&report), "block");

    let _ = fs::remove_dir_all(dir);
}