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