Skip to main content

heddle_git_projection/
credential.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Credential configuration for embedded Sley transports.
3
4use 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
26/// Dispatch Sley's built-in credential helpers when Heddle is the embedding
27/// executable. Returns `None` for ordinary CLI arguments.
28pub 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
40/// Git credential helpers adapted for a process embedding Sley.
41pub 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}