yosh 0.1.3

A POSIX-compliant shell implemented in Rust
Documentation
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;

use yosh::env::ShellEnv;
use yosh::plugin::PluginManager;

/// Serialize all plugin tests to avoid interference from the test plugin's
/// internal static Mutex (each load_plugin call resets the static, so parallel
/// tests that load the same .dylib can corrupt each other's state).
static TEST_LOCK: Mutex<()> = Mutex::new(());

fn build_test_plugin() -> PathBuf {
    let manifest =
        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugins/test_plugin/Cargo.toml");
    let target_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugins/test_plugin/target");
    let status = Command::new("cargo")
        .args(["build", "--manifest-path", manifest.to_str().unwrap()])
        .env("CARGO_TARGET_DIR", &target_dir)
        .status()
        .expect("failed to run cargo build for test plugin");
    assert!(status.success(), "test plugin build failed");

    let target_dir = target_dir.join("debug");
    if cfg!(target_os = "macos") {
        target_dir.join("libtest_plugin.dylib")
    } else {
        target_dir.join("libtest_plugin.so")
    }
}

#[test]
fn load_plugin_successfully() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();
    assert!(manager.has_command("test-hello"));
    assert!(manager.has_command("test-set-var"));
    assert!(!manager.has_command("nonexistent"));
}

#[test]
fn exec_plugin_command() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    let status = manager.exec_command(&mut env, "test-hello", &[]);
    assert_eq!(status, Some(0));
    assert_eq!(env.vars.get("TEST_EXEC_CALLED"), Some("1"));
}

#[test]
fn exec_plugin_command_with_args() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    let status = manager.exec_command(
        &mut env,
        "test-set-var",
        &["MY_VAR".to_string(), "my_value".to_string()],
    );
    assert_eq!(status, Some(0));
    assert_eq!(env.vars.get("MY_VAR"), Some("my_value"));
}

#[test]
fn exec_unknown_command_returns_none() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    let status = manager.exec_command(&mut env, "nonexistent", &[]);
    assert_eq!(status, None);
}

#[test]
fn hook_pre_exec() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    manager.call_pre_exec(&mut env, "echo hello");
    assert_eq!(env.vars.get("TEST_PRE_EXEC"), Some("echo hello"));
}

#[test]
fn hook_post_exec() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    manager.call_post_exec(&mut env, "ls -la", 0);
    assert_eq!(env.vars.get("TEST_POST_EXEC"), Some("ls -la:0"));
}

#[test]
fn hook_on_cd() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    manager.call_on_cd(&mut env, "/old/dir", "/new/dir");
    assert_eq!(env.vars.get("TEST_ON_CD"), Some("/old/dir->/new/dir"));
}

#[test]
fn hook_pre_prompt() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    manager.call_pre_prompt(&mut env);
    assert_eq!(env.vars.get("TEST_PRE_PROMPT"), Some("1"));
}

#[test]
fn load_nonexistent_plugin_fails() {
    let _guard = TEST_LOCK.lock().unwrap();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    let result = manager.load_plugin(Path::new("/nonexistent/libfoo.dylib"), &mut env);
    assert!(result.is_err());
}

#[test]
fn readonly_var_rejected_by_plugin() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);
    manager.load_plugin(&dylib, &mut env).unwrap();

    // Set a readonly variable
    let _ = env.vars.set("RO_VAR", "immutable");
    env.vars.set_readonly("RO_VAR");

    // Plugin tries to overwrite — set_var returns error code 1, but the plugin
    // ignores the result of set_var. The variable should be unchanged.
    let status = manager.exec_command(
        &mut env,
        "test-set-var",
        &["RO_VAR".to_string(), "changed".to_string()],
    );
    assert_eq!(env.vars.get("RO_VAR"), Some("immutable"));
    assert!(status.is_some());
}

#[test]
fn sandbox_deny_set_var_without_capability() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Load with only variables:read + io (no variables:write)
    let caps = yosh_plugin_api::CAP_VARIABLES_READ | yosh_plugin_api::CAP_IO;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // test-set-var calls set_var — should be denied
    let status = manager.exec_command(
        &mut env,
        "test-set-var",
        &["MY_VAR".to_string(), "my_value".to_string()],
    );
    assert!(status.is_some());
    // Variable should NOT be set because set_var was denied
    assert_eq!(env.vars.get("MY_VAR"), None);
}

