Skip to main content

sley_remote/
credentials.rs

1//! Credential acquisition for authenticated remotes.
2//!
3//! Thin wrapper over [`sley_transport::credential`].
4
5use sley_config::GitConfig;
6use sley_core::Result;
7use sley_transport::{
8    GitCredential, RemoteTransport, RemoteUrl,
9    credential::{
10        credential_approve, credential_fill as transport_credential_fill, credential_fill_simple,
11        credential_reject,
12    },
13};
14
15use crate::CredentialProvider;
16
17/// The `protocol` field of a credential request derived from `remote`.
18pub fn http_protocol_name(remote: &RemoteUrl) -> Option<String> {
19    match remote.transport {
20        RemoteTransport::Https => Some("https".to_string()),
21        RemoteTransport::Http => Some("http".to_string()),
22        _ => None,
23    }
24}
25
26/// The `host[:port]` field of a credential request derived from `remote`.
27pub fn http_credential_host(remote: &RemoteUrl) -> Option<String> {
28    remote.host.clone().map(|host| match remote.port {
29        Some(port) => format!("{host}:{port}"),
30        None => host,
31    })
32}
33
34/// Credential implied by `user[:password]@` userinfo in the remote URL.
35pub fn http_url_credential(remote: &RemoteUrl) -> Option<GitCredential> {
36    let username = remote.user.clone()?;
37    Some(GitCredential {
38        protocol: http_protocol_name(remote),
39        host: http_credential_host(remote),
40        username: Some(username),
41        password: remote.password.clone(),
42        ..GitCredential::default()
43    })
44}
45
46/// The lookup key a credential helper is asked to fill for this remote.
47pub fn credential_request_for_url(remote: &RemoteUrl) -> GitCredential {
48    GitCredential {
49        protocol: http_protocol_name(remote),
50        host: http_credential_host(remote),
51        username: remote.user.clone(),
52        ..GitCredential::default()
53    }
54}
55
56/// Fill `request` using credential helpers and, when needed, `GIT_ASKPASS` /
57/// `core.askPass` (matching upstream git's HTTP auth retry path).
58pub fn credential_fill(
59    config: Option<&GitConfig>,
60    mut request: GitCredential,
61) -> Result<Option<GitCredential>> {
62    if let Err(err) = transport_credential_fill(config, None, &mut request, true) {
63        if request.username.is_some() && request.password.is_some() {
64            return Ok(Some(request));
65        }
66        return Err(err);
67    }
68    if request.username.is_some() && request.password.is_some() {
69        Ok(Some(request))
70    } else {
71        Ok(None)
72    }
73}
74
75/// Tell configured helpers to store (`approve = true`) or erase a credential.
76pub fn credential_store(config: Option<&GitConfig>, credential: &GitCredential, approve: bool) {
77    let mut working = credential.clone();
78    if approve {
79        let _ = credential_approve(config, None, &mut working);
80    } else {
81        let _ = credential_reject(config, None, &mut working);
82    }
83}
84
85/// The default [`CredentialProvider`]: fills and stores credentials via
86/// `credential.helper` programs.
87pub struct CredentialHelperProvider<'a> {
88    config: Option<&'a GitConfig>,
89}
90
91impl<'a> CredentialHelperProvider<'a> {
92    pub fn new(config: Option<&'a GitConfig>) -> Self {
93        Self { config }
94    }
95}
96
97impl CredentialProvider for CredentialHelperProvider<'_> {
98    fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>> {
99        credential_fill(self.config, request)
100    }
101
102    fn approve(&mut self, credential: &GitCredential) -> Result<()> {
103        credential_store(self.config, credential, true);
104        Ok(())
105    }
106
107    fn reject(&mut self, credential: &GitCredential) -> Result<()> {
108        credential_store(self.config, credential, false);
109        Ok(())
110    }
111}
112
113#[cfg(all(test, unix))]
114mod credential_dispatch_parity_tests {
115    use std::fs;
116    use std::os::unix::fs::PermissionsExt;
117    use std::path::Path;
118
119    use sley_config::GitConfig;
120    use sley_transport::GitCredential;
121    use sley_transport::credential::{credential_fill_simple, credential_helper_command};
122
123    use super::credential_fill;
124
125    fn config_with_helper(helper: &str) -> GitConfig {
126        let escaped = helper.replace('\\', "\\\\").replace('"', "\\\"");
127        let body = format!("[credential]\n\thelper = \"{escaped}\"\n");
128        GitConfig::parse(body.as_bytes()).expect("config parses")
129    }
130
131    fn write_script(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
132        let path = dir.join(name);
133        fs::write(&path, body).expect("write script");
134        let mut perms = fs::metadata(&path).expect("metadata").permissions();
135        perms.set_mode(0o755);
136        fs::set_permissions(&path, perms).expect("chmod");
137        path
138    }
139
140    fn base_request() -> GitCredential {
141        GitCredential {
142            protocol: Some("https".to_string()),
143            host: Some("example.com".to_string()),
144            ..GitCredential::default()
145        }
146    }
147
148    #[test]
149    fn absolute_path_form_passes_args_and_op() {
150        let tmp = tempdir();
151        let marker = tmp.path().join("abs.out");
152        let script = write_script(
153            tmp.path(),
154            "abs-helper.sh",
155            &format!(
156                "#!/bin/sh\ncat >/dev/null\nprintf 'ARGS:[%s]\\n' \"$*\" >> '{}'\necho username=abs-user\necho password=abs-pass\n",
157                marker.display()
158            ),
159        );
160        let cfg = config_with_helper(&format!("{} --flag", script.display()));
161        let filled = credential_fill(Some(&cfg), base_request())
162            .expect("fill ok")
163            .expect("credential filled");
164        assert_eq!(filled.username.as_deref(), Some("abs-user"));
165        assert_eq!(filled.password.as_deref(), Some("abs-pass"));
166        let recorded = fs::read_to_string(&marker).expect("marker written");
167        assert_eq!(recorded.trim(), "ARGS:[--flag get]");
168    }
169
170    #[test]
171    fn shell_snippet_form_runs_through_shell_with_op_arg() {
172        let tmp = tempdir();
173        let marker = tmp.path().join("snip.out");
174        let helper = format!(
175            "!f() {{ cat >/dev/null; printf 'GOT:[%s]\\n' \"$*\" >> '{}'; echo username=snip-user; echo password=snip-pass; }}; f",
176            marker.display()
177        );
178        let cfg = config_with_helper(&helper);
179        let filled = credential_fill(Some(&cfg), base_request())
180            .expect("fill ok")
181            .expect("credential filled");
182        assert_eq!(filled.username.as_deref(), Some("snip-user"));
183        assert_eq!(filled.password.as_deref(), Some("snip-pass"));
184        let recorded = fs::read_to_string(&marker).expect("marker written");
185        assert_eq!(recorded.trim(), "GOT:[get]");
186    }
187
188    #[test]
189    fn relative_slash_name_is_bare_not_path() {
190        let cmd = credential_helper_command("sub/relhelper", "get").expect("command built");
191        let argv = command_argv(&cmd);
192        assert_ne!(
193            argv[0], "sub/relhelper",
194            "relative slash name must not be exec'd directly"
195        );
196        assert!(
197            argv.iter().any(|arg| arg == "credential-sub/relhelper")
198                || argv[0] == "sh"
199                || argv[0] == "/bin/sh",
200            "expected credential-<name> dispatch, got argv {argv:?}"
201        );
202    }
203
204    #[test]
205    fn plain_bare_name_maps_to_credential_binary() {
206        let cmd = credential_helper_command("myhelper --opt val", "get").expect("command built");
207        let argv = command_argv(&cmd);
208        assert!(
209            argv.iter().any(|arg| arg == "credential-myhelper")
210                || argv[0] == "sh"
211                || argv[0] == "/bin/sh",
212            "expected credential-<name> dispatch, got argv {argv:?}"
213        );
214        let rendered = argv.join(" ");
215        assert!(
216            rendered.contains("credential-myhelper")
217                && rendered.contains("--opt")
218                && rendered.contains("val")
219                && rendered.contains("get"),
220            "expected `credential-myhelper --opt val get` dispatch, got {rendered:?}"
221        );
222    }
223
224    fn command_program(cmd: &std::process::Command) -> String {
225        cmd.get_program().to_string_lossy().into_owned()
226    }
227
228    fn command_argv(cmd: &std::process::Command) -> Vec<String> {
229        let mut out = vec![cmd.get_program().to_string_lossy().into_owned()];
230        out.extend(cmd.get_args().map(|a| a.to_string_lossy().into_owned()));
231        out
232    }
233
234    struct TempDir {
235        path: std::path::PathBuf,
236    }
237    impl TempDir {
238        fn path(&self) -> &Path {
239            &self.path
240        }
241    }
242    impl Drop for TempDir {
243        fn drop(&mut self) {
244            let _ = fs::remove_dir_all(&self.path);
245        }
246    }
247    fn tempdir() -> TempDir {
248        use std::sync::atomic::{AtomicU64, Ordering};
249        static COUNTER: AtomicU64 = AtomicU64::new(0);
250        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
251        let pid = std::process::id();
252        let path = std::env::temp_dir().join(format!("sley-cred-parity-{pid}-{n}"));
253        fs::create_dir_all(&path).expect("mkdir tempdir");
254        TempDir { path }
255    }
256}