返回 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 assert!(output.stderr.is_empty());
139 }
140
141 fn isolated_command(home: &Path, codewhale_home: Option<&Path>) -> Command {
142 let mut command = Command::new(codewhale_binary());
143 command
144 .env_clear()
145 .env("HOME", home)
146 .env("USERPROFILE", home)
147 .env("CODEWHALE_SECRET_BACKEND", "file")
148 .stdin(Stdio::null())
149 .stderr(Stdio::piped());
150 if let Some(codewhale_home) = codewhale_home {
151 command.env("CODEWHALE_HOME", codewhale_home);
152 }
153 command
154 }
155
156 fn output_with_terminal_stdout(mut command: Command) -> Output {
157 let mut master_fd = -1;
158 let mut slave_fd = -1;
159 // SAFETY: `openpty` initializes both descriptors on success. Each is
160 // immediately transferred into exactly one `File`, which owns the close.
161 let result = unsafe {
162 libc::openpty(
163 &mut master_fd,
164 &mut slave_fd,
165 std::ptr::null_mut(),
166 std::ptr::null_mut(),
167 std::ptr::null_mut(),
168 )
169 };
170 assert_eq!(result, 0, "open a pseudo-terminal");
171 // SAFETY: successful `openpty` returned two fresh owned descriptors.
172 let master = unsafe { File::from_raw_fd(master_fd) };
173 // SAFETY: successful `openpty` returned two fresh owned descriptors.
174 let slave = unsafe { File::from_raw_fd(slave_fd) };
175 let output = command
176 .stdout(Stdio::from(slave))
177 .output()
178 .expect("run dispatcher with terminal stdout");
179 drop(master);
180 output
181 }
182
183 fn closed_pipe_writer() -> Stdio {
184 let mut descriptors = [-1; 2];
185 // SAFETY: `pipe` initializes both descriptors on success.
186 let result = unsafe { libc::pipe(descriptors.as_mut_ptr()) };
187 assert_eq!(result, 0, "create pipe fixture");
188 // No reader exists before the child starts, so its first write
189 // deterministically receives EPIPE (or SIGPIPE if the guard regresses).
190 // SAFETY: `descriptors[0]` is a fresh descriptor and is closed once here.
191 assert_eq!(unsafe { libc::close(descriptors[0]) }, 0);
192 // SAFETY: `descriptors[1]` is the remaining fresh owned descriptor.
193 let writer = unsafe { File::from_raw_fd(descriptors[1]) };
194 Stdio::from(writer)
195 }
196
197 fn codewhale_binary() -> PathBuf {
198 if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
199 return PathBuf::from(path);
200 }
201 if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
202 return PathBuf::from(path);
203 }
204
205 let mut path = std::env::current_exe().expect("current test executable path");
206 path.pop();
207 if path.ends_with("deps") {
208 path.pop();
209 }
210 path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
211 path
212 }
213
213 lines RUST