Ctrl K
ComunidadGuías…

crates/tui/src/test_support.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
pub(crate) use codewhale_engine::test_support::*;

#[cfg(unix)]
mod tests {
    use std::path::Path;
    use std::time::UNIX_EPOCH;

    /// Tripwire for the leak this module exists to prevent: run a sample of
    /// the tests that once wrote `~/.codewhale` in a child process whose
    /// *ambient* `HOME` and `CODEWHALE_HOME` are a seeded, read-only sentinel,
    /// then require both that they pass and that the sentinel is byte-for-byte
    /// as it was. A test that writes the ambient home without sealing its own
    /// fails here by name, instead of silently rewriting a developer's profile.
    ///
    /// The sample covers one test per resolver class that leaked
    /// (`App::new`, command dispatch, sessions, snapshots, plugin bundles,
    /// credentials, the `/import-claude` report). To sweep a whole family,
    /// set `CODEWHALE_TEST_AMBIENT_HOME_FILTER` to a libtest filter such as
    /// `commands::`; that takes minutes, which is why it is not the default.
    #[test]
    fn ambient_home_survives_a_sample_of_state_touching_tests() {
        use std::os::unix::fs::PermissionsExt;

        const PROBE_ENV: &str = "CODEWHALE_TEST_AMBIENT_HOME_PROBE";
        const FILTER_ENV: &str = "CODEWHALE_TEST_AMBIENT_HOME_FILTER";
        const SAMPLE: &[&str] = &[
            "commands::contract::tests::bundle_construction_performs_no_eager_work",
            "commands::contract::tests::skill_group_snapshot_list_and_restore_roundtrip",
            "commands::debug_diagnostics_host_tests::test_context_report_subcommands_return_source_map",
            "commands::debug_mutation_host_tests::test_patch_undo_requests_durable_rewind_after_restore",
            "commands::groups::core::core::tests::test_clear_resets_all_state",
            "commands::groups::plugins::tests::kimi_managed_import_refuses_linked_children",
            "commands::session_lifecycle_regression_tests::fork_saves_parent_and_switches_to_child_session",
            "commands::session_lifecycle_regression_tests::test_save_creates_file_and_sets_session_id",
            "commands::tests::every_registered_command_dispatches_to_a_handler",
            "commands::tests::every_command_alias_dispatches_to_a_handler",
            "commands::tests::feat020_plugin_dispatches_through_public_seam",
        ];

        // The child runs the sample; it must not recurse into this probe.
        if std::env::var_os(PROBE_ENV).is_some() {
            return;
        }

        fn walk(dir: &Path, visit: &mut dyn FnMut(&Path, &std::fs::Metadata)) {
            let mut entries: Vec<_> = std::fs::read_dir(dir)
                .expect("read sentinel dir")
                .map(|entry| entry.expect("sentinel entry").path())
                .collect();
            entries.sort();
            for path in entries {
                let metadata = std::fs::symlink_metadata(&path).expect("sentinel metadata");
                visit(&path, &metadata);
                if metadata.is_dir() {
                    walk(&path, visit);
                }
            }
        }
        fn listing(root: &Path) -> Vec<String> {
            let mut out = Vec::new();
            walk(root, &mut |path, metadata| {
                let mtime = metadata
                    .modified()
                    .ok()
                    .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
                    .map_or(0, |elapsed| elapsed.as_nanos());
                out.push(format!(
                    "{} dir={} len={} mtime={mtime} contents={:?}",
                    path.strip_prefix(root).unwrap_or(path).display(),
                    metadata.is_dir(),
                    metadata.len(),
                    metadata
                        .is_file()
                        .then(|| std::fs::read(path).expect("read sentinel file"))
                ));
            });
            out
        }
        fn set_readonly(root: &Path, readonly: bool) {
            let mode = |dir: bool| match (dir, readonly) {
                (true, true) => 0o500,
                (true, false) => 0o700,
                (false, true) => 0o400,
                (false, false) => 0o600,
            };
            let apply = |path: &Path, dir: bool| {
                std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode(dir)))
                    .expect("set sentinel permissions");
            };
            walk(root, &mut |path, metadata| {
                if !metadata.file_type().is_symlink() {
                    apply(path, metadata.is_dir());
                }
            });
            apply(root, true);
        }

        let sentinel = tempfile::tempdir().expect("sentinel tempdir");
        let home = sentinel.path().join("home");
        let codewhale_home = sentinel.path().join("codewhale-home");
        // Content a leaking test would read, rewrite or migrate.
        for (relative, body) in [
            (
                "home/.claude/CLAUDE.md",
                "# someone's Claude instructions\n",
            ),
            ("home/.claude.json", "{\"mcpServers\":{}}\n"),
            ("home/.codewhale/instructions.md", "# real instructions\n"),
            ("home/.deepseek/config.toml", "model = \"keep-me\"\n"),
            ("home/.deepseek/sessions/legacy.json", "{}\n"),
            ("codewhale-home/instructions.md", "# real instructions\n"),
            ("codewhale-home/sessions/keep.json", "{}\n"),
        ] {
            let path = sentinel.path().join(relative);
            std::fs::create_dir_all(path.parent().expect("sentinel parent")).expect("seed dir");
            std::fs::write(path, body).expect("seed file");
        }
        assert!(home.is_dir() && codewhale_home.is_dir());
        let before = listing(sentinel.path());
        set_readonly(sentinel.path(), true);

        let filter = std::env::var(FILTER_ENV).ok().filter(|f| !f.is_empty());
        let mut child = std::process::Command::new(std::env::current_exe().expect("test binary"));
        child.arg("--test-threads=4");
        match &filter {
            Some(filter) => child.arg(filter),
            None => child.arg("--exact").args(SAMPLE),
        };
        let output = child
            .env(PROBE_ENV, "1")
            .env("HOME", &home)
            .env("USERPROFILE", &home)
            .env("CODEWHALE_HOME", &codewhale_home)
            .env_remove("HOMEDRIVE")
            .env_remove("HOMEPATH")
            .env_remove("CODEWHALE_CONFIG_PATH")
            .env_remove("DEEPSEEK_CONFIG_PATH")
            .env_remove("DEEPSEEK_HOME")
            .output();

        // Restore write access first so a failure still cleans up after itself.
        set_readonly(sentinel.path(), false);
        let output = output.expect("run the sample under a read-only ambient home");
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        let report = format!("stdout:\n{stdout}\nstderr:\n{stderr}");

        assert!(
            output.status.success(),
            "a test failed under a read-only ambient home — it reaches the user's \
             state without sealing it (use SealedHome, or fence the resolver):\n{report}"
        );
        if filter.is_none() {
            assert!(
                stdout.contains(&format!("running {} tests", SAMPLE.len())),
                "the sample names drifted from real tests; update SAMPLE:\n{report}"
            );
        }
        assert_eq!(
            listing(sentinel.path()),
            before,
            "a test wrote the ambient home:\n{report}"
        );
    }
}