1const SERVICE: &str = "ytcli";
12
13const TOKEN_ENV: &str = "YTCLI_TOKEN";
18
19#[derive(Debug, thiserror::Error)]
20pub enum SecretError {
21 #[error("no token stored for account `{0}`; run `ytcli auth login --account {0}`")]
22 Missing(String),
23 #[error(
28 "the OS keychain is unavailable; ytcli never falls back to plaintext storage.\n\
29 Where there is no keychain — a container, CI, a session sandbox — put the token in the \
30 environment as YTCLI_TOKEN instead. Set it as an environment variable, never as a \
31 command-line argument: arguments are visible to every process on the machine"
32 )]
33 Unavailable(#[source] keyring::Error),
34 #[error("keychain error")]
35 Backend(#[source] keyring::Error),
36}
37
38static READ: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
44 std::sync::OnceLock::new();
45
46fn cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, String>> {
47 READ.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
48}
49
50fn entry(account: &str) -> Result<keyring::Entry, SecretError> {
51 keyring::Entry::new(SERVICE, account).map_err(SecretError::Unavailable)
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Origin {
61 Keychain,
62 Environment,
63}
64
65pub fn token(account: &str) -> Result<String, SecretError> {
67 token_from(account).map(|(token, _)| token)
68}
69
70pub fn token_from(account: &str) -> Result<(String, Origin), SecretError> {
75 if let Ok(token) = std::env::var(TOKEN_ENV)
76 && !token.is_empty()
77 {
78 tracing::debug!("using the token from {TOKEN_ENV}");
79 return Ok((token, Origin::Environment));
80 }
81
82 if let Ok(cached) = cache().lock()
86 && let Some(token) = cached.get(account)
87 {
88 return Ok((token.clone(), Origin::Keychain));
89 }
90
91 match entry(account)?.get_password() {
92 Ok(token) => {
93 if let Ok(mut cached) = cache().lock() {
94 cached.insert(account.to_owned(), token.clone());
95 }
96 Ok((token, Origin::Keychain))
97 }
98 Err(keyring::Error::NoEntry) => Err(SecretError::Missing(account.to_owned())),
99 Err(err) => Err(SecretError::Backend(err)),
100 }
101}
102
103#[must_use]
108pub fn overridden() -> bool {
109 std::env::var(TOKEN_ENV).is_ok_and(|token| !token.is_empty())
110}
111
112pub fn store(account: &str, token: &str) -> Result<(), SecretError> {
114 entry(account)?
115 .set_password(token)
116 .map_err(SecretError::Backend)?;
117 if let Ok(mut cached) = cache().lock() {
118 cached.insert(account.to_owned(), token.to_owned());
119 }
120 Ok(())
121}
122
123pub fn forget(account: &str) -> Result<(), SecretError> {
125 if let Ok(mut cached) = cache().lock() {
126 cached.remove(account);
127 }
128 match entry(account)?.delete_credential() {
129 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
130 Err(err) => Err(SecretError::Backend(err)),
131 }
132}
133
134#[must_use]
136pub fn is_stored(account: &str) -> bool {
137 token(account).is_ok()
138}