1use std::{
5 ffi::OsStr,
6 io::{Cursor, Read as _},
7 path::{Path, PathBuf},
8 process::{Child, Command, ExitStatus, Stdio},
9 sync::mpsc,
10 thread,
11 time::{Duration, Instant},
12};
13
14use sley::{
15 GitConfig, GitError,
16 plumbing::sley_remote::{CredentialHelperProvider, CredentialProvider},
17};
18use sley_transport::{
19 GitCredential,
20 credential::{
21 CredentialOpType, TIME_MAX, credential_apply_config, credential_clear_secrets,
22 credential_read, credential_write,
23 },
24};
25
26pub fn dispatch_embedded_credential_helper(args: &[String]) -> Option<Result<(), GitError>> {
29 let (command, helper_args) = args.split_first()?;
30 match command.as_str() {
31 "credential-store" => Some(sley_transport::cmd_credential_store(helper_args)),
32 "credential-cache" => Some(sley_transport::cmd_credential_cache(helper_args)),
33 "credential-cache--daemon" => {
34 Some(sley_transport::cmd_credential_cache_daemon(helper_args))
35 }
36 _ => None,
37 }
38}
39
40pub struct EmbeddingSafeCredentialProvider {
42 config: GitConfig,
43 prompt_config: GitConfig,
44 has_embedded_helpers: bool,
45 external_helper_config: GitConfig,
46 external_helpers: Vec<ExternalCredentialHelper>,
47 external_helper_timeout: Duration,
48}
49
50struct ExternalCredentialHelper {
51 executable: Option<PathBuf>,
52 args: Vec<String>,
53}
54
55const MAX_CREDENTIAL_HELPER_OUTPUT: usize = 64 * 1024;
56const CREDENTIAL_HELPER_TIMEOUT: Duration = Duration::from_secs(30);
57const EXTERNAL_HELPER_MARKER_PREFIX: &str = "heddle-external-helper-";
58const EXTERNAL_HELPER_RESET_MARKER: &str = "heddle-external-helper-reset";
59
60impl EmbeddingSafeCredentialProvider {
61 pub fn new(config: &GitConfig) -> Self {
62 Self::with_search_path(config, std::env::var_os("PATH").as_deref())
63 }
64
65 fn with_search_path(config: &GitConfig, search_path: Option<&OsStr>) -> Self {
66 let mut config = config.clone();
67 let (external_helper_config, external_helpers) =
68 extract_external_helpers(&mut config, search_path);
69 let has_embedded_helpers = has_credential_helpers(&config);
70 let mut prompt_config = config.clone();
71 remove_credential_helpers(&mut prompt_config);
72 Self {
73 config,
74 prompt_config,
75 has_embedded_helpers,
76 external_helper_config,
77 external_helpers,
78 external_helper_timeout: CREDENTIAL_HELPER_TIMEOUT,
79 }
80 }
81
82 #[cfg(test)]
83 fn with_search_path_and_timeout(
84 config: &GitConfig,
85 search_path: Option<&OsStr>,
86 external_helper_timeout: Duration,
87 ) -> Self {
88 let mut provider = Self::with_search_path(config, search_path);
89 provider.external_helper_timeout = external_helper_timeout;
90 provider
91 }
92
93 fn selected_external_helpers(&self, credential: &GitCredential) -> Vec<usize> {
94 let mut selection = credential.clone();
95 selection.configured = false;
96 selection.helpers.clear();
97 if credential_apply_config(Some(&self.external_helper_config), None, &mut selection)
98 .is_err()
99 {
100 return Vec::new();
101 }
102 let mut selected = Vec::new();
103 for helper in selection
104 .helpers
105 .iter()
106 .flat_map(|helpers| helpers.split_whitespace())
107 {
108 if helper == EXTERNAL_HELPER_RESET_MARKER {
109 selected.clear();
110 } else if let Some(index) = helper
111 .strip_prefix(EXTERNAL_HELPER_MARKER_PREFIX)
112 .and_then(|index| index.parse().ok())
113 {
114 selected.push(index);
115 }
116 }
117 selected
118 }
119
120 fn run_external_helpers(
121 &self,
122 credential: &mut GitCredential,
123 operation: &str,
124 helper_indices: &[usize],
125 ) {
126 for &helper_index in helper_indices {
127 if operation == "get" && credential.is_full() {
128 break;
129 }
130 let Some(helper) = self.external_helpers.get(helper_index) else {
131 continue;
132 };
133 let Some(executable) = helper.executable.as_deref() else {
134 continue;
135 };
136 let mut command = Command::new(executable);
137 command
138 .args(&helper.args)
139 .arg(operation)
140 .stdin(Stdio::piped())
141 .stdout(if operation == "get" {
142 Stdio::piped()
143 } else {
144 Stdio::null()
145 })
146 .stderr(Stdio::null());
147 let Ok(mut child) = command.spawn() else {
148 continue;
149 };
150 let Some(mut stdin) = child.stdin.take() else {
151 terminate_helper(&mut child);
152 continue;
153 };
154 if credential_write(credential, &mut stdin, CredentialOpType::Helper).is_err() {
155 terminate_helper(&mut child);
156 continue;
157 }
158 drop(stdin);
159 let deadline = Instant::now() + self.external_helper_timeout;
160 if operation != "get" {
161 let _ = wait_for_helper(&mut child, deadline);
162 continue;
163 }
164 let Some(stdout) = child.stdout.take() else {
165 terminate_helper(&mut child);
166 continue;
167 };
168 let (send_output, receive_output) = mpsc::sync_channel(1);
169 thread::spawn(move || {
170 let mut output = Vec::new();
171 let result = stdout
172 .take((MAX_CREDENTIAL_HELPER_OUTPUT + 1) as u64)
173 .read_to_end(&mut output)
174 .map(|_| output);
175 let _ = send_output.send(result);
176 });
177 let output = match receive_output
178 .recv_timeout(deadline.saturating_duration_since(Instant::now()))
179 {
180 Ok(Ok(output)) => output,
181 Ok(Err(_)) | Err(_) => {
182 terminate_helper(&mut child);
183 continue;
184 }
185 };
186 if output.len() > MAX_CREDENTIAL_HELPER_OUTPUT {
187 terminate_helper(&mut child);
188 continue;
189 }
190 let Some(status) = wait_for_helper(&mut child, deadline) else {
191 continue;
192 };
193 if !status.success() {
194 continue;
195 }
196 let mut response = credential.clone();
197 if credential_read(
198 &mut response,
199 &mut Cursor::new(output),
200 CredentialOpType::Helper,
201 )
202 .is_ok()
203 {
204 if response.password_expiry_utc < unix_time_now() {
205 credential_clear_secrets(&mut response);
206 response.password_expiry_utc = TIME_MAX;
207 }
208 *credential = response;
209 }
210 }
211 }
212}
213
214fn wait_for_helper(child: &mut Child, deadline: Instant) -> Option<ExitStatus> {
215 loop {
216 match child.try_wait() {
217 Ok(Some(status)) => return Some(status),
218 Err(_) => {
219 terminate_helper(child);
220 return None;
221 }
222 Ok(None) => {}
223 }
224 let remaining = deadline.saturating_duration_since(Instant::now());
225 if remaining.is_zero() {
226 terminate_helper(child);
227 return None;
228 }
229 thread::sleep(remaining.min(Duration::from_millis(10)));
230 }
231}
232
233fn terminate_helper(child: &mut Child) {
234 let _ = child.kill();
235 let _ = child.wait();
236}
237
238impl CredentialProvider for EmbeddingSafeCredentialProvider {
239 fn fill(&mut self, request: GitCredential) -> Result<Option<GitCredential>, GitError> {
240 let mut credential = request;
241 let external_helpers = self.selected_external_helpers(&credential);
242 credential_apply_config(Some(&self.config), None, &mut credential)?;
243 self.run_external_helpers(&mut credential, "get", &external_helpers);
244 if credential.quit {
245 return Err(GitError::InvalidFormat(
246 "credential helper told Heddle to stop authentication".to_string(),
247 ));
248 }
249 if credential.is_full() {
250 return Ok(Some(credential));
251 }
252 credential.helpers.clear();
253 match CredentialHelperProvider::new(Some(&self.config)).fill(credential.clone()) {
254 Ok(result) => Ok(result),
255 Err(error)
256 if self.has_embedded_helpers && embedded_helper_failure_allows_prompt(&error) =>
257 {
258 CredentialHelperProvider::new(Some(&self.prompt_config)).fill(credential)
259 }
260 Err(error) => Err(error),
261 }
262 }
263
264 fn approve(&mut self, credential: &GitCredential) -> Result<(), GitError> {
265 let external_helpers = self.selected_external_helpers(credential);
266 self.run_external_helpers(&mut credential.clone(), "store", &external_helpers);
267 CredentialHelperProvider::new(Some(&self.config)).approve(credential)
268 }
269
270 fn reject(&mut self, credential: &GitCredential) -> Result<(), GitError> {
271 let external_helpers = self.selected_external_helpers(credential);
272 self.run_external_helpers(&mut credential.clone(), "erase", &external_helpers);
273 CredentialHelperProvider::new(Some(&self.config)).reject(credential)
274 }
275}
276
277fn extract_external_helpers(
278 config: &mut GitConfig,
279 search_path: Option<&OsStr>,
280) -> (GitConfig, Vec<ExternalCredentialHelper>) {
281 let mut helpers = Vec::new();
282 let mut helper_config = config.clone();
283 for (section, helper_section) in config.sections.iter_mut().zip(&mut helper_config.sections) {
284 if !section.name.eq_ignore_ascii_case("credential") {
285 helper_section.entries.clear();
286 continue;
287 }
288 let mut helper_entries = helper_section.entries.iter_mut();
289 section.entries.retain(|entry| {
290 let helper_entry = helper_entries.next().expect("cloned config entry");
291 if !entry.key.eq_ignore_ascii_case("helper") {
292 helper_entry.key.clear();
293 return true;
294 }
295 let spec = entry.value.as_deref().unwrap_or("").trim();
296 if spec.is_empty() {
297 helper_entry.value = Some(EXTERNAL_HELPER_RESET_MARKER.to_string());
298 return true;
299 }
300 if spec.starts_with('!') {
301 helper_entry.key.clear();
302 return true;
303 }
304 let (head, args) = spec
305 .split_once(char::is_whitespace)
306 .map_or((spec, ""), |(head, args)| (head, args.trim_start()));
307 if Path::new(head).is_absolute() {
308 #[cfg(windows)]
309 helpers.push(ExternalCredentialHelper {
310 executable: Some(PathBuf::from(head)),
311 args: args.split_whitespace().map(str::to_string).collect(),
312 });
313 #[cfg(windows)]
314 {
315 helper_entry.value = Some(format!(
316 "{EXTERNAL_HELPER_MARKER_PREFIX}{}",
317 helpers.len() - 1
318 ));
319 }
320 #[cfg(windows)]
321 return false;
322 #[cfg(not(windows))]
323 helper_entry.key.clear();
324 #[cfg(not(windows))]
325 return true;
326 }
327 if matches!(head, "store" | "cache" | "cache--daemon") {
328 let mut helper_args = vec![format!("credential-{head}")];
329 helper_args.extend(args.split_whitespace().map(str::to_string));
330 helpers.push(ExternalCredentialHelper {
331 executable: std::env::current_exe().ok(),
332 args: helper_args,
333 });
334 helper_entry.value = Some(format!(
335 "{EXTERNAL_HELPER_MARKER_PREFIX}{}",
336 helpers.len() - 1
337 ));
338 return false;
339 }
340 let executable_name = if head.starts_with("git-credential-") {
341 head.to_string()
342 } else {
343 format!("git-credential-{head}")
344 };
345 helpers.push(ExternalCredentialHelper {
346 executable: find_executable(&executable_name, search_path),
347 args: args.split_whitespace().map(str::to_string).collect(),
348 });
349 helper_entry.value = Some(format!(
350 "{EXTERNAL_HELPER_MARKER_PREFIX}{}",
351 helpers.len() - 1
352 ));
353 false
354 });
355 helper_section
356 .entries
357 .retain(|entry| entry.key.eq_ignore_ascii_case("helper"));
358 }
359 helper_config.sections.retain(|section| {
360 section.name.eq_ignore_ascii_case("credential") && !section.entries.is_empty()
361 });
362 (helper_config, helpers)
363}
364
365fn has_credential_helpers(config: &GitConfig) -> bool {
366 config.sections.iter().any(|section| {
367 section.name.eq_ignore_ascii_case("credential")
368 && section.entries.iter().any(|entry| {
369 entry.key.eq_ignore_ascii_case("helper")
370 && entry
371 .value
372 .as_deref()
373 .is_some_and(|value| !value.is_empty())
374 })
375 })
376}
377
378fn remove_credential_helpers(config: &mut GitConfig) {
379 for section in &mut config.sections {
380 if section.name.eq_ignore_ascii_case("credential") {
381 section
382 .entries
383 .retain(|entry| !entry.key.eq_ignore_ascii_case("helper"));
384 }
385 }
386}
387
388fn embedded_helper_failure_allows_prompt(error: &GitError) -> bool {
389 match error {
390 GitError::Command(message) => message.contains("credential helper"),
391 #[cfg(windows)]
392 GitError::Io(_) => true,
393 _ => false,
394 }
395}
396
397fn find_executable(name: &str, search_path: Option<&OsStr>) -> Option<PathBuf> {
398 let names = executable_names(name);
399 std::env::split_paths(search_path?).find_map(|directory| {
400 names.iter().find_map(|name| {
401 let candidate = directory.join(name);
402 executable_file(&candidate).then_some(candidate)
403 })
404 })
405}
406
407fn executable_file(path: &Path) -> bool {
408 #[cfg(unix)]
409 {
410 use std::os::unix::fs::PermissionsExt as _;
411
412 path.metadata()
413 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
414 }
415 #[cfg(not(unix))]
416 {
417 path.is_file()
418 }
419}
420
421fn executable_names(name: &str) -> Vec<String> {
422 let names = vec![name.to_string()];
423 #[cfg(windows)]
424 let names = {
425 let mut names = names;
426 let extensions =
427 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
428 names.extend(
429 extensions
430 .split(';')
431 .filter(|extension| !extension.is_empty())
432 .map(|extension| format!("{name}{extension}")),
433 );
434 names
435 };
436 names
437}
438
439fn unix_time_now() -> i64 {
440 std::time::SystemTime::now()
441 .duration_since(std::time::UNIX_EPOCH)
442 .map_or(0, |duration| duration.as_secs() as i64)
443}
444
445#[cfg(all(test, unix))]
446mod tests {
447 use std::{
448 fs,
449 os::unix::fs::PermissionsExt as _,
450 path::Path,
451 time::{Duration, Instant},
452 };
453
454 use sley::plumbing::sley_remote::{CredentialProvider as _, http_send_with_auth};
455 use sley_transport::{GitCredential, HttpResponse, parse_remote_url};
456 use tempfile::TempDir;
457
458 use super::EmbeddingSafeCredentialProvider;
459
460 fn write_helper(directory: &Path, name: &str, body: &str) {
461 let helper = directory.join(format!("git-credential-{name}"));
462 fs::write(&helper, format!("#!/bin/sh\n{body}\n")).expect("write helper");
463 let mut permissions = fs::metadata(&helper)
464 .expect("helper metadata")
465 .permissions();
466 permissions.set_mode(0o755);
467 fs::set_permissions(helper, permissions).expect("make helper executable");
468 }
469
470 fn https_credential(host: &str) -> GitCredential {
471 GitCredential {
472 protocol: Some("https".to_string()),
473 host: Some(host.to_string()),
474 ..GitCredential::default()
475 }
476 }
477
478 #[test]
479 fn https_transport_retries_with_bare_credential_helper_without_git_dispatch() {
480 let temp = TempDir::new().expect("tempdir");
481 let helper = temp.path().join("git-credential-heddle-test");
482 fs::write(
483 &helper,
484 "#!/bin/sh\ncat >/dev/null\nprintf 'username=alice\\npassword=secret\\n'\n",
485 )
486 .expect("write helper");
487 let mut permissions = fs::metadata(&helper)
488 .expect("helper metadata")
489 .permissions();
490 permissions.set_mode(0o755);
491 fs::set_permissions(&helper, permissions).expect("make helper executable");
492
493 let config = sley::GitConfig::parse(b"[credential]\n\thelper = heddle-test\n")
494 .expect("parse config");
495 let mut provider = EmbeddingSafeCredentialProvider::with_search_path(
496 &config,
497 Some(temp.path().as_os_str()),
498 );
499 let remote = parse_remote_url("https://example.test/repo.git").expect("HTTPS remote");
500 let mut attempts = 0;
501 let response = http_send_with_auth(&remote, &mut provider, |authorization| {
502 attempts += 1;
503 let status = if attempts == 1 {
504 assert!(authorization.is_none());
505 401
506 } else {
507 assert!(authorization.is_some_and(|value| value.starts_with("Basic ")));
508 200
509 };
510 Ok(HttpResponse {
511 status,
512 content_type: None,
513 body: Box::new(std::io::Cursor::new(Vec::new())),
514 })
515 })
516 .expect("HTTPS authentication retry");
517
518 assert_eq!(response.status, 200);
519 assert_eq!(attempts, 2);
520 }
521
522 #[test]
523 fn missing_bare_helper_falls_through_to_the_next_helper() {
524 let temp = TempDir::new().expect("tempdir");
525 let helper = temp.path().join("git-credential-heddle-fallback");
526 fs::write(
527 &helper,
528 "#!/bin/sh\ncat >/dev/null\nprintf 'username=fallback\npassword=secret\n'\n",
529 )
530 .expect("write helper");
531 let mut permissions = fs::metadata(&helper)
532 .expect("helper metadata")
533 .permissions();
534 permissions.set_mode(0o755);
535 fs::set_permissions(&helper, permissions).expect("make helper executable");
536 let config = sley::GitConfig::parse(
537 b"[credential]\n\thelper = definitely-missing-heddle-helper\n\thelper = heddle-fallback\n",
538 )
539 .expect("parse config");
540 let mut provider = EmbeddingSafeCredentialProvider::with_search_path(
541 &config,
542 Some(temp.path().as_os_str()),
543 );
544 let credential = provider
545 .fill(GitCredential {
546 protocol: Some("https".to_string()),
547 host: Some("example.test".to_string()),
548 ..GitCredential::default()
549 })
550 .expect("fallback helper runs")
551 .expect("fallback helper fills credentials");
552
553 assert_eq!(credential.username.as_deref(), Some("fallback"));
554 assert_eq!(credential.password.as_deref(), Some("secret"));
555 }
556
557 #[test]
558 fn failing_bare_helper_falls_through_to_the_next_helper() {
559 let temp = TempDir::new().expect("tempdir");
560 let helper = temp.path().join("git-credential-heddle-fails");
561 fs::write(&helper, "#!/bin/sh\nexit 23\n").expect("write helper");
562 let mut permissions = fs::metadata(&helper)
563 .expect("helper metadata")
564 .permissions();
565 permissions.set_mode(0o755);
566 fs::set_permissions(&helper, permissions).expect("make helper executable");
567
568 let fallback = temp.path().join("git-credential-heddle-good");
569 fs::write(
570 &fallback,
571 "#!/bin/sh\ncat >/dev/null\nprintf 'username=alice\npassword=secret\n'\n",
572 )
573 .expect("write fallback helper");
574 let mut permissions = fs::metadata(&fallback)
575 .expect("fallback metadata")
576 .permissions();
577 permissions.set_mode(0o755);
578 fs::set_permissions(&fallback, permissions).expect("make fallback executable");
579
580 let config = sley::GitConfig::parse(
581 b"[credential]\n\thelper = heddle-fails\n\thelper = heddle-good\n",
582 )
583 .expect("parse config");
584 let mut provider = EmbeddingSafeCredentialProvider::with_search_path(
585 &config,
586 Some(temp.path().as_os_str()),
587 );
588 let credential = provider
589 .fill(GitCredential {
590 protocol: Some("https".to_string()),
591 host: Some("example.test".to_string()),
592 ..GitCredential::default()
593 })
594 .expect("fallback helper runs")
595 .expect("fallback helper fills credentials");
596
597 assert_eq!(credential.username.as_deref(), Some("alice"));
598 assert_eq!(credential.password.as_deref(), Some("secret"));
599 }
600
601 #[test]
602 fn url_scoped_reset_runs_only_helpers_selected_for_the_request() {
603 let temp = TempDir::new().expect("tempdir");
604 write_helper(
605 temp.path(),
606 "heddle-global",
607 "cat >/dev/null\nprintf 'username=global\\npassword=wrong\\n'",
608 );
609 write_helper(
610 temp.path(),
611 "heddle-other",
612 "cat >/dev/null\nprintf 'username=other\\npassword=wrong\\n'",
613 );
614 write_helper(
615 temp.path(),
616 "heddle-scoped",
617 "cat >/dev/null\nprintf 'username=scoped\\npassword=secret\\n'",
618 );
619 let config = sley::GitConfig::parse(
620 b"[credential]\n\thelper = heddle-global\n\
621 [credential \"https://example.test/other\"]\n\thelper = heddle-other\n\
622 [credential \"https://example.test/org\"]\n\thelper =\n\thelper = heddle-scoped\n",
623 )
624 .expect("parse config");
625 let mut provider = EmbeddingSafeCredentialProvider::with_search_path(
626 &config,
627 Some(temp.path().as_os_str()),
628 );
629
630 let mut request = https_credential("example.test");
631 request.path = Some("org/repo.git".to_string());
632 let credential = provider
633 .fill(request)
634 .expect("scoped helper runs")
635 .expect("scoped helper fills credentials");
636
637 assert_eq!(credential.username.as_deref(), Some("scoped"));
638 assert_eq!(credential.password.as_deref(), Some("secret"));
639 }
640
641 #[test]
642 fn oversized_streaming_helper_is_killed_before_fallback_runs() {
643 let temp = TempDir::new().expect("tempdir");
644 write_helper(
645 temp.path(),
646 "heddle-oversized",
647 "cat >/dev/null\nwhile :; do printf '0123456789abcdef'; done",
648 );
649 write_helper(
650 temp.path(),
651 "heddle-after-oversized",
652 "cat >/dev/null\nprintf 'username=fallback\\npassword=secret\\n'",
653 );
654 let config = sley::GitConfig::parse(
655 b"[credential]\n\thelper = heddle-oversized\n\thelper = heddle-after-oversized\n",
656 )
657 .expect("parse config");
658 let mut provider = EmbeddingSafeCredentialProvider::with_search_path_and_timeout(
659 &config,
660 Some(temp.path().as_os_str()),
661 Duration::from_secs(2),
662 );
663 let started = Instant::now();
664
665 let credential = provider
666 .fill(https_credential("example.test"))
667 .expect("oversized helper falls through")
668 .expect("fallback helper fills credentials");
669
670 assert!(started.elapsed() < Duration::from_secs(2));
671 assert_eq!(credential.username.as_deref(), Some("fallback"));
672 }
673
674 #[test]
675 fn nonterminating_helper_is_killed_at_the_deadline_before_fallback_runs() {
676 let temp = TempDir::new().expect("tempdir");
677 write_helper(temp.path(), "heddle-hangs", "cat >/dev/null\nexec sleep 30");
678 write_helper(
679 temp.path(),
680 "heddle-after-hang",
681 "cat >/dev/null\nprintf 'username=fallback\\npassword=secret\\n'",
682 );
683 let config = sley::GitConfig::parse(
684 b"[credential]\n\thelper = heddle-hangs\n\thelper = heddle-after-hang\n",
685 )
686 .expect("parse config");
687 let mut provider = EmbeddingSafeCredentialProvider::with_search_path_and_timeout(
688 &config,
689 Some(temp.path().as_os_str()),
690 Duration::from_secs(2),
691 );
692 let started = Instant::now();
693
694 let credential = provider
695 .fill(https_credential("example.test"))
696 .expect("timed-out helper falls through")
697 .expect("fallback helper fills credentials");
698
699 assert!(started.elapsed() < Duration::from_secs(5));
700 assert_eq!(credential.username.as_deref(), Some("fallback"));
701 }
702
703 #[test]
704 fn nonterminating_store_helper_is_killed_at_the_deadline() {
705 let temp = TempDir::new().expect("tempdir");
706 write_helper(
707 temp.path(),
708 "heddle-store-hangs",
709 "cat >/dev/null\nexec sleep 30",
710 );
711 let config = sley::GitConfig::parse(b"[credential]\n\thelper = heddle-store-hangs\n")
712 .expect("parse config");
713 let mut provider = EmbeddingSafeCredentialProvider::with_search_path_and_timeout(
714 &config,
715 Some(temp.path().as_os_str()),
716 Duration::from_millis(250),
717 );
718 let mut credential = https_credential("example.test");
719 credential.username = Some("alice".to_string());
720 credential.password = Some("secret".to_string());
721 let started = Instant::now();
722
723 provider
724 .approve(&credential)
725 .expect("timed-out store helper is ignored");
726
727 assert!(started.elapsed() < Duration::from_secs(2));
728 }
729
730 #[test]
731 fn built_in_helpers_use_heddles_direct_embedding_dispatch() {
732 let config = sley::GitConfig::parse(
733 b"[credential]\n\thelper = store --file=/tmp/heddle-credentials\n",
734 )
735 .expect("parse config");
736 let provider = EmbeddingSafeCredentialProvider::with_search_path(&config, None);
737
738 assert_eq!(provider.config.get("credential", None, "helper"), None);
739 assert_eq!(provider.external_helpers.len(), 1);
740 assert_eq!(
741 provider.external_helpers[0].args,
742 [
743 "credential-store".to_string(),
744 "--file=/tmp/heddle-credentials".to_string()
745 ]
746 );
747 }
748
749 #[test]
750 fn bare_manager_resolves_to_the_standalone_helper_binary() {
751 let temp = TempDir::new().expect("tempdir");
752 let manager = temp.path().join("git-credential-manager");
753 fs::write(&manager, "#!/bin/sh\nexit 0\n").expect("write manager helper");
754 let mut permissions = fs::metadata(&manager)
755 .expect("manager metadata")
756 .permissions();
757 permissions.set_mode(0o755);
758 fs::set_permissions(&manager, permissions).expect("make manager executable");
759 let config =
760 sley::GitConfig::parse(b"[credential]\n\thelper = manager\n").expect("parse config");
761 let provider = EmbeddingSafeCredentialProvider::with_search_path(
762 &config,
763 Some(temp.path().as_os_str()),
764 );
765
766 assert_eq!(provider.external_helpers.len(), 1);
767 assert_eq!(
768 provider.external_helpers[0].executable.as_deref(),
769 Some(manager.as_path())
770 );
771 }
772}
773
774#[cfg(all(test, windows))]
775mod windows_tests {
776 use super::executable_names;
777
778 #[test]
779 fn manager_search_includes_windows_executable_names() {
780 let names = executable_names("git-credential-manager");
781 assert!(names.iter().any(|name| {
782 name.eq_ignore_ascii_case("git-credential-manager.exe")
783 || name.eq_ignore_ascii_case("git-credential-manager.cmd")
784 }));
785 }
786}