tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Proof tests for `tsafe plugin` failure semantics (ADR-026).
//!
//! Covers at least 5 of the 10 failure modes in the ADR-026 failure table.
//! All tests that require the `plugins` feature are gated behind
//! `#[cfg(feature = "plugins")]`.
//!
//! Failure modes covered:
//!   F1 — unknown plugin name → exit 1, clear error
//!   F4 — binary not found on PATH → exit 1, clear error
//!   F5 — required key missing from vault → exit 1, error before spawn
//!   F8 — binary exits non-zero → exit code propagated
//!   F3 — empty/no command (static table guard) + structural: no ambient
//!         env leak for declared plugin names (ADR-025 §D4 ambient-strip proof)

#[cfg(feature = "plugins")]
mod plugin_failure_contract {
    use assert_cmd::Command;
    use predicates::str::contains;
    use std::path::Path;
    use tempfile::tempdir;

    fn tsafe() -> Command {
        Command::cargo_bin("tsafe").unwrap()
    }

    fn init_vault(dir: &Path) {
        tsafe()
            .args(["init"])
            .env("TSAFE_VAULT_DIR", dir)
            .env("TSAFE_PASSWORD", "pw")
            .assert()
            .success();
    }

    // ── F1: Unknown plugin name → exit 1, clear error ────────────────────────

