envseal 0.3.13

Write-only secret vault with process-level access control — post-agent secret management
//! Invariant: `resolve_binary` rejects non-executable files found in PATH.
//!
//! Mutates `PATH`; serialized against other env-mutating tests via
//! `ENV_MUTEX` and restored via [`EnvVarGuard`].

use std::sync::{Mutex, PoisonError};

use envseal::error::Error;
use envseal::policy::resolve_binary;

static ENV_MUTEX: Mutex<()> = Mutex::new(());

struct EnvVarGuard {
    key: &'static str,
    previous: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
    fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
        let previous = std::env::var_os(key);
        std::env::set_var(key, value);
        Self { key, previous }
    }
}
impl Drop for EnvVarGuard {
    fn drop(&mut self) {
        match self.previous.take() {
            Some(v) => std::env::set_var(self.key, v),
            None => std::env::remove_var(self.key),
        }
    }
}

#[test]
fn resolve_binary_rejects_non_executable() {
    let _env_lock = ENV_MUTEX.lock().unwrap_or_else(PoisonError::into_inner);

    // `tempfile::tempdir()` puts files under the OS tmp dir, which on
    // some kernels is mode 1777 (world-writable). Use the crate manifest
    // directory as the base — never o+w — to stay stable across sandboxes.
    let base = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
    let tmp = tempfile::Builder::new()
        .prefix("path-resolve-")
        .tempdir_in(base)
        .expect("create temp dir for PATH test");
    let path = tmp.path().join("fake-tool");
    std::fs::write(&path, "#!/bin/sh\necho hi\n").expect("write test command candidate");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
            .expect("make candidate non-executable");
    }

    let _path_guard = EnvVarGuard::set("PATH", tmp.path().display().to_string());

    let err = resolve_binary("fake-tool")
        .expect_err("non-executable files in PATH must never be resolved as runnable");

    // The security invariant we're pinning: a non-executable file in
    // PATH must NEVER be resolved as runnable. Two acceptable failure
    // shapes prove that property:
    //   - "not executable" — resolve_binary found the candidate, saw
    //     the missing X bit, and refused.
    //   - "binary not found in PATH" — resolve_binary's PATH walker
    //     filtered the candidate up-front (Linux walkers that pre-
    //     filter on the X bit do this; Windows does it because the
    //     candidate has no `.exe`/`.bat`/etc extension).
    // Both close the attack surface; the test must accept either on
    // every platform. (Earlier this file required the first error
    // shape on Unix, which broke on Linux walkers that pre-filter.)
    assert!(
        matches!(err, Error::BinaryResolution(ref msg)
            if msg.contains("not executable") || msg.contains("binary not found in PATH")),
        "expected non-executable / not-found rejection, got: {err:?}"
    );
}