返回 CodeWhale
diagnostic_dispatch_read_only.rs
根目录 / crates / cli / tests / diagnostic_dispatch_read_only.rs
1 //! Diagnostic dispatch must be read-only whether the command uses the real
2 //! in-process TUI entry (`doctor`, `setup --status`) or stays in the CLI
3 //! (`auth status --diagnostic`). The single `codewhale` binary has no sibling
4 //! TUI executable to delegate to (#5259 single-binary argv0 dispatch). These
5 //! invariants stay: the dispatcher must not migrate legacy secrets, must not
6 //! rewrite legacy settings, and must not create any state under a sealed HOME.
7 //! `doctor --context-json` must still emit a machine-readable context source
8 //! map (`{"entries":[...]}`).
9
10 #![cfg(unix)]
11
12 use std::fs;
13 use std::path::{Path, PathBuf};
14 use std::process::Command;
15
16 use codewhale_secrets::{FileKeyringStore, KeyringStore};
17 use tempfile::TempDir;
18
19 #[test]
20 fn dispatcher_diagnostics_are_in_process_and_read_only() {
21 // (cli args, whether stdout must be a JSON object carrying an `entries`
22 // array, whether this is the structural auth diagnostic). Only
23 // `doctor --context-json` carries the context source map.
24 for (args, expects_entries_json, expects_auth_diagnostic) in [
25 (&["doctor"][..], false, false),
26 (&["doctor", "--json"][..], false, false),
27 (&["doctor", "--context-json"][..], true, false),
28 (&["setup", "--status"][..], false, false),
29 (&["auth", "status", "--diagnostic"][..], false, true),
30 ] {
31 let fixture = TempDir::new().expect("fixture root");
32 let sealed_home = fixture.path().join("sealed-home");
33 let codewhale_home = fixture.path().join("sealed-codewhale-home");
34 let primary_home = sealed_home.join(".codewhale");
35 let legacy = sealed_home
36 .join(".deepseek")
37 .join("secrets")
38 .join("secrets.json");
39 let legacy_settings = sealed_home.join(".deepseek").join("settings.toml");
40 let legacy_settings_bytes = b"default_mode = \"plan\"\n";
41 FileKeyringStore::new(&legacy)
42 .set("deepseek", "synthetic-legacy-fixture")
43 .expect("seed synthetic legacy store");
44 fs::write(&legacy_settings, legacy_settings_bytes).expect("seed legacy settings");
45 let before_paths = relative_paths(&sealed_home);
46 let before_legacy = fs::read(&legacy).expect("read synthetic legacy store");
47
48 // The diagnostic runs entirely in-process: the single `codewhale` binary
49 // dispatches through `run_tui_in_process` -> `codewhale_tui::run`. No
50 // `DEEPSEEK_TUI_BIN` sibling is spawned, so there is no receipt to read;
51 // assert the in-process behavior and the read-only invariants instead.
52 let mut command = Command::new(codewhale_binary());
53 command
54 .args(args)
55 .env_clear()
56 .env("HOME", &sealed_home)
57 .env("USERPROFILE", &sealed_home)
58 .env("CODEWHALE_HOME", &codewhale_home)
59 .env("CODEWHALE_SECRET_BACKEND", "file");
60 preserve_host_rustup_home(&mut command);
61 let output = command.output().expect("run dispatcher diagnostic");
62
63 assert!(
64 output.status.success(),
65 "dispatcher {args:?} failed\nstdout:\n{}\nstderr:\n{}",
66 String::from_utf8_lossy(&output.stdout),
67 String::from_utf8_lossy(&output.stderr)
68 );
69
70 if expects_entries_json {
71 let report: serde_json::Value = serde_json::from_slice(&output.stdout)
72 .unwrap_or_else(|error| {
73 panic!(
74 "doctor --context-json must emit a machine-readable context source map: {error}\nstdout:\n{}\nstderr:\n{}",
75 String::from_utf8_lossy(&output.stdout),
76 String::from_utf8_lossy(&output.stderr)
77 )
78 });
79 assert!(
80 report["entries"].is_array(),
81 "doctor --context-json must carry an `entries` array\nstdout:\n{}",
82 String::from_utf8_lossy(&output.stdout)
83 );
84 }
85
86 if expects_auth_diagnostic {
87 let stdout = String::from_utf8_lossy(&output.stdout);
88 assert!(
89 stdout.contains(
90 "auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)"
91 ),
92 "{stdout}"
93 );
94 assert!(
95 stdout.contains(&format!(
96 "codewhale home: {}",
97 codewhale_config::quote_os_path(&codewhale_home)
98 )),
99 "{stdout}"
100 );
101 assert!(
102 stdout.contains(&format!(
103 "config: {}",
104 codewhale_config::quote_os_path(&codewhale_home.join("config.toml"))
105 )),
106 "{stdout}"
107 );
108 assert!(
109 stdout.contains(&format!(
110 "settings: {}",
111 codewhale_config::quote_os_path(&codewhale_home.join("settings.toml"))
112 )),
113 "{stdout}"
114 );
115 assert!(
116 stdout.contains("secret backend: file (inspection: metadata_only)"),
117 "{stdout}"
118 );
119 assert!(
120 stdout.contains(
121 "legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"
122 ),
123 "{stdout}"
124 );
125 assert!(!stdout.contains("synthetic-legacy-fixture"), "{stdout}");
126 }
127
128 assert_eq!(
129 relative_paths(&sealed_home),
130 before_paths,
131 "dispatcher {args:?} must not create or migrate state below HOME"
132 );
133 assert_eq!(
134 fs::read(&legacy).expect("read synthetic legacy store after diagnostic"),
135 before_legacy,
136 "dispatcher {args:?} must not rewrite the legacy store"
137 );
138 assert_eq!(
139 fs::read(&legacy_settings).expect("read legacy settings after diagnostic"),
140 legacy_settings_bytes,
141 "dispatcher {args:?} must not rewrite legacy settings"
142 );
143 assert!(
144 !primary_home.exists(),
145 "dispatcher {args:?} must not create a primary Codewhale home or migrated state"
146 );
147 assert!(
148 !codewhale_home.exists(),
149 "dispatcher {args:?} must not create an explicit CODEWHALE_HOME"
150 );
151 }
152 }
153
154 fn relative_paths(root: &Path) -> Vec<PathBuf> {
155 let mut paths = Vec::new();
156 collect_relative_paths(root, root, &mut paths);
157 paths.sort();
158 paths
159 }
160
161 fn collect_relative_paths(root: &Path, current: &Path, paths: &mut Vec<PathBuf>) {
162 let entries = fs::read_dir(current).expect("read synthetic state directory");
163 for entry in entries {
164 let entry = entry.expect("synthetic state directory entry");
165 let path = entry.path();
166 paths.push(
167 path.strip_prefix(root)
168 .expect("synthetic path below root")
169 .to_path_buf(),
170 );
171 if entry.file_type().expect("synthetic entry type").is_dir() {
172 collect_relative_paths(root, &path, paths);
173 }
174 }
175 }
176
177 fn codewhale_binary() -> PathBuf {
178 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
179 return PathBuf::from(path);
180 }
181 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
182 return PathBuf::from(path);
183 }
184
185 let mut path = std::env::current_exe().expect("current test executable path");
186 path.pop();
187 if path.ends_with("deps") {
188 path.pop();
189 }
190 path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
191 path
192 }
193
194 /// A rustup shim may initialize its own toolchain state below `$HOME` when
195 /// `doctor` asks `rustc --version`. Preserve an already-configured toolchain
196 /// root so this test isolates Codewhale's own state contract.
197 fn preserve_host_rustup_home(command: &mut Command) {
198 let rustup_home = std::env::var_os("RUSTUP_HOME")
199 .map(PathBuf::from)
200 .or_else(|| {
201 std::env::var_os("HOME")
202 .map(PathBuf::from)
203 .map(|home| home.join(".rustup"))
204 .filter(|path| path.is_dir())
205 });
206 if let Some(rustup_home) = rustup_home {
207 command.env("RUSTUP_HOME", rustup_home);
208 }
209 }
210
210 lines RUST