#[test]
fn sandbox_hook_not_fired_without_capability() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Load with variables:write + io but NO hook capabilities
    let caps = yosh_plugin_api::CAP_VARIABLES_READ
        | yosh_plugin_api::CAP_VARIABLES_WRITE
        | yosh_plugin_api::CAP_IO;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // Hooks should not fire
    manager.call_pre_exec(&mut env, "echo hello");
    assert_eq!(env.vars.get("TEST_PRE_EXEC"), None);

    manager.call_post_exec(&mut env, "ls -la", 0);
    assert_eq!(env.vars.get("TEST_POST_EXEC"), None);

    manager.call_on_cd(&mut env, "/old", "/new");
    assert_eq!(env.vars.get("TEST_ON_CD"), None);
}

#[test]
fn sandbox_selective_hook_capability() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Grant variables:write (for hooks to set_var) + only pre_exec hook
    let caps = yosh_plugin_api::CAP_VARIABLES_READ
        | yosh_plugin_api::CAP_VARIABLES_WRITE
        | yosh_plugin_api::CAP_IO
        | yosh_plugin_api::CAP_HOOK_PRE_EXEC;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // pre_exec should fire
    manager.call_pre_exec(&mut env, "echo hello");
    assert_eq!(env.vars.get("TEST_PRE_EXEC"), Some("echo hello"));

    // post_exec should NOT fire
    manager.call_post_exec(&mut env, "ls", 0);
    assert_eq!(env.vars.get("TEST_POST_EXEC"), None);

    // on_cd should NOT fire
    manager.call_on_cd(&mut env, "/old", "/new");
    assert_eq!(env.vars.get("TEST_ON_CD"), None);
}

#[test]
fn sandbox_full_capabilities_works_normally() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Load with all capabilities (None = trust mode)
    manager.load_plugin(&dylib, &mut env).unwrap();

    // Everything should work as before
    let status = manager.exec_command(&mut env, "test-hello", &[]);
    assert_eq!(status, Some(0));
    assert_eq!(env.vars.get("TEST_EXEC_CALLED"), Some("1"));

    manager.call_pre_exec(&mut env, "echo");
    assert_eq!(env.vars.get("TEST_PRE_EXEC"), Some("echo"));

    manager.call_post_exec(&mut env, "ls", 42);
    assert_eq!(env.vars.get("TEST_POST_EXEC"), Some("ls:42"));

    manager.call_on_cd(&mut env, "/a", "/b");
    assert_eq!(env.vars.get("TEST_ON_CD"), Some("/a->/b"));

    manager.call_pre_prompt(&mut env);
    assert_eq!(env.vars.get("TEST_PRE_PROMPT"), Some("1"));
}

#[test]
fn sandbox_pre_prompt_not_fired_without_capability() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Load with variables:write + io but NO hook capabilities
    let caps = yosh_plugin_api::CAP_VARIABLES_READ
        | yosh_plugin_api::CAP_VARIABLES_WRITE
        | yosh_plugin_api::CAP_IO;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // pre_prompt hook should not fire
    manager.call_pre_prompt(&mut env);
    assert_eq!(env.vars.get("TEST_PRE_PROMPT"), None);
}

#[test]
fn sandbox_selective_pre_prompt_capability() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Grant variables:write (for hook to set_var) + only pre_prompt hook
    let caps = yosh_plugin_api::CAP_VARIABLES_READ
        | yosh_plugin_api::CAP_VARIABLES_WRITE
        | yosh_plugin_api::CAP_IO
        | yosh_plugin_api::CAP_HOOK_PRE_PROMPT;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // pre_prompt should fire
    manager.call_pre_prompt(&mut env);
    assert_eq!(env.vars.get("TEST_PRE_PROMPT"), Some("1"));

    // pre_exec should NOT fire (not granted)
    manager.call_pre_exec(&mut env, "echo hello");
    assert_eq!(env.vars.get("TEST_PRE_EXEC"), None);
}

#[test]
fn sandbox_config_restricts_capabilities() {
    let _guard = TEST_LOCK.lock().unwrap();
    let dylib = build_test_plugin();
    let mut manager = PluginManager::new();
    let mut env = ShellEnv::new("yosh", vec![]);

    // Plugin requests all capabilities, config only grants io
    let caps = yosh_plugin_api::CAP_IO;
    manager
        .load_plugin_with_capabilities(&dylib, &mut env, Some(caps))
        .unwrap();

    // test-hello calls print (io) and set_var (variables:write)
    // print should work, set_var should be denied
    let status = manager.exec_command(&mut env, "test-hello", &[]);
    assert_eq!(status, Some(0));
    // set_var("TEST_EXEC_CALLED", "1") was denied
    assert_eq!(env.vars.get("TEST_EXEC_CALLED"), None);
}