mod atomic;
mod hook;
mod lock;
mod migration;
#[doc(hidden)]
pub mod snapshot;
mod tokens;
pub mod types;
pub(crate) use atomic::write_atomically;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
#[allow(unused_imports)] pub use types::{App, LoadState, OAuth1Token, OAuth2Token, Token, TokenType};
use crate::error::{Error, Result};
pub struct TokenStore {
pub apps: BTreeMap<String, App>,
pub default_app: String,
pub file_path: PathBuf,
pub load_state: LoadState,
}
impl std::fmt::Debug for TokenStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenStore")
.field("file_path", &self.file_path)
.field("default_app", &self.default_app)
.field("apps", &self.apps.keys().collect::<Vec<_>>())
.field("load_state", &self.load_state)
.finish()
}
}
impl Default for TokenStore {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)] impl TokenStore {
#[must_use]
pub fn new() -> Self {
Self::with_credentials("", "")
}
#[must_use]
pub fn with_credentials(client_id: &str, client_secret: &str) -> Self {
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let file_path = home_dir.join(".xurl");
let mut store = TokenStore {
apps: BTreeMap::new(),
default_app: String::new(),
file_path,
load_state: LoadState::Fresh,
};
store.load_backing_file();
for app in store.apps.values_mut() {
if app.has_tokens() {
if app.client_id.is_empty() && !client_id.is_empty() {
app.client_id = client_id.to_string();
}
if app.client_secret.is_empty() && !client_secret.is_empty() {
app.client_secret = client_secret.to_string();
}
}
}
let needs_import = match store.active_app() {
None => true,
Some(app) => app.oauth1_token.is_none() || app.bearer_token.is_none(),
};
if needs_import {
let twurlrc_path = home_dir.join(".twurlrc");
if twurlrc_path.exists()
&& let Err(e) = store.import_from_twurlrc(&twurlrc_path)
{
tracing::warn!(target: "xdk::store", "error importing from .twurlrc: {e}");
}
}
store
}
#[must_use]
pub fn new_with_path(path: &str) -> Self {
let file_path = PathBuf::from(path);
let mut store = TokenStore {
apps: BTreeMap::new(),
default_app: String::new(),
file_path,
load_state: LoadState::Fresh,
};
store.load_backing_file();
store
}
#[must_use]
pub fn new_with_credentials_and_path(client_id: &str, client_secret: &str, path: &str) -> Self {
let mut store = Self::new_with_path(path);
for app in store.apps.values_mut() {
if app.has_tokens() || app.client_id.is_empty() {
if app.client_id.is_empty() && !client_id.is_empty() {
app.client_id = client_id.to_string();
}
if app.client_secret.is_empty() && !client_secret.is_empty() {
app.client_secret = client_secret.to_string();
}
}
}
store
}
#[must_use]
#[doc(hidden)]
pub fn new_with_home(home: &str) -> Self {
let home_path = PathBuf::from(home);
let file_path = home_path.join(".xurl");
let mut store = TokenStore {
apps: BTreeMap::new(),
default_app: String::new(),
file_path,
load_state: LoadState::Fresh,
};
store.load_backing_file();
let needs_import = match store.active_app() {
None => true,
Some(app) => app.oauth1_token.is_none(),
};
if needs_import {
let twurlrc_path = home_path.join(".twurlrc");
if twurlrc_path.exists() {
let _ = store.import_from_twurlrc(&twurlrc_path);
}
}
store
}
fn load_backing_file(&mut self) {
self.load_state = match fs::read(&self.file_path) {
Ok(data) if data.iter().all(u8::is_ascii_whitespace) => LoadState::Fresh,
Ok(data) => {
if self.load_from_data(&data) {
LoadState::Loaded
} else {
LoadState::Unparseable
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => LoadState::Fresh,
Err(_) => LoadState::Unreadable,
};
}
#[must_use]
pub fn load_from_path(path: &str) -> Self {
Self::new_with_path(path)
}
pub fn add_app(&mut self, name: &str, client_id: &str, client_secret: &str) -> Result<()> {
self.refuse_if_app_present(name)?;
validate_app_name(name)?;
self.update(|store| {
store.refuse_if_app_present(name)?;
let promote = store.apps.is_empty() || store.default_lacks_credentials();
store.apps.insert(
name.to_string(),
App::with_credentials(client_id, client_secret),
);
if promote {
store.default_app = name.to_string();
}
Ok(())
})
}
fn require_app(&self, name: &str) -> Result<()> {
if self.apps.contains_key(name) {
Ok(())
} else {
Err(Error::token_store(format!("app {name:?} not found")))
}
}
fn require_user(app: &App, username: &str) -> Result<()> {
if app.oauth2_tokens.contains_key(username) {
Ok(())
} else {
Err(Error::token_store(format!(
"user {username:?} not found in app"
)))
}
}
fn refuse_if_app_present(&self, name: &str) -> Result<()> {
if self.apps.contains_key(name) {
Err(Error::token_store(format!("app {name:?} already exists")))
} else {
Ok(())
}
}
fn default_lacks_credentials(&self) -> bool {
match self.apps.get(&self.default_app) {
None => true,
Some(app) => app.client_id.is_empty() && !app.has_tokens(),
}
}
pub fn update_app(&mut self, name: &str, client_id: &str, client_secret: &str) -> Result<()> {
self.require_app(name)?;
self.update(|store| {
let app = store
.apps
.get_mut(name)
.ok_or_else(|| Error::token_store(format!("app {name:?} not found")))?;
if !client_id.is_empty() {
app.client_id = client_id.to_string();
}
if !client_secret.is_empty() {
app.client_secret = client_secret.to_string();
}
Ok(())
})
}
pub fn remove_app(&mut self, name: &str) -> Result<()> {
self.require_app(name)?;
self.update(|store| {
store.require_app(name)?;
store.apps.remove(name);
if store.default_app == name {
store.default_app = store.apps.keys().next().cloned().unwrap_or_default();
}
Ok(())
})
}
pub fn set_default_app(&mut self, name: &str) -> Result<()> {
self.require_app(name)?;
self.update(|store| {
store.require_app(name)?;
store.default_app = name.to_string();
Ok(())
})
}
#[must_use]
pub fn default_app_is_uninitialized(&self) -> bool {
let app = self.resolve_app("");
app.oauth2_tokens.is_empty()
&& app.oauth1_token.is_none()
&& app.bearer_token.is_none()
&& app.unnamed_oauth2_token.is_none()
}
pub fn promote_to_default_if_first_credentialed(
&mut self,
candidate_app: &str,
) -> Result<Option<String>> {
if candidate_app.is_empty() || candidate_app == self.default_app {
return Ok(None);
}
if !self.apps.contains_key(candidate_app) {
return Ok(None);
}
if !self.default_app_is_uninitialized() {
return Ok(None);
}
self.update(|store| {
if !store.apps.contains_key(candidate_app) || !store.default_app_is_uninitialized() {
return Ok(None);
}
store.default_app = candidate_app.to_string();
Ok(Some(candidate_app.to_string()))
})
}
#[must_use]
pub fn list_apps(&self) -> Vec<String> {
self.apps.keys().cloned().collect()
}
#[must_use]
pub fn get_app(&self, name: &str) -> Option<&App> {
self.apps.get(name)
}
pub fn set_default_user(&mut self, app_name: &str, username: &str) -> Result<()> {
Self::require_user(self.resolve_app(app_name), username)?;
self.update(|store| {
let app = store.resolve_app_mut(app_name);
Self::require_user(app, username)?;
app.default_user = username.to_string();
Ok(())
})
}
#[must_use]
pub fn get_default_user(&self, app_name: &str) -> &str {
let app = self.resolve_app(app_name);
&app.default_user
}
pub fn set_app_redirect_uri(&mut self, name: &str, uri: &str) -> Result<()> {
if !name.is_empty() {
self.require_app(name)?;
}
if !uri.is_empty() {
let _ = crate::config::Config::validate_redirect_uri(uri)?;
}
self.update(|store| {
if !name.is_empty() {
store.require_app(name)?;
}
let app = store.resolve_app_mut(name);
app.redirect_uri = uri.to_string();
Ok(())
})
}
#[must_use]
pub fn get_app_redirect_uri(&self, name: &str) -> Option<&str> {
let app = self.resolve_app(name);
if app.redirect_uri.is_empty() {
None
} else {
Some(app.redirect_uri.as_str())
}
}
#[must_use]
pub fn get_default_app(&self) -> &str {
&self.default_app
}
#[must_use]
pub fn get_active_app_name<'a>(&'a self, explicit: &'a str) -> &'a str {
if explicit.is_empty() {
&self.default_app
} else {
explicit
}
}
pub(crate) fn active_app(&self) -> Option<&App> {
self.apps.get(&self.default_app)
}
pub(crate) fn active_app_or_create(&mut self) -> &mut App {
if !self.apps.contains_key(&self.default_app) {
self.apps.insert("default".to_string(), App::new());
if self.default_app.is_empty() {
self.default_app = "default".to_string();
}
}
let key = if self.apps.contains_key(&self.default_app) {
self.default_app.clone()
} else {
"default".to_string()
};
self.apps.get_mut(&key).expect("just inserted")
}
#[must_use]
pub fn resolve_app(&self, name: &str) -> &App {
if !name.is_empty()
&& let Some(app) = self.apps.get(name)
{
return app;
}
self.apps.get(&self.default_app).unwrap_or_else(|| {
static EMPTY: std::sync::LazyLock<App> = std::sync::LazyLock::new(App::new);
&EMPTY
})
}
pub fn resolve_app_mut(&mut self, name: &str) -> &mut App {
if !name.is_empty() && self.apps.contains_key(name) {
return self.apps.get_mut(name).expect("just checked");
}
self.active_app_or_create()
}
pub fn update<R>(&mut self, f: impl FnOnce(&mut Self) -> Result<R>) -> Result<R> {
let lock = lock::StoreLock::acquire(&self.file_path)?;
if lock.is_reentrant() {
return f(self);
}
self.reload_locked();
self.refuse_if_load_failed()?;
let out = f(self)?;
self.save_to_file()?;
Ok(out)
}
pub async fn update_at<R, F>(path: PathBuf, f: F) -> Result<R>
where
F: FnOnce(&mut Self) -> Result<R> + Send + 'static,
R: Send + 'static,
{
let dispatch = tracing::Dispatch::default();
tokio::task::spawn_blocking(move || {
tracing::dispatcher::with_default(&dispatch, || {
let mut store = Self::new_with_path(&path.to_string_lossy());
store.update(f)
})
})
.await
.map_err(|e| Error::Internal(format!("store update task failed: {e}")))?
}
fn reload_locked(&mut self) {
let held: Vec<(String, String, String)> = self
.apps
.iter()
.map(|(name, app)| {
(
name.clone(),
app.client_id.clone(),
app.client_secret.clone(),
)
})
.collect();
self.load_backing_file();
for (name, client_id, client_secret) in held {
if let Some(app) = self.apps.get_mut(&name) {
if app.client_id.is_empty() {
app.client_id = client_id;
}
if app.client_secret.is_empty() {
app.client_secret = client_secret;
}
}
}
}
pub(crate) fn save_locked(&self) -> Result<()> {
let _lock = lock::StoreLock::acquire(&self.file_path)?;
self.save_to_file()
}
pub(crate) fn save_to_file(&self) -> Result<()> {
self.refuse_if_load_failed()?;
let sf = types::StoreFile {
apps: self.apps.clone(),
default_app: self.default_app.clone(),
};
let data = serde_yaml::to_string(&sf).map_err(|e| Error::Json(e.to_string()))?;
write_atomically(&self.file_path, data.as_bytes())?;
Ok(())
}
}
impl TokenStore {
#[must_use]
pub fn load_failed(&self) -> bool {
matches!(
self.load_state,
LoadState::Unreadable | LoadState::Unparseable
)
}
fn refuse_if_load_failed(&self) -> Result<()> {
if self.load_failed() {
return Err(Error::token_store(format!(
"refusing to write {}: the file exists but could not be loaded; fix or move it, then retry",
self.file_path.display()
)));
}
Ok(())
}
}
#[must_use]
#[doc(hidden)]
pub fn is_app_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')
}
fn validate_app_name(name: &str) -> Result<()> {
if name.is_empty() || !name.chars().all(is_app_name_char) {
return Err(Error::validation(format!(
"invalid app name {name:?}: use letters, digits, '_', '.', and '-' only"
)));
}
Ok(())
}