Skip to main content

crate_seq_core/
auth.rs

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