sley_remote/
credentials.rs1use std::io::Write;
10use std::process::{Command, Stdio};
11
12use sley_config::GitConfig;
13use sley_core::Result;
14use sley_transport::{
15 GitCredential, RemoteTransport, RemoteUrl, encode_git_credential, parse_git_credential,
16};
17
18use crate::CredentialProvider;
19
20pub fn http_protocol_name(remote: &RemoteUrl) -> Option<String> {
22 match remote.transport {
23 RemoteTransport::Https => Some("https".to_string()),
24 RemoteTransport::Http => Some("http".to_string()),
25 _ => None,
26 }
27}
28
29pub fn http_credential_host(remote: &RemoteUrl) -> Option<String> {
31 remote.host.clone().map(|host| match remote.port {
32 Some(port) => format!("{host}:{port}"),
33 None => host,
34 })
35}
36
37pub fn http_url_credential(remote: &RemoteUrl) -> Option<GitCredential> {
39 let username = remote.user.clone()?;
40 Some(GitCredential {
41 protocol: http_protocol_name(remote),
42 host: http_credential_host(remote),
43 username: Some(username),
44 password: remote.password.clone(),
45 ..GitCredential::default()
46 })
47}
48
49pub fn credential_request_for_url(remote: &RemoteUrl) -> GitCredential {
51 GitCredential {
52 protocol: http_protocol_name(remote),
53 host: http_credential_host(remote),
54 username: remote.user.clone(),
55 ..GitCredential::default()
56 }
57}
58
59fn credential_helper_specs(config: Option<&GitConfig>) -> Vec<String> {
62 let Some(config) = config else {
63 return Vec::new();
64 };
65 let mut specs = Vec::new();
66 for section in &config.sections {
67 if section.name != "credential" || section.subsection.is_some() {
68 continue;
69 }
70 for entry in §ion.entries {
71 if !entry.key.eq_ignore_ascii_case("helper") {
72 continue;
73 }
74 match entry.value.as_deref() {
75 Some("") | None => specs.clear(),
76 Some(value) => specs.push(value.to_string()),
77 }
78 }
79 }
80 specs
81}
82
83fn credential_helper_command(spec: &str, op: &str) -> Option<Command> {
104 let spec = spec.trim();
105 if spec.is_empty() {
106 return None;
107 }
108 if let Some(shell) = spec.strip_prefix('!') {
109 let mut command = Command::new("sh");
112 command
113 .arg("-c")
114 .arg(format!("{shell} \"$@\""))
115 .arg("sh")
116 .arg(op);
117 return Some(command);
118 }
119 let mut tokens = spec.split_whitespace();
120 let head = tokens.next()?;
121 let program = if head.starts_with('/') {
126 head.to_string()
127 } else {
128 format!("git-credential-{head}")
129 };
130 let mut command = Command::new(program);
131 for arg in tokens {
132 command.arg(arg);
133 }
134 command.arg(op);
135 Some(command)
136}
137
138fn run_credential_helper(spec: &str, op: &str, input: &[u8]) -> Result<Option<Vec<u8>>> {
141 let Some(mut command) = credential_helper_command(spec, op) else {
142 return Ok(None);
143 };
144 command
145 .stdin(Stdio::piped())
146 .stdout(Stdio::piped())
147 .stderr(Stdio::null());
148 let mut child = match command.spawn() {
149 Ok(child) => child,
150 Err(_) => return Ok(None),
151 };
152 if let Some(mut stdin) = child.stdin.take() {
153 stdin.write_all(input)?;
154 }
155 let output = child.wait_with_output()?;
156 if !output.status.success() {
157 return Ok(None);
158 }
159 Ok(Some(output.stdout))
160}
161
162pub fn credential_fill(
165 config: Option<&GitConfig>,
166 mut request: GitCredential,
167) -> Result<Option<GitCredential>> {
168 for spec in credential_helper_specs(config) {
169 if request.username.is_some() && request.password.is_some() {
170 break;
171 }
172 let input = encode_git_credential(&request)?;
173 if let Some(stdout) = run_credential_helper(&spec, "get", &input)? {
174 let filled = parse_git_credential(&stdout)?;
175 if filled.username.is_some() {
176 request.username = filled.username;
177 }
178 if filled.password.is_some() {
179 request.password = filled.password;
180 }
181 }
182 }
183 if request.username.is_some() && request.password.is_some() {
184 Ok(Some(request))
185 } else {
186 Ok(None)
187 }
188}
189
190pub fn credential_store(config: Option<&GitConfig>, credential: &GitCredential, approve: bool) {
192 let Ok(input) = encode_git_credential(credential) else {
193 return;
194 };
195 let op = if approve { "store" } else { "erase" };
196 for spec in credential_helper_specs(config) {
197 let _ = run_credential_helper(&spec, op, &input);
198 }
199}
200
201pub struct CredentialHelperProvider<'a> {
204 config: Option<&'a GitConfig>,
205}
206
207impl<'a> CredentialHelperProvider<'a> {
208 pub fn new(config: Option<&'a GitConfig>) -> Self {
210 Self { config }
211 }
212}
213
214impl CredentialProvider for CredentialHelperProvider<'_> {
215 fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>> {
216 credential_fill(self.config, request)
217 }
218
219 fn approve(&mut self, credential: &GitCredential) -> Result<()> {
220 credential_store(self.config, credential, true);
221 Ok(())
222 }
223
224 fn reject(&mut self, credential: &GitCredential) -> Result<()> {
225 credential_store(self.config, credential, false);
226 Ok(())
227 }
228}
229
230#[cfg(all(test, unix))]
231mod credential_dispatch_parity_tests {
232 use std::fs;
254 use std::os::unix::fs::PermissionsExt;
255 use std::path::Path;
256
257 use sley_config::GitConfig;
258 use sley_transport::GitCredential;
259
260 use super::{credential_fill, credential_helper_command};
261
262 fn config_with_helper(helper: &str) -> GitConfig {
269 let escaped = helper.replace('\\', "\\\\").replace('"', "\\\"");
271 let body = format!("[credential]\n\thelper = \"{escaped}\"\n");
272 GitConfig::parse(body.as_bytes()).expect("config parses")
273 }
274
275 fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
277 let path = dir.join(name);
278 fs::write(&path, body).expect("write script");
279 let mut perms = fs::metadata(&path).expect("metadata").permissions();
280 perms.set_mode(0o755);
281 fs::set_permissions(&path, perms).expect("chmod");
282 path
283 }
284
285 fn base_request() -> GitCredential {
286 GitCredential {
287 protocol: Some("https".to_string()),
288 host: Some("example.com".to_string()),
289 ..GitCredential::default()
290 }
291 }
292
293 #[test]
297 fn absolute_path_form_passes_args_and_op() {
298 let tmp = tempdir();
299 let marker = tmp.path().join("abs.out");
300 let script = write_script(
301 tmp.path(),
302 "abs-helper.sh",
303 &format!(
304 "#!/bin/sh\ncat >/dev/null\nprintf 'ARGS:[%s]\\n' \"$*\" >> '{}'\necho username=abs-user\necho password=abs-pass\n",
305 marker.display()
306 ),
307 );
308 let cfg = config_with_helper(&format!("{} --flag", script.display()));
309 let filled = credential_fill(Some(&cfg), base_request())
310 .expect("fill ok")
311 .expect("credential filled");
312 assert_eq!(filled.username.as_deref(), Some("abs-user"));
313 assert_eq!(filled.password.as_deref(), Some("abs-pass"));
314 let recorded = fs::read_to_string(&marker).expect("marker written");
315 assert_eq!(recorded.trim(), "ARGS:[--flag get]");
317 }
318
319 #[test]
323 fn shell_snippet_form_runs_through_shell_with_op_arg() {
324 let tmp = tempdir();
325 let marker = tmp.path().join("snip.out");
326 let helper = format!(
327 "!f() {{ cat >/dev/null; printf 'GOT:[%s]\\n' \"$*\" >> '{}'; echo username=snip-user; echo password=snip-pass; }}; f",
328 marker.display()
329 );
330 let cfg = config_with_helper(&helper);
331 let filled = credential_fill(Some(&cfg), base_request())
332 .expect("fill ok")
333 .expect("credential filled");
334 assert_eq!(filled.username.as_deref(), Some("snip-user"));
335 assert_eq!(filled.password.as_deref(), Some("snip-pass"));
336 let recorded = fs::read_to_string(&marker).expect("marker written");
337 assert_eq!(recorded.trim(), "GOT:[get]");
339 }
340
341 #[test]
350 fn relative_slash_name_is_bare_not_path() {
351 let cmd = credential_helper_command("sub/relhelper", "get").expect("command built");
352 let program = command_program(&cmd);
356 assert_ne!(
357 program, "sub/relhelper",
358 "relative slash name must not be exec'd directly (git would prefix it)"
359 );
360 assert!(
361 program.contains("git-credential-sub/relhelper")
362 || program == "sh"
363 || program == "/bin/sh",
364 "expected git-credential-<name> dispatch, got program {program:?}"
365 );
366 }
367
368 #[test]
376 fn plain_bare_name_maps_to_credential_binary() {
377 let cmd = credential_helper_command("myhelper --opt val", "get").expect("command built");
378 let argv = command_argv(&cmd);
379 assert!(
382 argv[0].contains("git-credential-myhelper") || argv[0] == "sh" || argv[0] == "/bin/sh",
383 "expected git-credential-<name> dispatch, got argv {argv:?}"
384 );
385 assert_ne!(argv[0], "git", "must not shell out to the git binary");
386 let rendered = argv.join(" ");
389 assert!(
390 rendered.contains("git-credential-myhelper")
391 && rendered.contains("--opt")
392 && rendered.contains("val")
393 && rendered.contains("get"),
394 "expected `git-credential-myhelper --opt val get` dispatch, got {rendered:?}"
395 );
396 }
397
398 fn command_program(cmd: &std::process::Command) -> String {
402 cmd.get_program().to_string_lossy().into_owned()
403 }
404
405 fn command_argv(cmd: &std::process::Command) -> Vec<String> {
407 let mut out = vec![cmd.get_program().to_string_lossy().into_owned()];
408 out.extend(cmd.get_args().map(|a| a.to_string_lossy().into_owned()));
409 out
410 }
411
412 struct TempDir {
414 path: std::path::PathBuf,
415 }
416 impl TempDir {
417 fn path(&self) -> &Path {
418 &self.path
419 }
420 }
421 impl Drop for TempDir {
422 fn drop(&mut self) {
423 let _ = fs::remove_dir_all(&self.path);
424 }
425 }
426 fn tempdir() -> TempDir {
427 use std::sync::atomic::{AtomicU64, Ordering};
428 static COUNTER: AtomicU64 = AtomicU64::new(0);
429 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
430 let pid = std::process::id();
431 let path = std::env::temp_dir().join(format!("sley-cred-parity-{pid}-{n}"));
432 fs::create_dir_all(&path).expect("mkdir tempdir");
433 TempDir { path }
434 }
435}