返回 CodeWhale
credential_handoff_dispatch.rs
根目录 / crates / cli / tests / credential_handoff_dispatch.rs
1 //! Installed-dispatcher acceptance for the pipe-only credential handoff.
2
3 #![cfg(unix)]
4
5 use std::fs::{self, File};
6 use std::os::fd::FromRawFd;
7 use std::path::{Path, PathBuf};
8 use std::process::{Command, Output, Stdio};
9
10 use codewhale_secrets::{FileKeyringStore, KeyringStore};
11 use tempfile::TempDir;
12
13 const SENTINEL: &str = "cw-handoff-child-sentinel-7b30";
14 const TERMINAL_ERROR: &str =
15 "error: refusing terminal output; pipe credential handoff to the intended local client\n";
16
17 #[test]
18 fn terminal_refusal_precedes_config_reads_and_secret_migration() {
19 let malformed = TempDir::new().expect("malformed fixture root");
20 let malformed_home = malformed.path().join("home");
21 let malformed_codewhale_home = malformed.path().join("codewhale-home");
22 fs::create_dir_all(&malformed_codewhale_home).expect("create explicit Codewhale home");
23 let malformed_config = malformed_codewhale_home.join("config.toml");
24 let malformed_bytes = format!("provider = \"openrouter\"\ninvalid = [{SENTINEL}\n");
25 fs::write(&malformed_config, &malformed_bytes).expect("write malformed config trap");
26
27 let mut command = isolated_command(&malformed_home, Some(&malformed_codewhale_home));
28 command.args(["auth", "print-api-key", "--provider", "openrouter"]);
29 let output = output_with_terminal_stdout(command);
30 assert!(!output.status.success(), "terminal handoff must refuse");
31 assert_eq!(String::from_utf8_lossy(&output.stderr), TERMINAL_ERROR);
32 assert_eq!(
33 fs::read_to_string(&malformed_config).expect("malformed config remains readable"),
34 malformed_bytes,
35 );
36 assert!(
37 !malformed_codewhale_home.join("secrets").exists(),
38 "terminal preflight must not construct durable credential state"
39 );
40
41 let migration = TempDir::new().expect("migration fixture root");
42 let sealed_home = migration.path().join("home");
43 let primary_home = sealed_home.join(".codewhale");
44 fs::create_dir_all(&primary_home).expect("create primary home");
45 fs::write(
46 primary_home.join("config.toml"),
47 "provider = \"openrouter\"\n",
48 )
49 .expect("write valid config");
50 let legacy_store = sealed_home
51 .join(".deepseek")
52 .join("secrets")
53 .join("secrets.json");
54 FileKeyringStore::new(&legacy_store)
55 .set("openrouter", SENTINEL)
56 .expect("seed synthetic legacy credential");
57 let legacy_before = fs::read(&legacy_store).expect("read legacy fixture");
58 let primary_store = primary_home.join("secrets").join("secrets.json");
59
60 let mut command = isolated_command(&sealed_home, None);
61 command.args(["auth", "print-api-key", "--provider", "openrouter"]);
62 let output = output_with_terminal_stdout(command);
63 assert!(!output.status.success(), "terminal handoff must refuse");
64 assert_eq!(String::from_utf8_lossy(&output.stderr), TERMINAL_ERROR);
65 assert_eq!(
66 fs::read(&legacy_store).expect("legacy store remains readable"),
67 legacy_before,
68 "terminal preflight must not rewrite the legacy credential store"
69 );
70 assert!(
71 !primary_store.exists(),
72 "terminal preflight must not migrate a legacy credential"
73 );
74 }
75
76 #[test]
77 fn initialization_failures_are_source_free() {
78 let fixture = TempDir::new().expect("fixture root");
79 let sealed_home = fixture.path().join("home");
80 let codewhale_home = fixture.path().join(format!("codewhale-home-{SENTINEL}"));
81 fs::create_dir_all(&codewhale_home).expect("create Codewhale home");
82 fs::write(
83 codewhale_home.join("config.toml"),
84 format!("invalid = [{SENTINEL}\n"),
85 )
86 .expect("write malformed config trap");
87
88 let mut command = isolated_command(&sealed_home, Some(&codewhale_home));
89 let output = command
90 .args([
91 "--api-key",
92 SENTINEL,
93 "auth",
94 "print-api-key",
95 "--provider",
96 "openrouter",
97 ])
98 .output()
99 .expect("run pipe handoff with malformed initialization");
100
101 assert!(!output.status.success());
102 assert!(output.stdout.is_empty());
103 assert_eq!(
104 String::from_utf8_lossy(&output.stderr),
105 "error: unavailable credential\n"
106 );
107 assert!(
108 !String::from_utf8_lossy(&output.stderr).contains(SENTINEL),
109 "neither a secret nor a sentinel-bearing path may reach stderr"
110 );
111 }
112
113 #[test]
114 fn installed_unix_dispatcher_settles_a_closed_pipe_cleanly() {
115 let fixture = TempDir::new().expect("fixture root");
116 let sealed_home = fixture.path().join("home");
117 let codewhale_home = fixture.path().join("codewhale-home");
118 let mut command = isolated_command(&sealed_home, Some(&codewhale_home));
119 let output = command
120 .args([
121 "--api-key",
122 SENTINEL,
123 "auth",
124 "print-api-key",
125 "--provider",
126 "openrouter",
127 ])
128 .stdout(closed_pipe_writer())
129 .output()
130 .expect("run installed dispatcher against a closed pipe");
131
132 assert!(
133 output.status.success(),
134 "closed consumer must be a clean settlement, not SIGPIPE or failure: {:?}\nstderr:\n{}",
135 output.status,
136 String::from_utf8_lossy(&output.stderr)
137 );
138 // A sealed fresh home has never seen the usage policy, so the only thing
139 // allowed on stderr is that one-time disclosure. Nothing else may leak.
140 let stderr = String::from_utf8_lossy(&output.stderr);
141 assert!(
142 stderr.is_empty()
143 || stderr == format!("{}\n", codewhale_telemetry::notice::STARTUP_DISCLOSURE),
144 "unexpected stderr: {stderr}"
145 );
146 assert!(!stderr.contains(SENTINEL));
147 }
148
149 fn isolated_command(home: &Path, codewhale_home: Option<&Path>) -> Command {
150 let mut command = Command::new(codewhale_binary());
151 command
152 .env_clear()
153 .env("HOME", home)
154 .env("USERPROFILE", home)
155 .env("CODEWHALE_SECRET_BACKEND", "file")
156 .stdin(Stdio::null())
157 .stderr(Stdio::piped());
158 if let Some(codewhale_home) = codewhale_home {
159 command.env("CODEWHALE_HOME", codewhale_home);
160 }
161 command
162 }
163
164 fn output_with_terminal_stdout(mut command: Command) -> Output {
165 let mut master_fd = -1;
166 let mut slave_fd = -1;
167 // SAFETY: `openpty` initializes both descriptors on success. Each is
168 // immediately transferred into exactly one `File`, which owns the close.
169 let result = unsafe {
170 libc::openpty(
171 &mut master_fd,
172 &mut slave_fd,
173 std::ptr::null_mut(),
174 std::ptr::null_mut(),
175 std::ptr::null_mut(),
176 )
177 };
178 assert_eq!(result, 0, "open a pseudo-terminal");
179 // SAFETY: successful `openpty` returned two fresh owned descriptors.
180 let master = unsafe { File::from_raw_fd(master_fd) };
181 // SAFETY: successful `openpty` returned two fresh owned descriptors.
182 let slave = unsafe { File::from_raw_fd(slave_fd) };
183 let output = command
184 .stdout(Stdio::from(slave))
185 .output()
186 .expect("run dispatcher with terminal stdout");
187 drop(master);
188 output
189 }
190
191 fn closed_pipe_writer() -> Stdio {
192 let mut descriptors = [-1; 2];
193 // SAFETY: `pipe` initializes both descriptors on success.
194 let result = unsafe { libc::pipe(descriptors.as_mut_ptr()) };
195 assert_eq!(result, 0, "create pipe fixture");
196 // No reader exists before the child starts, so its first write
197 // deterministically receives EPIPE (or SIGPIPE if the guard regresses).
198 // SAFETY: `descriptors[0]` is a fresh descriptor and is closed once here.
199 assert_eq!(unsafe { libc::close(descriptors[0]) }, 0);
200 // SAFETY: `descriptors[1]` is the remaining fresh owned descriptor.
201 let writer = unsafe { File::from_raw_fd(descriptors[1]) };
202 Stdio::from(writer)
203 }
204
205 fn codewhale_binary() -> PathBuf {
206 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
207 return PathBuf::from(path);
208 }
209 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
210 return PathBuf::from(path);
211 }
212
213 let mut path = std::env::current_exe().expect("current test executable path");
214 path.pop();
215 if path.ends_with("deps") {
216 path.pop();
217 }
218 path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
219 path
220 }
221
221 lines RUST