1use std::collections::HashMap;
25use std::ffi::OsString;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28use std::time::Duration;
29
30pub const TOKEN_ENV: &str = "SGIAN_CLIENT_TOKEN";
32pub const BIN_ENV: &str = "KRANZ_SGIAN_BIN";
34pub const DEADLINE: Duration = Duration::from_secs(5);
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SgianCredential {
42 pub id: String,
44 pub holder: String,
46 bin: PathBuf,
47 workspace: PathBuf,
48}
49
50pub(crate) struct RevocationGuard(pub SgianCredential);
52
53impl Drop for RevocationGuard {
54 fn drop(&mut self) {
55 self.0.revoke();
56 }
57}
58
59pub fn holder_for(run_id: &str) -> String {
61 format!("kranz:{run_id}")
62}
63
64pub fn issue(workspace: &Path, run_id: &str) -> Option<(SgianCredential, String)> {
68 let bin = resolve_bin(std::env::var_os(BIN_ENV), std::env::var_os("PATH"))?;
69 issue_with(&bin, workspace, run_id, DEADLINE)
70}
71
72pub fn issue_with(
74 bin: &Path,
75 workspace: &Path,
76 run_id: &str,
77 deadline: Duration,
78) -> Option<(SgianCredential, String)> {
79 let holder = holder_for(run_id);
80 let args = [
81 "ctl",
82 "--workspace",
83 &workspace.display().to_string(),
84 "--json",
85 "identity",
86 "issue",
87 "--holder",
88 &holder,
89 "--scope",
90 "write",
91 ];
92 let output = match run_ctl(bin, &args, deadline) {
93 Ok(output) => output,
94 Err(reason) => {
95 tracing::debug!(
96 run_id,
97 workspace = %workspace.display(),
98 reason,
99 "sgian credential not issued; the session runs without one"
100 );
101 return None;
102 }
103 };
104 let record: serde_json::Value = match serde_json::from_slice(&output) {
105 Ok(record) => record,
106 Err(error) => {
107 tracing::warn!(
108 run_id,
109 error = %error,
110 "sgian identity issue replied with something other than a credential record"
111 );
112 return None;
113 }
114 };
115 let id = record.get("id").and_then(serde_json::Value::as_str);
116 let issued = record.get("token").and_then(serde_json::Value::as_str);
117 let (Some(id), Some(token)) = (id, issued) else {
118 tracing::warn!(
119 run_id,
120 "sgian identity issue record lacks an id or token; ignoring it"
121 );
122 return None;
123 };
124 if id.is_empty() || token.is_empty() {
125 return None;
126 }
127 tracing::info!(
128 run_id,
129 id,
130 holder = %holder,
131 "sgian credential issued for the run (revoked when the run ends)"
132 );
133 Some((
134 SgianCredential {
135 id: id.to_string(),
136 holder,
137 bin: bin.to_path_buf(),
138 workspace: workspace.to_path_buf(),
139 },
140 token.to_string(),
141 ))
142}
143
144impl SgianCredential {
145 pub fn revoke(&self) {
148 self.revoke_with(DEADLINE);
149 }
150
151 pub fn revoke_with(&self, deadline: Duration) {
153 let args = [
154 "ctl",
155 "--workspace",
156 &self.workspace.display().to_string(),
157 "--json",
158 "identity",
159 "revoke",
160 &self.id,
161 ];
162 match run_ctl(&self.bin, &args, deadline) {
163 Ok(_) => tracing::info!(
164 id = %self.id,
165 holder = %self.holder,
166 "sgian credential revoked"
167 ),
168 Err(reason) => tracing::warn!(
169 id = %self.id,
170 holder = %self.holder,
171 reason,
172 "sgian credential could not be revoked; revoke it by hand with \
173 `sgian ctl identity revoke` if the daemon is still running"
174 ),
175 }
176 }
177}
178
179pub fn resolve_bin(override_var: Option<OsString>, path_var: Option<OsString>) -> Option<PathBuf> {
182 match override_var {
183 Some(value) if value.is_empty() => None,
184 Some(value) => Some(PathBuf::from(value)),
185 None => std::env::split_paths(&path_var?).find_map(|dir| {
186 let candidate = dir.join(BIN_NAME);
187 candidate.is_file().then_some(candidate)
188 }),
189 }
190}
191
192#[cfg(windows)]
193const BIN_NAME: &str = "sgian.exe";
194#[cfg(not(windows))]
195const BIN_NAME: &str = "sgian";
196
197fn run_ctl(bin: &Path, args: &[&str], deadline: Duration) -> Result<Vec<u8>, String> {
201 let mut command = Command::new(bin);
202 command
203 .args(args)
204 .env_clear()
205 .envs(crate::agent_env::probe_child_env(&[]));
206 let output = crate::git_ops::process::output(
207 command,
208 crate::git_ops::process::Limits::for_control(deadline),
209 )
210 .map_err(|error| format!("{} control call failed: {error}", bin.display()))?;
211 if output.status.success() {
212 Ok(output.stdout)
213 } else {
214 Err(format!("{} exited with {}", bin.display(), output.status))
215 }
216}
217
218pub fn seed_env(env: &mut HashMap<String, String>, token: String) {
221 env.insert(TOKEN_ENV.to_string(), token);
222}
223
224#[cfg(all(test, unix))]
225mod tests {
226 use super::*;
227 use std::os::unix::fs::PermissionsExt;
228 use std::time::Instant;
229
230 fn fake_sgian(dir: &Path, body: &str) -> PathBuf {
233 let bin = dir.join("sgian");
234 let calls = dir.join("calls");
235 std::fs::write(
236 &bin,
237 format!(
238 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n{body}\n",
239 calls.display()
240 ),
241 )
242 .unwrap();
243 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
244 bin
245 }
246
247 fn calls(dir: &Path) -> Vec<String> {
248 std::fs::read_to_string(dir.join("calls"))
249 .unwrap_or_default()
250 .lines()
251 .map(str::to_string)
252 .collect()
253 }
254
255 #[test]
256 fn issue_parses_the_record_and_revoke_uses_its_id() {
257 let dir = tempfile::tempdir().unwrap();
258 let ws = dir.path().join("repo");
259 std::fs::create_dir_all(&ws).unwrap();
260 let bin = fake_sgian(
261 dir.path(),
262 r#"case "$*" in *"identity issue"*) printf '{"id":"cred-7","holder":"kranz:run-1","scopes":["write"],"token":"sgc_t"}\n';; *) printf '{"id":"cred-7","revoked":true}\n';; esac"#,
263 );
264 let (cred, token) = issue_with(&bin, &ws, "run-1", DEADLINE).expect("credential issued");
268 assert_eq!(cred.id, "cred-7");
269 assert_eq!(cred.holder, "kranz:run-1");
270 assert_eq!(token, "sgc_t");
271 cred.revoke();
272 let calls = calls(dir.path());
273 assert_eq!(
274 calls,
275 vec![
276 format!(
277 "ctl --workspace {} --json identity issue --holder kranz:run-1 --scope write",
278 ws.display()
279 ),
280 format!(
281 "ctl --workspace {} --json identity revoke cred-7",
282 ws.display()
283 ),
284 ]
285 );
286 }
287
288 #[test]
289 fn issue_is_none_when_ctl_fails_or_answers_nonsense() {
290 let dir = tempfile::tempdir().unwrap();
291 let ws = dir.path().to_path_buf();
292 let failing = fake_sgian(
293 dir.path(),
294 "echo 'no daemon serves this workspace' >&2; exit 1",
295 );
296 assert!(issue_with(&failing, &ws, "run-2", DEADLINE).is_none());
297 let garbage = fake_sgian(dir.path(), "echo 'not json'");
298 assert!(issue_with(&garbage, &ws, "run-2", DEADLINE).is_none());
299 let partial = fake_sgian(dir.path(), r#"printf '{"id":"x"}\n'"#);
300 assert!(issue_with(&partial, &ws, "run-2", DEADLINE).is_none());
301 }
302
303 #[test]
304 fn issue_gives_up_on_a_wedged_client() {
305 let dir = tempfile::tempdir().unwrap();
306 let bin = fake_sgian(dir.path(), "sleep 5");
307 let started = Instant::now();
308 assert!(issue_with(&bin, dir.path(), "run-3", Duration::from_millis(200)).is_none());
309 assert!(
310 started.elapsed() < Duration::from_secs(3),
311 "the deadline must cut the wait short"
312 );
313 }
314
315 #[test]
316 fn sgian_control_deadline_covers_pipes_after_leader_exit() {
317 let dir = tempfile::tempdir().unwrap();
318 let marker = dir.path().join("descendant-ran");
319 let bin = fake_sgian(
320 dir.path(),
321 &format!("(sleep 1; touch '{}') & exit 0", marker.display()),
322 );
323 assert!(run_ctl(&bin, &[], Duration::from_secs(3)).is_ok());
325 assert!(marker.exists());
326 std::fs::remove_file(&marker).unwrap();
327 let started = Instant::now();
328 assert!(run_ctl(&bin, &[], Duration::from_millis(100)).is_err());
329 assert!(started.elapsed() < Duration::from_secs(3));
330 std::thread::sleep(Duration::from_millis(1200));
331 assert!(!marker.exists(), "timed-out descendants must not continue");
332 }
333
334 #[test]
335 fn sgian_control_refuses_oversize_output_and_does_not_log_reply_bytes() {
336 let dir = tempfile::tempdir().unwrap();
337 for stream in ["", " >&2"] {
338 let bin = fake_sgian(dir.path(), &format!("head -c 65537 /dev/zero{stream}"));
339 assert!(run_ctl(&bin, &[], DEADLINE).is_err());
340 }
341 let bin = fake_sgian(dir.path(), "echo 'private-reply' >&2; exit 1");
342 let error = run_ctl(&bin, &[], DEADLINE).unwrap_err();
343 assert!(!error.contains("private-reply"));
344 }
345
346 #[test]
347 fn sgian_control_keeps_discovery_environment_without_ambient_secrets() {
348 let name =
349 "sgian::tests::sgian_control_keeps_discovery_environment_without_ambient_secrets";
350 if crate::agent_env::isolated_global_home_test(name) {
351 return;
352 }
353 std::env::set_var("KRANZ_SGIAN_PRIVATE_FIXTURE", "private-value");
354 std::env::set_var(TOKEN_ENV, "private-value");
355 let dir = tempfile::tempdir().unwrap();
356 let bin = fake_sgian(dir.path(), "env");
357 let output = run_ctl(&bin, &[], DEADLINE).unwrap();
358 let env = String::from_utf8(output).unwrap();
359 assert!(env.lines().any(|line| line.starts_with("HOME=")));
360 assert!(env.lines().any(|line| line.starts_with("PATH=")));
361 assert!(!env.contains("KRANZ_SGIAN_PRIVATE_FIXTURE="));
362 assert!(!env.contains("SGIAN_CLIENT_TOKEN="));
363 }
364
365 #[test]
366 fn issue_is_none_when_the_binary_is_missing() {
367 let dir = tempfile::tempdir().unwrap();
368 assert!(issue_with(&dir.path().join("absent"), dir.path(), "run-4", DEADLINE).is_none());
369 }
370
371 #[test]
372 fn resolve_bin_honours_the_override_and_searches_path() {
373 let dir = tempfile::tempdir().unwrap();
374 let bin = fake_sgian(dir.path(), "true");
375 assert_eq!(
376 resolve_bin(Some(OsString::from("/opt/sgian")), None),
377 Some(PathBuf::from("/opt/sgian"))
378 );
379 assert_eq!(
380 resolve_bin(Some(OsString::new()), Some(dir.path().into())),
381 None
382 );
383 let path =
384 std::env::join_paths([dir.path().join("nowhere"), dir.path().to_path_buf()]).unwrap();
385 assert_eq!(resolve_bin(None, Some(path)), Some(bin));
386 assert_eq!(
387 resolve_bin(None, Some(OsString::from(dir.path().join("nowhere")))),
388 None
389 );
390 assert_eq!(resolve_bin(None, None), None);
391 }
392
393 #[test]
394 fn holder_names_the_run() {
395 assert_eq!(holder_for("abc"), "kranz:abc");
396 }
397}