Skip to main content

binstalk_manifests/
cargo_credentials.rs

1//! Cargo's credentials file.
2//!
3//! Cargo stores plaintext registry tokens in `$CARGO_HOME/credentials.toml`
4//! or the legacy `$CARGO_HOME/credentials` path.
5
6use std::{fs::File, io, path::Path};
7
8use binstalk_types::SecretString;
9use compact_str::CompactString;
10use fs_lock::FileLock;
11use miette::Diagnostic;
12use serde::{Deserialize, Deserializer};
13use thiserror::Error;
14
15#[derive(Clone, Debug, Default, Deserialize)]
16pub struct RegistryCredential {
17    #[serde(default, deserialize_with = "deserialize_secret_string")]
18    pub token: Option<SecretString>,
19}
20
21#[derive(Clone, Debug, Default, Deserialize)]
22pub struct Credentials {
23    pub registries: Option<std::collections::BTreeMap<CompactString, RegistryCredential>>,
24    pub registry: Option<RegistryCredential>,
25}
26
27impl Credentials {
28    pub fn load_from_reader<R: io::Read>(reader: R) -> Result<Self, CredentialsLoadError> {
29        let mut reader = reader;
30        let mut contents = Vec::new();
31        reader.read_to_end(&mut contents)?;
32        let credentials: Credentials = toml_edit::de::from_slice(&contents)?;
33        Ok(credentials)
34    }
35
36    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, CredentialsLoadError> {
37        match File::open(path.as_ref()) {
38            Ok(file) => {
39                let file = FileLock::new_shared(file)?.set_file_path(path.as_ref());
40                Self::load_from_reader(file)
41            }
42            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Default::default()),
43            Err(err) => Err(err.into()),
44        }
45    }
46
47    pub fn load_from_home(cargo_home: impl AsRef<Path>) -> Result<Self, CredentialsLoadError> {
48        let cargo_home = cargo_home.as_ref();
49        let toml_path = cargo_home.join("credentials.toml");
50
51        match File::open(&toml_path) {
52            Ok(file) => {
53                let file = FileLock::new_shared(file)?.set_file_path(toml_path.as_path());
54                Self::load_from_reader(file)
55            }
56            Err(err) if err.kind() == io::ErrorKind::NotFound => {
57                Self::load_from_path(cargo_home.join("credentials"))
58            }
59            Err(err) => Err(err.into()),
60        }
61    }
62
63    pub fn get_registry_token(&self, name: &str) -> Option<&SecretString> {
64        self.registries.as_ref()?.get(name)?.token.as_ref()
65    }
66}
67
68fn deserialize_secret_string<'de, D>(deserializer: D) -> Result<Option<SecretString>, D::Error>
69where
70    D: Deserializer<'de>,
71{
72    Option::<Box<str>>::deserialize(deserializer)
73        .map(|value| value.map(SecretString::from_boxed_str))
74}
75
76#[derive(Debug, Diagnostic, Error)]
77#[non_exhaustive]
78pub enum CredentialsLoadError {
79    #[error("I/O Error: {0}")]
80    Io(#[from] io::Error),
81
82    #[error("Failed to deserialize toml: {0}")]
83    TomlParse(Box<toml_edit::de::Error>),
84}
85
86impl From<toml_edit::de::Error> for CredentialsLoadError {
87    fn from(e: toml_edit::de::Error) -> Self {
88        CredentialsLoadError::TomlParse(Box::new(e))
89    }
90}
91
92impl From<toml_edit::TomlError> for CredentialsLoadError {
93    fn from(e: toml_edit::TomlError) -> Self {
94        CredentialsLoadError::TomlParse(Box::new(e.into()))
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::{fs, io::Cursor};
101
102    use tempfile::tempdir;
103
104    use super::*;
105
106    #[test]
107    fn test_loading() {
108        const CREDENTIALS: &str = r#"
109[registry]
110token = "crates-io-token"
111
112[registries.private-registry]
113token = "private-token"
114        "#;
115
116        let credentials = Credentials::load_from_reader(Cursor::new(CREDENTIALS)).unwrap();
117
118        assert_eq!(
119            credentials
120                .get_registry_token("private-registry")
121                .map(|token| &token[..]),
122            Some("private-token")
123        );
124    }
125
126    #[test]
127    fn test_load_from_home_prefers_toml_path() {
128        let tempdir = tempdir().unwrap();
129        let home = tempdir.path();
130
131        fs::write(
132            home.join("credentials"),
133            "[registries.example]\ntoken = \"legacy\"\n",
134        )
135        .unwrap();
136        fs::write(
137            home.join("credentials.toml"),
138            "[registries.example]\ntoken = \"toml\"\n",
139        )
140        .unwrap();
141
142        let credentials = Credentials::load_from_home(home).unwrap();
143
144        assert_eq!(
145            credentials
146                .get_registry_token("example")
147                .map(|token| &token[..]),
148            Some("toml")
149        );
150    }
151
152    #[test]
153    fn test_registry_credential_debug_redacts_token() {
154        let credential = RegistryCredential {
155            token: Some(SecretString::from_boxed_str("secret-token".into())),
156        };
157
158        let debug = format!("{credential:?}");
159
160        assert!(!debug.contains("secret-token"));
161        assert!(debug.contains("<redacted>"));
162    }
163}