Skip to main content

shipper_core/ops/auth/
resolver.rs

1//! Token resolution from environment variables and credentials files.
2//!
3//! Returns an [`AuthInfo`] diagnostic record (token + source) rather than a
4//! bare `Option<String>` so callers can render the origin for users
5//! (e.g. `shipper doctor`).
6
7use std::env;
8use std::path::{Path, PathBuf};
9
10use super::credentials::{CREDENTIALS_FILE, token_from_credentials_file};
11
12/// Default registry name for crates.io.
13pub const CRATES_IO_REGISTRY: &str = "crates-io";
14
15/// Environment variable for the default registry token.
16pub const CARGO_REGISTRY_TOKEN_ENV: &str = "CARGO_REGISTRY_TOKEN";
17
18/// Environment variable prefix for registry-specific tokens.
19pub const CARGO_REGISTRIES_TOKEN_PREFIX: &str = "CARGO_REGISTRIES_";
20
21/// Environment variable for `CARGO_HOME`.
22pub const CARGO_HOME_ENV: &str = "CARGO_HOME";
23
24/// Result of token resolution for a single registry.
25///
26/// Returned by [`resolve_token`]. When `detected` is `true`, a valid
27/// token was found; its origin is recorded in [`source`](AuthInfo::source).
28#[derive(Debug, Clone)]
29pub struct AuthInfo {
30    /// The resolved token (if found).
31    pub token: Option<String>,
32    /// Source of the token.
33    pub source: TokenSource,
34    /// Whether authentication was detected.
35    pub detected: bool,
36}
37
38impl Default for AuthInfo {
39    fn default() -> Self {
40        Self {
41            token: None,
42            source: TokenSource::None,
43            detected: false,
44        }
45    }
46}
47
48/// Where the authentication token was resolved from.
49///
50/// Used for diagnostics and the `shipper doctor` command so users can
51/// verify which credential source is active.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum TokenSource {
54    /// No token found.
55    None,
56    /// From `CARGO_REGISTRY_TOKEN` environment variable.
57    EnvDefault,
58    /// From `CARGO_REGISTRIES_<NAME>_TOKEN` environment variable.
59    EnvRegistry,
60    /// From `$CARGO_HOME/credentials.toml`.
61    CredentialsFile,
62}
63
64impl std::fmt::Display for TokenSource {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            TokenSource::None => write!(f, "none"),
68            TokenSource::EnvDefault => write!(f, "CARGO_REGISTRY_TOKEN"),
69            TokenSource::EnvRegistry => write!(f, "CARGO_REGISTRIES_<NAME>_TOKEN"),
70            TokenSource::CredentialsFile => write!(f, "credentials.toml"),
71        }
72    }
73}
74
75/// Resolve the authentication token for a registry.
76///
77/// Resolution order:
78/// 1. `CARGO_REGISTRY_TOKEN` environment variable (default registry only)
79/// 2. `CARGO_REGISTRIES_<NAME>_TOKEN` environment variable
80/// 3. `$CARGO_HOME/credentials.toml` file
81///
82/// # Arguments
83///
84/// * `registry` — The registry name (e.g., `"crates-io"`).
85/// * `cargo_home` — Optional path to `CARGO_HOME` (defaults to `$CARGO_HOME`
86///   or the platform home directory's `.cargo`).
87pub fn resolve_token(registry: &str, cargo_home: Option<&Path>) -> AuthInfo {
88    // 1. CARGO_REGISTRY_TOKEN (for default/crates-io registry)
89    if (registry == CRATES_IO_REGISTRY || registry.is_empty())
90        && let Ok(token) = env::var(CARGO_REGISTRY_TOKEN_ENV)
91        && !token.is_empty()
92    {
93        return AuthInfo {
94            token: Some(token),
95            source: TokenSource::EnvDefault,
96            detected: true,
97        };
98    }
99
100    // 2. CARGO_REGISTRIES_<NAME>_TOKEN
101    let env_var = format!(
102        "{}{}_TOKEN",
103        CARGO_REGISTRIES_TOKEN_PREFIX,
104        registry.to_uppercase().replace('-', "_")
105    );
106    if let Ok(token) = env::var(&env_var)
107        && !token.is_empty()
108    {
109        return AuthInfo {
110            token: Some(token),
111            source: TokenSource::EnvRegistry,
112            detected: true,
113        };
114    }
115
116    // 3. Credentials file
117    let home = cargo_home_path(cargo_home);
118    let credentials_path = home.join(CREDENTIALS_FILE);
119
120    if let Ok(token) = token_from_credentials_file(&credentials_path, registry) {
121        return AuthInfo {
122            token: Some(token),
123            source: TokenSource::CredentialsFile,
124            detected: true,
125        };
126    }
127
128    AuthInfo::default()
129}
130
131/// Check whether any token is available for the given registry.
132///
133/// This is a convenience wrapper around [`resolve_token`] that returns
134/// `true` when a token was found from any source.
135pub fn has_token(registry: &str, cargo_home: Option<&Path>) -> bool {
136    resolve_token(registry, cargo_home).detected
137}
138
139/// Resolve the `CARGO_HOME` directory path.
140///
141/// Checks, in order:
142/// 1. The explicit `cargo_home` argument (if `Some`).
143/// 2. The `CARGO_HOME` environment variable.
144/// 3. `~/.cargo` (platform home directory).
145/// 4. Falls back to `.cargo` in the current directory.
146pub fn cargo_home_path(cargo_home: Option<&Path>) -> PathBuf {
147    if let Some(path) = cargo_home {
148        return path.to_path_buf();
149    }
150
151    if let Ok(path) = env::var(CARGO_HOME_ENV) {
152        return PathBuf::from(path);
153    }
154
155    if let Some(home) = dirs::home_dir() {
156        return home.join(".cargo");
157    }
158
159    PathBuf::from(".cargo")
160}
161
162/// Mask a token for safe display.
163///
164/// Shows the first 4 and last 4 characters, replacing the middle with
165/// `****`. Tokens of 8 characters or fewer are fully masked.
166pub fn mask_token(token: &str) -> String {
167    if token.len() <= 8 {
168        return "*".repeat(token.len());
169    }
170    format!("{}****{}", &token[..4], &token[token.len() - 4..])
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use tempfile::tempdir;
177
178    #[test]
179    fn mask_token_short() {
180        assert_eq!(mask_token("abc"), "***");
181        assert_eq!(mask_token("abcdefgh"), "********");
182    }
183
184    #[test]
185    fn mask_token_long() {
186        assert_eq!(mask_token("abcdefghijklmnop"), "abcd****mnop");
187    }
188
189    #[test]
190    fn mask_token_empty() {
191        assert_eq!(mask_token(""), "");
192    }
193
194    #[test]
195    fn mask_token_boundary_nine_chars() {
196        assert_eq!(mask_token("123456789"), "1234****6789");
197    }
198
199    #[test]
200    fn mask_token_exactly_eight_chars() {
201        assert_eq!(mask_token("12345678"), "********");
202    }
203
204    #[test]
205    fn mask_token_single_char() {
206        assert_eq!(mask_token("x"), "*");
207    }
208
209    #[test]
210    fn cargo_home_path_uses_env() {
211        let td = tempdir().expect("tempdir");
212        let path = cargo_home_path(Some(td.path()));
213        assert_eq!(path, td.path());
214    }
215
216    #[test]
217    fn cargo_home_path_explicit_overrides_env() {
218        let explicit = tempdir().expect("tempdir");
219        temp_env::with_var(CARGO_HOME_ENV, Some("/some/other/path"), || {
220            let path = cargo_home_path(Some(explicit.path()));
221            assert_eq!(path, explicit.path());
222        });
223    }
224
225    #[test]
226    fn cargo_home_path_falls_back_to_env_var() {
227        let td = tempdir().expect("tempdir");
228        temp_env::with_var(CARGO_HOME_ENV, Some(td.path().to_str().unwrap()), || {
229            let path = cargo_home_path(None);
230            assert_eq!(path, td.path());
231        });
232    }
233
234    #[test]
235    fn cargo_home_path_no_env_falls_to_home_dir() {
236        temp_env::with_var(CARGO_HOME_ENV, None::<&str>, || {
237            let path = cargo_home_path(None);
238            assert!(path.to_str().unwrap().contains(".cargo"));
239        });
240    }
241
242    #[test]
243    fn resolve_token_from_env_default() {
244        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("test-token"), || {
245            let auth = resolve_token(CRATES_IO_REGISTRY, None);
246            assert!(auth.detected);
247            assert_eq!(auth.token, Some("test-token".to_string()));
248            assert_eq!(auth.source, TokenSource::EnvDefault);
249        });
250    }
251
252    #[test]
253    fn resolve_token_from_env_registry() {
254        temp_env::with_var(
255            "CARGO_REGISTRIES_MY_REGISTRY_TOKEN",
256            Some("custom-token"),
257            || {
258                let auth = resolve_token("my-registry", None);
259                assert!(auth.detected);
260                assert_eq!(auth.token, Some("custom-token".to_string()));
261                assert_eq!(auth.source, TokenSource::EnvRegistry);
262            },
263        );
264    }
265
266    #[test]
267    fn resolve_token_none_found() {
268        temp_env::with_vars(
269            [
270                (CARGO_REGISTRY_TOKEN_ENV, None::<String>),
271                ("CARGO_REGISTRIES_TEST_TOKEN", None::<String>),
272            ],
273            || {
274                let auth = resolve_token("test", None);
275                assert!(!auth.detected);
276                assert!(auth.token.is_none());
277            },
278        );
279    }
280
281    #[test]
282    fn token_source_display() {
283        assert_eq!(TokenSource::None.to_string(), "none");
284        assert_eq!(TokenSource::EnvDefault.to_string(), "CARGO_REGISTRY_TOKEN");
285        assert_eq!(
286            TokenSource::EnvRegistry.to_string(),
287            "CARGO_REGISTRIES_<NAME>_TOKEN"
288        );
289        assert_eq!(TokenSource::CredentialsFile.to_string(), "credentials.toml");
290    }
291
292    #[test]
293    fn auth_info_default_values() {
294        let info = AuthInfo::default();
295        assert!(info.token.is_none());
296        assert_eq!(info.source, TokenSource::None);
297        assert!(!info.detected);
298    }
299
300    #[test]
301    fn resolve_token_empty_env_is_skipped() {
302        temp_env::with_vars(
303            [
304                (CARGO_REGISTRY_TOKEN_ENV, Some("")),
305                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
306            ],
307            || {
308                let td = tempdir().expect("tempdir");
309                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
310                assert!(!auth.detected);
311                assert!(auth.token.is_none());
312                assert_eq!(auth.source, TokenSource::None);
313            },
314        );
315    }
316
317    #[test]
318    fn resolve_token_empty_registry_specific_env_is_skipped() {
319        temp_env::with_var("CARGO_REGISTRIES_MY_REG_TOKEN", Some(""), || {
320            let td = tempdir().expect("tempdir");
321            let auth = resolve_token("my-reg", Some(td.path()));
322            assert!(!auth.detected);
323            assert!(auth.token.is_none());
324        });
325    }
326
327    #[test]
328    fn resolve_token_empty_registry_name_uses_default_env() {
329        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("default-tok"), || {
330            let auth = resolve_token("", None);
331            assert!(auth.detected);
332            assert_eq!(auth.token, Some("default-tok".to_string()));
333            assert_eq!(auth.source, TokenSource::EnvDefault);
334        });
335    }
336
337    #[test]
338    fn resolve_token_custom_registry_ignores_default_env() {
339        temp_env::with_vars(
340            [
341                (CARGO_REGISTRY_TOKEN_ENV, Some("default-tok")),
342                ("CARGO_REGISTRIES_CUSTOM_REG_TOKEN", None::<&str>),
343            ],
344            || {
345                let td = tempdir().expect("tempdir");
346                let auth = resolve_token("custom-reg", Some(td.path()));
347                assert!(!auth.detected);
348            },
349        );
350    }
351
352    #[test]
353    fn resolve_token_env_default_takes_priority_over_credentials() {
354        let td = tempdir().expect("tempdir");
355        let creds = td.path().join(CREDENTIALS_FILE);
356        std::fs::write(&creds, "[registry]\ntoken = \"creds-token\"\n").expect("write");
357
358        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("env-token"), || {
359            let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
360            assert!(auth.detected);
361            assert_eq!(auth.token, Some("env-token".to_string()));
362            assert_eq!(auth.source, TokenSource::EnvDefault);
363        });
364    }
365
366    #[test]
367    fn resolve_token_env_registry_takes_priority_over_credentials() {
368        let td = tempdir().expect("tempdir");
369        let creds = td.path().join(CREDENTIALS_FILE);
370        std::fs::write(
371            &creds,
372            "[registries.my-registry]\ntoken = \"creds-token\"\n",
373        )
374        .expect("write");
375
376        temp_env::with_var(
377            "CARGO_REGISTRIES_MY_REGISTRY_TOKEN",
378            Some("env-token"),
379            || {
380                let auth = resolve_token("my-registry", Some(td.path()));
381                assert!(auth.detected);
382                assert_eq!(auth.token, Some("env-token".to_string()));
383                assert_eq!(auth.source, TokenSource::EnvRegistry);
384            },
385        );
386    }
387
388    #[test]
389    fn resolve_token_falls_through_to_credentials_file() {
390        let td = tempdir().expect("tempdir");
391        let creds = td.path().join(CREDENTIALS_FILE);
392        std::fs::write(&creds, "[registry]\ntoken = \"file-token\"\n").expect("write");
393
394        temp_env::with_vars(
395            [
396                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
397                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
398            ],
399            || {
400                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
401                assert!(auth.detected);
402                assert_eq!(auth.token, Some("file-token".to_string()));
403                assert_eq!(auth.source, TokenSource::CredentialsFile);
404            },
405        );
406    }
407
408    #[test]
409    fn resolve_token_custom_registry_from_credentials_file() {
410        let td = tempdir().expect("tempdir");
411        let creds = td.path().join(CREDENTIALS_FILE);
412        std::fs::write(&creds, "[registries.private-reg]\ntoken = \"priv-token\"\n")
413            .expect("write");
414
415        temp_env::with_var("CARGO_REGISTRIES_PRIVATE_REG_TOKEN", None::<&str>, || {
416            let auth = resolve_token("private-reg", Some(td.path()));
417            assert!(auth.detected);
418            assert_eq!(auth.token, Some("priv-token".to_string()));
419            assert_eq!(auth.source, TokenSource::CredentialsFile);
420        });
421    }
422
423    #[test]
424    fn has_token_returns_true_when_found() {
425        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("tok"), || {
426            assert!(has_token(CRATES_IO_REGISTRY, None));
427        });
428    }
429
430    #[test]
431    fn has_token_returns_false_when_missing() {
432        temp_env::with_vars(
433            [
434                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
435                ("CARGO_REGISTRIES_NOEXIST_TOKEN", None::<&str>),
436            ],
437            || {
438                let td = tempdir().expect("tempdir");
439                assert!(!has_token("noexist", Some(td.path())));
440            },
441        );
442    }
443
444    #[test]
445    fn token_source_equality() {
446        assert_eq!(TokenSource::None, TokenSource::None);
447        assert_eq!(TokenSource::EnvDefault, TokenSource::EnvDefault);
448        assert_ne!(TokenSource::EnvDefault, TokenSource::EnvRegistry);
449        assert_ne!(TokenSource::CredentialsFile, TokenSource::None);
450    }
451
452    #[test]
453    fn resolve_token_registry_name_with_hyphens_maps_to_underscores() {
454        temp_env::with_var(
455            "CARGO_REGISTRIES_MY_CUSTOM_REG_TOKEN",
456            Some("hyphen-tok"),
457            || {
458                let auth = resolve_token("my-custom-reg", None);
459                assert!(auth.detected);
460                assert_eq!(auth.token, Some("hyphen-tok".to_string()));
461                assert_eq!(auth.source, TokenSource::EnvRegistry);
462            },
463        );
464    }
465
466    #[test]
467    fn resolve_token_registry_name_uppercased() {
468        temp_env::with_var("CARGO_REGISTRIES_MYREG_TOKEN", Some("upper-tok"), || {
469            let auth = resolve_token("myReg", None);
470            assert!(auth.detected);
471            assert_eq!(auth.token, Some("upper-tok".to_string()));
472            assert_eq!(auth.source, TokenSource::EnvRegistry);
473        });
474    }
475
476    #[test]
477    fn env_default_over_env_registry_for_crates_io() {
478        temp_env::with_vars(
479            [
480                (CARGO_REGISTRY_TOKEN_ENV, Some("env-default")),
481                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", Some("env-registry")),
482            ],
483            || {
484                let auth = resolve_token(CRATES_IO_REGISTRY, None);
485                assert!(auth.detected);
486                assert_eq!(auth.token, Some("env-default".to_string()));
487                assert_eq!(auth.source, TokenSource::EnvDefault);
488            },
489        );
490    }
491
492    #[test]
493    fn env_registry_used_when_default_unset_for_crates_io() {
494        let td = tempdir().expect("tempdir");
495        temp_env::with_vars(
496            [
497                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
498                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", Some("env-registry")),
499            ],
500            || {
501                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
502                assert!(auth.detected);
503                assert_eq!(auth.token, Some("env-registry".to_string()));
504                assert_eq!(auth.source, TokenSource::EnvRegistry);
505            },
506        );
507    }
508
509    #[test]
510    fn full_precedence_chain_env_default_wins() {
511        let td = tempdir().expect("tempdir");
512        let creds = td.path().join(CREDENTIALS_FILE);
513        std::fs::write(&creds, "[registry]\ntoken = \"file-token\"\n").expect("write");
514
515        temp_env::with_vars(
516            [
517                (CARGO_REGISTRY_TOKEN_ENV, Some("env-default")),
518                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", Some("env-registry")),
519            ],
520            || {
521                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
522                assert_eq!(auth.source, TokenSource::EnvDefault);
523                assert_eq!(auth.token, Some("env-default".to_string()));
524            },
525        );
526    }
527
528    #[test]
529    fn full_precedence_chain_env_registry_second() {
530        let td = tempdir().expect("tempdir");
531        let creds = td.path().join(CREDENTIALS_FILE);
532        std::fs::write(&creds, "[registry]\ntoken = \"file-token\"\n").expect("write");
533
534        temp_env::with_vars(
535            [
536                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
537                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", Some("env-registry")),
538            ],
539            || {
540                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
541                assert_eq!(auth.source, TokenSource::EnvRegistry);
542                assert_eq!(auth.token, Some("env-registry".to_string()));
543            },
544        );
545    }
546
547    #[test]
548    fn full_precedence_chain_file_last() {
549        let td = tempdir().expect("tempdir");
550        let creds = td.path().join(CREDENTIALS_FILE);
551        std::fs::write(&creds, "[registry]\ntoken = \"file-token\"\n").expect("write");
552
553        temp_env::with_vars(
554            [
555                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
556                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
557            ],
558            || {
559                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
560                assert_eq!(auth.source, TokenSource::CredentialsFile);
561                assert_eq!(auth.token, Some("file-token".to_string()));
562            },
563        );
564    }
565
566    #[test]
567    fn resolve_token_whitespace_only_env_is_not_skipped() {
568        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("   "), || {
569            let auth = resolve_token(CRATES_IO_REGISTRY, None);
570            assert!(auth.detected);
571            assert_eq!(auth.token, Some("   ".to_string()));
572        });
573    }
574
575    #[test]
576    fn resolve_token_very_long_env_token() {
577        let long_token = "x".repeat(10_000);
578        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some(long_token.as_str()), || {
579            let auth = resolve_token(CRATES_IO_REGISTRY, None);
580            assert!(auth.detected);
581            assert_eq!(auth.token.as_deref(), Some(long_token.as_str()));
582        });
583    }
584
585    #[test]
586    fn resolve_token_env_with_unicode() {
587        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("tök€n_πλ∞"), || {
588            let auth = resolve_token(CRATES_IO_REGISTRY, None);
589            assert!(auth.detected);
590            assert_eq!(auth.token, Some("tök€n_πλ∞".to_string()));
591        });
592    }
593
594    #[test]
595    fn resolve_token_env_with_tabs() {
596        temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("token\twith\ttabs"), || {
597            let auth = resolve_token(CRATES_IO_REGISTRY, None);
598            assert!(auth.detected);
599            assert_eq!(auth.token, Some("token\twith\ttabs".to_string()));
600        });
601    }
602
603    #[test]
604    fn resolve_token_env_with_newlines() {
605        temp_env::with_var(
606            CARGO_REGISTRY_TOKEN_ENV,
607            Some("token\nwith\nnewlines"),
608            || {
609                let auth = resolve_token(CRATES_IO_REGISTRY, None);
610                assert!(auth.detected);
611                assert_eq!(auth.token, Some("token\nwith\nnewlines".to_string()));
612            },
613        );
614    }
615
616    #[test]
617    fn resolve_token_no_cargo_home_no_env_no_creds() {
618        temp_env::with_vars(
619            [
620                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
621                (CARGO_HOME_ENV, None::<&str>),
622                ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
623            ],
624            || {
625                let td = tempdir().expect("tempdir");
626                let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
627                assert!(!auth.detected);
628                assert!(auth.token.is_none());
629                assert_eq!(auth.source, TokenSource::None);
630            },
631        );
632    }
633
634    #[test]
635    fn resolve_token_multiple_registries_independent() {
636        temp_env::with_vars(
637            [
638                (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
639                ("CARGO_REGISTRIES_ALPHA_TOKEN", Some("alpha-token")),
640                ("CARGO_REGISTRIES_BETA_TOKEN", Some("beta-token")),
641            ],
642            || {
643                let auth_a = resolve_token("alpha", None);
644                let auth_b = resolve_token("beta", None);
645                assert_eq!(auth_a.token, Some("alpha-token".to_string()));
646                assert_eq!(auth_b.token, Some("beta-token".to_string()));
647                assert_eq!(auth_a.source, TokenSource::EnvRegistry);
648                assert_eq!(auth_b.source, TokenSource::EnvRegistry);
649            },
650        );
651    }
652
653    #[test]
654    fn resolve_token_registry_with_numbers_in_name() {
655        temp_env::with_var("CARGO_REGISTRIES_REG123_TOKEN", Some("num-tok"), || {
656            let auth = resolve_token("reg123", None);
657            assert!(auth.detected);
658            assert_eq!(auth.token, Some("num-tok".to_string()));
659        });
660    }
661
662    #[test]
663    fn resolve_token_registry_single_char_name() {
664        temp_env::with_var("CARGO_REGISTRIES_X_TOKEN", Some("x-tok"), || {
665            let auth = resolve_token("x", None);
666            assert!(auth.detected);
667            assert_eq!(auth.token, Some("x-tok".to_string()));
668            assert_eq!(auth.source, TokenSource::EnvRegistry);
669        });
670    }
671
672    // ── snapshot tests ──────────────────────────────────────────────────
673
674    mod snapshots {
675        use super::*;
676        use insta::assert_debug_snapshot;
677        use tempfile::tempdir;
678
679        #[test]
680        fn snapshot_resolve_token_from_env_default() {
681            temp_env::with_vars(
682                [
683                    (CARGO_REGISTRY_TOKEN_ENV, Some("cio-secret-token-value")),
684                    ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
685                ],
686                || {
687                    let auth = resolve_token(CRATES_IO_REGISTRY, None);
688                    assert_debug_snapshot!(auth);
689                },
690            );
691        }
692
693        #[test]
694        fn snapshot_resolve_token_from_env_registry() {
695            temp_env::with_vars(
696                [
697                    (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
698                    ("CARGO_REGISTRIES_MY_REGISTRY_TOKEN", Some("my-reg-token")),
699                ],
700                || {
701                    let td = tempdir().expect("tempdir");
702                    let auth = resolve_token("my-registry", Some(td.path()));
703                    assert_debug_snapshot!(auth);
704                },
705            );
706        }
707
708        #[test]
709        fn snapshot_resolve_token_none_found() {
710            temp_env::with_vars(
711                [
712                    (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
713                    ("CARGO_REGISTRIES_MISSING_TOKEN", None::<&str>),
714                ],
715                || {
716                    let td = tempdir().expect("tempdir");
717                    let auth = resolve_token("missing", Some(td.path()));
718                    assert_debug_snapshot!(auth);
719                },
720            );
721        }
722
723        #[test]
724        fn snapshot_resolve_token_from_credentials_file() {
725            let td = tempdir().expect("tempdir");
726            let creds = td.path().join(CREDENTIALS_FILE);
727            std::fs::write(&creds, "[registry]\ntoken = \"file-secret-token\"\n").expect("write");
728
729            temp_env::with_vars(
730                [
731                    (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
732                    ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
733                ],
734                || {
735                    let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
736                    assert_debug_snapshot!(auth);
737                },
738            );
739        }
740
741        #[test]
742        fn snapshot_resolve_token_custom_registry_from_credentials() {
743            let td = tempdir().expect("tempdir");
744            let creds = td.path().join(CREDENTIALS_FILE);
745            std::fs::write(
746                &creds,
747                "[registries.private-reg]\ntoken = \"priv-token-abc\"\n",
748            )
749            .expect("write");
750
751            temp_env::with_var("CARGO_REGISTRIES_PRIVATE_REG_TOKEN", None::<&str>, || {
752                let auth = resolve_token("private-reg", Some(td.path()));
753                assert_debug_snapshot!(auth);
754            });
755        }
756
757        #[test]
758        fn snapshot_auth_info_default() {
759            let info = AuthInfo::default();
760            assert_debug_snapshot!(info);
761        }
762    }
763
764    // ── edge snapshots ──────────────────────────────────────────────────
765
766    mod edge_snapshots {
767        use super::*;
768        use insta::assert_debug_snapshot;
769
770        #[test]
771        fn snapshot_token_source_none() {
772            assert_debug_snapshot!(TokenSource::None);
773        }
774
775        #[test]
776        fn snapshot_token_source_env_default() {
777            assert_debug_snapshot!(TokenSource::EnvDefault);
778        }
779
780        #[test]
781        fn snapshot_token_source_env_registry() {
782            assert_debug_snapshot!(TokenSource::EnvRegistry);
783        }
784
785        #[test]
786        fn snapshot_token_source_credentials_file() {
787            assert_debug_snapshot!(TokenSource::CredentialsFile);
788        }
789
790        #[test]
791        fn snapshot_auth_info_with_env_default() {
792            let info = AuthInfo {
793                token: Some("tok-from-env".to_string()),
794                source: TokenSource::EnvDefault,
795                detected: true,
796            };
797            assert_debug_snapshot!(info);
798        }
799
800        #[test]
801        fn snapshot_auth_info_with_env_registry() {
802            let info = AuthInfo {
803                token: Some("tok-from-registry-env".to_string()),
804                source: TokenSource::EnvRegistry,
805                detected: true,
806            };
807            assert_debug_snapshot!(info);
808        }
809
810        #[test]
811        fn snapshot_auth_info_with_credentials_file() {
812            let info = AuthInfo {
813                token: Some("tok-from-file".to_string()),
814                source: TokenSource::CredentialsFile,
815                detected: true,
816            };
817            assert_debug_snapshot!(info);
818        }
819
820        #[test]
821        fn snapshot_auth_info_not_detected() {
822            let info = AuthInfo {
823                token: None,
824                source: TokenSource::None,
825                detected: false,
826            };
827            assert_debug_snapshot!(info);
828        }
829
830        #[test]
831        fn snapshot_resolve_whitespace_token() {
832            temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("   "), || {
833                let auth = resolve_token(CRATES_IO_REGISTRY, None);
834                assert_debug_snapshot!(auth);
835            });
836        }
837
838        #[test]
839        fn snapshot_resolve_unicode_token() {
840            temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some("tök€n_πλ∞"), || {
841                let auth = resolve_token(CRATES_IO_REGISTRY, None);
842                assert_debug_snapshot!(auth);
843            });
844        }
845
846        #[test]
847        fn snapshot_mask_token_short_values() {
848            let results: Vec<String> = ["", "a", "ab", "abcd", "abcdefgh"]
849                .iter()
850                .map(|t| mask_token(t))
851                .collect();
852            assert_debug_snapshot!(results);
853        }
854
855        #[test]
856        fn snapshot_mask_token_long_value() {
857            assert_debug_snapshot!(mask_token("abcdefghijklmnopqrstuvwxyz"));
858        }
859    }
860
861    // ── error message snapshots ──────────────────────────────────────────
862
863    mod error_message_snapshots {
864        use super::*;
865        use tempfile::tempdir;
866
867        #[test]
868        fn snapshot_error_missing_token_message() {
869            temp_env::with_vars(
870                [
871                    (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
872                    ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
873                ],
874                || {
875                    let td = tempdir().expect("tempdir");
876                    let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
877                    insta::assert_snapshot!(
878                        "error_msg_missing_token",
879                        format!(
880                            "detected={}, source={}, has_token={}",
881                            auth.detected,
882                            auth.source,
883                            auth.token.is_some()
884                        )
885                    );
886                },
887            );
888        }
889
890        #[test]
891        fn snapshot_error_token_source_display_none() {
892            insta::assert_snapshot!(
893                "error_msg_token_source_none",
894                format!("Token source: {}", TokenSource::None)
895            );
896        }
897    }
898
899    // ── property-based tests ─────────────────────────────────────────────
900
901    mod proptests {
902        use super::*;
903        use proptest::prelude::*;
904
905        fn token_strategy() -> impl Strategy<Value = String> {
906            "[a-zA-Z0-9_\\-\\.]{1,128}"
907        }
908
909        fn registry_name_strategy() -> impl Strategy<Value = String> {
910            "[a-z][a-z0-9\\-]{0,20}"
911        }
912
913        proptest! {
914            #[test]
915            fn env_default_token_resolution(token in token_strategy()) {
916                temp_env::with_vars(
917                    [
918                        (CARGO_REGISTRY_TOKEN_ENV, Some(token.as_str())),
919                        ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<&str>),
920                    ],
921                    || {
922                        let auth = resolve_token(CRATES_IO_REGISTRY, None);
923                        prop_assert_eq!(auth.token.as_deref(), Some(token.as_str()));
924                        prop_assert_eq!(auth.source, TokenSource::EnvDefault);
925                        prop_assert!(auth.detected);
926                        Ok(())
927                    },
928                )?;
929            }
930
931            #[test]
932            fn env_registry_token_resolution(
933                name in registry_name_strategy(),
934                token in token_strategy(),
935            ) {
936                let env_var = format!(
937                    "{}{}_TOKEN",
938                    CARGO_REGISTRIES_TOKEN_PREFIX,
939                    name.to_uppercase().replace('-', "_")
940                );
941                temp_env::with_vars(
942                    [
943                        (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
944                        (env_var.as_str(), Some(token.as_str())),
945                    ],
946                    || {
947                        let auth = resolve_token(&name, None);
948                        prop_assert_eq!(auth.token.as_deref(), Some(token.as_str()));
949                        prop_assert_eq!(auth.source, TokenSource::EnvRegistry);
950                        prop_assert!(auth.detected);
951                        Ok(())
952                    },
953                )?;
954            }
955
956            #[test]
957            fn env_token_takes_precedence_over_credentials(
958                env_token in token_strategy(),
959                file_token in token_strategy(),
960            ) {
961                let td = tempfile::tempdir().expect("tempdir");
962                let creds = td.path().join(CREDENTIALS_FILE);
963                let content = format!("[registry]\ntoken = \"{file_token}\"\n");
964                std::fs::write(&creds, &content).expect("write");
965
966                temp_env::with_var(CARGO_REGISTRY_TOKEN_ENV, Some(env_token.as_str()), || {
967                    let auth = resolve_token(CRATES_IO_REGISTRY, Some(td.path()));
968                    prop_assert_eq!(auth.token.as_deref(), Some(env_token.as_str()));
969                    prop_assert_eq!(auth.source, TokenSource::EnvDefault);
970                    Ok(())
971                })?;
972            }
973
974            #[test]
975            fn env_registry_token_takes_precedence_over_credentials(
976                name in registry_name_strategy(),
977                env_token in token_strategy(),
978                file_token in token_strategy(),
979            ) {
980                let td = tempfile::tempdir().expect("tempdir");
981                let creds = td.path().join(CREDENTIALS_FILE);
982                let content = format!("[registries.{name}]\ntoken = \"{file_token}\"\n");
983                std::fs::write(&creds, &content).expect("write");
984
985                let env_var = format!(
986                    "{}{}_TOKEN",
987                    CARGO_REGISTRIES_TOKEN_PREFIX,
988                    name.to_uppercase().replace('-', "_")
989                );
990                temp_env::with_vars(
991                    [
992                        (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
993                        (env_var.as_str(), Some(env_token.as_str())),
994                    ],
995                    || {
996                        let auth = resolve_token(&name, Some(td.path()));
997                        prop_assert_eq!(auth.token.as_deref(), Some(env_token.as_str()));
998                        prop_assert_eq!(auth.source, TokenSource::EnvRegistry);
999                        Ok(())
1000                    },
1001                )?;
1002            }
1003
1004            #[test]
1005            fn mask_token_never_exposes_middle(token in "[[:ascii:]]{1,200}") {
1006                let masked = mask_token(&token);
1007                if token.len() <= 8 {
1008                    prop_assert!(masked.chars().all(|c| c == '*'));
1009                    prop_assert_eq!(masked.len(), token.len());
1010                } else {
1011                    prop_assert!(masked.starts_with(&token[..4]));
1012                    prop_assert!(masked.ends_with(&token[token.len() - 4..]));
1013                    prop_assert!(masked.contains("****"));
1014                }
1015            }
1016
1017            #[test]
1018            fn resolve_token_never_panics(registry in "[a-zA-Z0-9_\\-]{0,50}") {
1019                let td = tempfile::tempdir().expect("tempdir");
1020                temp_env::with_vars(
1021                    [
1022                        (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
1023                        (CARGO_HOME_ENV, None::<&str>),
1024                    ],
1025                    || -> Result<(), proptest::test_runner::TestCaseError> {
1026                        let _ = resolve_token(&registry, Some(td.path()));
1027                        Ok(())
1028                    },
1029                )?;
1030            }
1031
1032            #[test]
1033            fn cargo_home_path_never_panics(home in "[^\x00]{1,100}") {
1034                temp_env::with_var(CARGO_HOME_ENV, Some(home.as_str()), || {
1035                    let _ = cargo_home_path(None);
1036                });
1037            }
1038
1039            #[test]
1040            fn mask_token_never_panics(token in "[[:ascii:]]{1,500}") {
1041                let _ = mask_token(&token);
1042            }
1043
1044            #[test]
1045            fn has_token_never_panics(registry in "[a-zA-Z0-9_\\-]{0,50}") {
1046                let td = tempfile::tempdir().expect("tempdir");
1047                temp_env::with_vars(
1048                    [
1049                        (CARGO_REGISTRY_TOKEN_ENV, None::<&str>),
1050                        (CARGO_HOME_ENV, None::<&str>),
1051                    ],
1052                    || -> Result<(), proptest::test_runner::TestCaseError> {
1053                        let _ = has_token(&registry, Some(td.path()));
1054                        Ok(())
1055                    },
1056                )?;
1057            }
1058        }
1059    }
1060}