    /// ADR-026 F1: requesting a plugin tool name that is not in the static
    /// PLUGINS table must fail immediately with exit code 1 and a message that
    /// names the unknown tool and hints at `tsafe plugin` for the list.
    #[test]
    fn f1_unknown_plugin_name_exits_1_with_clear_error() {
        let dir = tempdir().unwrap();
        init_vault(dir.path());

        tsafe()
            .args(["plugin", "nonexistent-tool-xyz"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .assert()
            .failure()
            .code(1)
            .stderr(contains("unknown plugin 'nonexistent-tool-xyz'"))
            .stderr(contains("tsafe plugin"));
    }

    // ── F4: Binary not found on PATH → exit 1, clear error ───────────────────

    /// ADR-026 F4: when a plugin's binary cannot be found on PATH (or the OS
    /// spawn fails), tsafe must exit 1 with an error naming the binary.
    ///
    /// We use the `npm` plugin (binary: `npm`) which may or may not be installed
    /// in the test environment. To ensure a controlled failure we point PATH at
    /// an empty temp directory so no binaries can be resolved, then check that
    /// tsafe exits 1 with a message about the binary or the spawn failure.
    ///
    /// Note: the `npm` plugin has all optional keys, so a missing key won't
    /// trigger F5 first — the vault can be empty.
    #[test]
    fn f4_binary_not_found_on_path_exits_1_with_spawn_error() {
        let dir = tempdir().unwrap();
        let empty_path_dir = tempdir().unwrap();
        init_vault(dir.path());

        // Point PATH at an empty dir so no binary can be found.
        let empty_path = empty_path_dir.path().to_str().unwrap().to_string();

        let assert = tsafe()
            .args(["plugin", "npm"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .env("PATH", &empty_path)
            .assert();

        // Must fail with exit code 1.
        assert
            .failure()
            .code(1)
            // The error must mention the binary name.
            .stderr(contains("npm"));
    }

    // ── F5: Required key missing from vault → exit 1, error before spawn ─────

    /// ADR-026 F5: the `aws` plugin marks `AWS_ACCESS_KEY_ID` and
    /// `AWS_SECRET_ACCESS_KEY` as `required: true`. If these keys are absent
    /// from the vault, tsafe must abort with exit code 1 before spawning
    /// the `aws` binary. The error message must name the missing keys.
    #[test]
    fn f5_required_key_missing_exits_1_before_spawn() {
        let dir = tempdir().unwrap();
        init_vault(dir.path());
        // Vault is empty — required AWS keys are absent.

        tsafe()
            .args(["plugin", "aws", "s3", "ls"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .assert()
            .failure()
            .code(1)
            .stderr(contains("requires vault keys that are missing"))
            .stderr(contains("AWS_ACCESS_KEY_ID"));
    }

    /// ADR-026 F5 variant: confirming the error occurs before spawn by
    /// ensuring no aws binary output appears in stdout/stderr and the error
    /// message arrives on stderr (not via the tool itself).
    #[test]
    fn f5_required_key_missing_error_is_tsafe_owned_not_tool_owned() {
        let dir = tempdir().unwrap();
        // Create a sentinel vault dir that ensures no aws output could appear.
        let empty_path_dir = tempdir().unwrap();
        let empty_path = empty_path_dir.path().to_str().unwrap().to_string();
        init_vault(dir.path());

        // Even with no PATH, the error should appear because tsafe checks keys
        // before attempting the spawn (pre-spawn check). This confirms the
        // failure happens in tsafe's code, not in the binary.
        tsafe()
            .args(["plugin", "aws", "s3", "ls"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .env("PATH", &empty_path)
            .assert()
            .failure()
            .code(1)
            .stderr(contains("requires vault keys that are missing"));
    }

    // ── F8: Binary exits non-zero → exit code propagated ─────────────────────

    /// ADR-026 F8: when the launched plugin binary exits with a non-zero
    /// code, tsafe must propagate that exact code rather than collapsing it
    /// to 1.
    ///
    /// We use the `gh` plugin (all optional keys) with a PATH that points to a
    /// fake `gh` script that exits with a specific code. We craft a tiny shell
    /// script (Unix) or batch file (Windows) to act as the fake binary.
    #[cfg(unix)]
    #[test]
    fn f8_plugin_binary_nonzero_exit_propagated() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempdir().unwrap();
        let bin_dir = tempdir().unwrap();
        init_vault(dir.path());

        // Write a fake `gh` script that exits with code 42.
        let fake_gh = bin_dir.path().join("gh");
        std::fs::write(&fake_gh, "#!/bin/sh\nexit 42\n").unwrap();
        std::fs::set_permissions(&fake_gh, std::fs::Permissions::from_mode(0o755)).unwrap();

        tsafe()
            .args(["plugin", "gh", "repo", "list"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .env("PATH", bin_dir.path())
            .assert()
            .failure()
            .code(42);
    }

    // ── Plugin list command is not an error (no-arg behavior) ────────────────

    /// ADR-026 F2 clarification: `tsafe plugin` with no tool name prints the
    /// plugin list and exits 0. This is the intended no-arg behavior, not an
    /// error case.
    #[test]
    fn no_arg_plugin_lists_available_plugins_and_exits_0() {
        let dir = tempdir().unwrap();
        init_vault(dir.path());

        tsafe()
            .args(["plugin"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .assert()
            .success()
            .stdout(contains("plugin launchers"))
            .stdout(contains("gh"))
            .stdout(contains("aws"));
    }

    /// `tsafe plugin list` is equivalent to `tsafe plugin` with no args.
    #[test]
    fn plugin_list_subcommand_exits_0_and_shows_plugins() {
        let dir = tempdir().unwrap();
        init_vault(dir.path());

        tsafe()
            .args(["plugin", "list"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .assert()
            .success()
            .stdout(contains("plugin launchers"));
    }

    // ── ADR-025 D4: ambient env-var strip proof ───────────────────────────────

    /// ADR-025 §D4 proof: ambient env vars that share names with declared
    /// plugin keys must NOT reach the subprocess if the vault does not
    /// override them — they are stripped from the subprocess environment.
    ///
    /// We use the `npm` plugin (single declared env: `NPM_TOKEN`) and set
    /// `NPM_TOKEN` as an ambient env var. The subprocess should NOT see the
    /// ambient value; it should either see nothing (key absent from vault)
    /// or the vault value (key present in vault).
    ///
    /// We use a fake `npm` binary (Unix) that prints its environment and exits 0,
    /// then confirm the ambient value is absent from its output.
    #[cfg(unix)]
    #[test]
    fn d4_ambient_env_stripped_for_declared_plugin_vars() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempdir().unwrap();
        let bin_dir = tempdir().unwrap();
        let capture = dir.path().join("env_output.txt");
        init_vault(dir.path());
        // Do NOT set NPM_TOKEN in the vault — it should be absent from the child.

        // Write a fake `npm` that dumps its environment to a file then exits 0.
        let capture_str = capture.to_str().unwrap();
        let script = format!(
            "#!/bin/sh\nprintenv > '{capture_str}'\nexit 0\n",
            capture_str = capture_str,
        );
        let fake_npm = bin_dir.path().join("npm");
        std::fs::write(&fake_npm, &script).unwrap();
        std::fs::set_permissions(&fake_npm, std::fs::Permissions::from_mode(0o755)).unwrap();

        tsafe()
            .args(["plugin", "npm"])
            .env("TSAFE_VAULT_DIR", dir.path())
            .env("TSAFE_PASSWORD", "pw")
            .env("PATH", bin_dir.path())
            // Ambient NPM_TOKEN — must be stripped before the subprocess sees it.
            .env("NPM_TOKEN", "ambient-secret-must-not-leak")
            .assert()
            .success();

        let captured = std::fs::read_to_string(&capture).unwrap_or_default();
        assert!(
            !captured.contains("ambient-secret-must-not-leak"),
            "ambient NPM_TOKEN value must not reach the plugin subprocess (ADR-025 D4)"
        );
    }
}