Skip to main content

crate_seq_core/
auth.rs

1//! Token resolution: CLI arg -> env/cmd -> `~/.cargo/credentials.toml`.
2
3#[cfg(test)]
4#[path = "auth_tests.rs"]
5mod tests;
6
7use std::env;
8use std::io;
9use std::process::Command;
10
11use crate_seq_ledger::LedgerAuth;
12
13/// Errors that can occur during token resolution.
14#[derive(Debug, thiserror::Error)]
15pub enum TokenError {
16    /// The named env var is set but contains an empty string.
17    #[error("token_env '{var}' is set but empty")]
18    EnvVarEmpty {
19        /// The environment variable name.
20        var: String,
21    },
22    /// The token command ran but produced no usable output, or exited non-zero.
23    #[error("token_cmd failed: {reason}")]
24    CmdFailed {
25        /// The stderr or diagnostic message from the command.
26        reason: String,
27    },
28    /// Could not read `~/.cargo/credentials.toml`.
29    #[error("failed to read cargo credentials: {0}")]
30    CargoCredentials(#[source] io::Error),
31    /// No token found after exhausting all tiers.
32    #[error("no token found; tried: {tried}")]
33    NotFound {
34        /// A human-readable list of the sources tried.
35        tried: String,
36    },
37}
38
39/// Returns the home directory path using `HOME` (Unix) or `USERPROFILE` (Windows).
40fn home_dir() -> Option<String> {
41    env::var("HOME")
42        .ok()
43        .or_else(|| env::var("USERPROFILE").ok())
44}
45
46/// Attempts tier 2a: reads `var_name` from the environment.
47///
48/// Returns `Ok(Some(val))` if set and non-empty, `Ok(None)` if not set,
49/// `Err(TokenError::EnvVarEmpty)` if set but empty.
50///
51/// # Errors
52///
53/// Returns `TokenError::EnvVarEmpty` if the variable exists but is empty or non-Unicode.
54fn try_env(var_name: &str) -> Result<Option<String>, TokenError> {
55    match env::var(var_name) {
56        Ok(val) if val.is_empty() => Err(TokenError::EnvVarEmpty {
57            var: var_name.to_owned(),
58        }),
59        Ok(val) => Ok(Some(val)),
60        Err(env::VarError::NotPresent) => Ok(None),
61        Err(env::VarError::NotUnicode(_)) => Err(TokenError::EnvVarEmpty {
62            var: var_name.to_owned(),
63        }),
64    }
65}
66
67/// Attempts tier 2b: runs `cmd` in a shell and captures trimmed stdout.
68///
69/// Returns `Ok(Some(token))` on non-empty output with zero exit,
70/// `Err(TokenError::CmdFailed)` otherwise.
71///
72/// # Errors
73///
74/// Returns `TokenError::CmdFailed` if the process cannot be spawned, exits non-zero,
75/// or produces empty stdout.
76fn try_cmd(cmd: &str) -> Result<Option<String>, TokenError> {
77    let output =
78        Command::new("sh")
79            .args(["-c", cmd])
80            .output()
81            .map_err(|e| TokenError::CmdFailed {
82                reason: e.to_string(),
83            })?;
84
85    let stdout = String::from_utf8_lossy(&output.stdout);
86    let trimmed = stdout.trim();
87
88    if output.status.success() && !trimmed.is_empty() {
89        return Ok(Some(trimmed.to_owned()));
90    }
91
92    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
93    Err(TokenError::CmdFailed {
94        reason: if stderr.is_empty() {
95            format!("command exited with status {}", output.status)
96        } else {
97            stderr
98        },
99    })
100}
101
102/// Attempts tier 3: reads `[registry].token` from a credentials TOML file.
103///
104/// `path_override` allows tests to inject a path; `None` falls back to the
105/// canonical `~/.cargo/credentials.toml` location.
106///
107/// Returns `Ok(None)` if the file does not exist or the key is absent.
108///
109/// # Errors
110///
111/// Returns `TokenError::CargoCredentials` if the file exists but cannot be read
112/// or contains invalid TOML.
113fn try_cargo_credentials_at(
114    path_override: Option<&std::path::Path>,
115) -> Result<Option<String>, TokenError> {
116    let path = if let Some(p) = path_override {
117        p.to_owned()
118    } else {
119        let Some(home) = home_dir() else {
120            return Ok(None);
121        };
122        std::path::PathBuf::from(home)
123            .join(".cargo")
124            .join("credentials.toml")
125    };
126
127    let content = match std::fs::read_to_string(&path) {
128        Ok(s) => s,
129        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
130        Err(e) => return Err(TokenError::CargoCredentials(e)),
131    };
132
133    let doc: toml_edit::DocumentMut = content.parse().map_err(|_| {
134        TokenError::CargoCredentials(io::Error::new(
135            io::ErrorKind::InvalidData,
136            "credentials.toml is not valid TOML",
137        ))
138    })?;
139
140    let token = doc
141        .get("registry")
142        .and_then(|r| r.get("token"))
143        .and_then(|t| t.as_str())
144        .map(ToOwned::to_owned);
145
146    Ok(token)
147}
148
149/// Attempts to resolve a crates.io API token from three sources in priority order.
150///
151/// 1. `cli_token` argument (highest priority -- for scripting and CI).
152/// 2. `ledger_auth.token_env`: reads the named environment variable.
153///    `ledger_auth.token_cmd`: runs the command string in a shell and captures stdout.
154///    Only one of `token_env` or `token_cmd` is used (env takes precedence if both set).
155/// 3. Fall through to `~/.cargo/credentials.toml` `[registry] token` field.
156///
157/// Returns `Ok(Some(token))` if any tier succeeds, `Ok(None)` if no token found
158/// (caller decides whether to proceed -- cargo itself may have credentials).
159///
160/// # Errors
161///
162/// Returns `TokenError::EnvVarEmpty` if `token_env` is set but the variable is empty.
163/// Returns `TokenError::CmdFailed` if `token_cmd` fails or produces no output.
164/// Returns `TokenError::CargoCredentials` if `credentials.toml` exists but is unreadable.
165pub fn resolve_token(
166    cli_token: Option<&str>,
167    ledger_auth: &LedgerAuth,
168) -> Result<Option<String>, TokenError> {
169    resolve_token_inner(cli_token, ledger_auth, None)
170}
171
172/// Inner implementation with injectable credentials path for testing.
173pub(crate) fn resolve_token_inner(
174    cli_token: Option<&str>,
175    ledger_auth: &LedgerAuth,
176    creds_path_override: Option<&std::path::Path>,
177) -> Result<Option<String>, TokenError> {
178    if let Some(t) = cli_token {
179        return Ok(Some(t.to_owned()));
180    }
181
182    if let Some(ref var) = ledger_auth.token_env {
183        if let Some(val) = try_env(var)? {
184            return Ok(Some(val));
185        }
186        // NotPresent: fall through to tier 3
187    } else if let Some(ref cmd) = ledger_auth.token_cmd {
188        return try_cmd(cmd);
189    }
190
191    try_cargo_credentials_at(creds_path_override)
192}
193
194/// Resolves the token for publishing, failing fast with setup instructions if none found.
195///
196/// Returns `Ok(Some(token))` if a token was found, `Ok(None)` if cargo's own
197/// credentials should be used (tier 3 returned nothing -- cargo may still succeed).
198/// Returns `Err` with actionable setup instructions if a tier 2 source was
199/// configured but failed.
200///
201/// # Errors
202///
203/// Returns `Error::TokenResolution` if a configured token source fails.
204pub fn require_token(
205    cli_token: Option<&str>,
206    ledger_auth: &LedgerAuth,
207) -> Result<Option<String>, crate::Error> {
208    match resolve_token(cli_token, ledger_auth) {
209        Ok(token) => Ok(token),
210        Err(TokenError::EnvVarEmpty { var }) => Err(crate::Error::TokenResolution(format!(
211            "env var '{var}' is set but empty; set it to your crates.io token"
212        ))),
213        Err(TokenError::CmdFailed { reason }) => Err(crate::Error::TokenResolution(format!(
214            "token_cmd failed: {reason}\nCheck the command in your .crate-seq.toml [auth] section"
215        ))),
216        Err(TokenError::CargoCredentials(e)) => Err(crate::Error::TokenResolution(format!(
217            "could not read ~/.cargo/credentials.toml: {e}\nRun `cargo login` or set [auth] in .crate-seq.toml"
218        ))),
219        Err(TokenError::NotFound { tried }) => Err(crate::Error::TokenResolution(format!(
220            "no token found (tried: {tried})"
221        ))),
222    }
223}