sley_remote/
credentials.rs1use 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
17pub 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
26pub 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
34pub 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
46pub 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
56pub 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
75pub 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
85pub 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}