use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use secrecy::{ExposeSecret, SecretString};
use serde::Deserialize;
use wyrd_spec::auth::SecretBearer;
use crate::error::WyrdClientError;
use crate::saved_login::saved_login;
const CREDENTIALS_FILE: &str = "credentials.toml";
const DEFAULT_KEY: &str = "default";
#[derive(Deserialize)]
struct DefaultProfileFile {
default: Option<DefaultProfile>,
}
#[derive(Deserialize)]
struct DefaultProfile {
api_key: Option<String>,
access_token: Option<String>,
access_expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub(crate) struct CredentialsFile {
dir: PathBuf,
filename: &'static str,
}
pub(crate) struct CredentialsLock {
_dir: File,
}
impl CredentialsFile {
pub(crate) fn locate() -> Option<Self> {
wyrd_utils::config_dir::wyrd_config_dir().map(Self::at)
}
pub(crate) fn at(dir: PathBuf) -> Self {
Self {
dir,
filename: CREDENTIALS_FILE,
}
}
pub(crate) fn config_at(dir: PathBuf) -> Self {
Self {
dir,
filename: "config.toml",
}
}
pub(crate) fn api_key(
document: &toml_edit::DocumentMut,
) -> Result<Option<SecretString>, WyrdClientError> {
let profile =
toml::from_str::<DefaultProfileFile>(&document.to_string()).map_err(|_| {
saved_login("corrupt", "credentials.toml has an invalid default profile")
})?;
Ok(profile
.default
.and_then(|profile| profile.api_key)
.filter(|key| !key.is_empty())
.map(SecretString::from))
}
pub(crate) fn cached_api_key_token(
&self,
api_key: &SecretString,
) -> Option<(SecretBearer, DateTime<Utc>)> {
let text = self.read_text().ok()??;
let profile = toml::from_str::<DefaultProfileFile>(&text).ok()?.default?;
if profile.api_key.as_deref() != Some(api_key.expose_secret()) {
return None;
}
Some((
SecretBearer::new(profile.access_token?),
profile.access_expires_at?,
))
}
pub(crate) fn cache_api_key_token(
&self,
api_key: &SecretString,
access_token: &SecretBearer,
expires_at: DateTime<Utc>,
) -> Result<(), WyrdClientError> {
let _lock = self.lock()?;
let mut document = self.document()?;
let Some(profile) = document
.get_mut(DEFAULT_KEY)
.and_then(toml_edit::Item::as_table_like_mut)
else {
return Ok(());
};
if profile.get("api_key").and_then(toml_edit::Item::as_str) != Some(api_key.expose_secret())
{
return Ok(());
}
profile.insert("access_token", toml_edit::value(access_token.expose()));
profile.insert(
"access_expires_at",
toml_edit::value(expires_at.to_rfc3339()),
);
self.replace(&document)
}
pub(crate) fn document(&self) -> Result<toml_edit::DocumentMut, WyrdClientError> {
let Some(text) = self.read_text()? else {
return Ok(toml_edit::DocumentMut::new());
};
text.parse::<toml_edit::DocumentMut>().map_err(|_| {
saved_login(
"corrupt",
format!("{} is not valid TOML", self.path().display()),
)
})
}
pub(crate) fn replace(&self, document: &toml_edit::DocumentMut) -> Result<(), WyrdClientError> {
let mut temp =
tempfile::NamedTempFile::new_in(&self.dir).map_err(|error| io_failure(&error))?;
temp.write_all(document.to_string().as_bytes())
.map_err(|error| io_failure(&error))?;
temp.as_file()
.sync_all()
.map_err(|error| io_failure(&error))?;
temp.persist(self.path())
.map_err(|error| io_failure(&error.error))?;
sync_dir(&self.dir)
}
pub(crate) fn path(&self) -> PathBuf {
self.dir.join(self.filename)
}
fn check_dir(&self, create: bool) -> Result<bool, WyrdClientError> {
match std::fs::symlink_metadata(&self.dir) {
Ok(metadata) => {
if !metadata.is_dir() {
return Err(unsafe_store(&self.dir, "is not a directory"));
}
check_owned(&self.dir, &metadata, 0o022)?;
Ok(true)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
if !create {
return Ok(false);
}
create_private_dir(&self.dir)?;
Ok(true)
}
Err(error) => Err(io_failure(&error)),
}
}
pub(crate) fn lock(&self) -> Result<CredentialsLock, WyrdClientError> {
self.check_dir(true)?;
#[cfg(not(unix))]
return Err(unsafe_store(
&self.dir,
"cannot write the credential file on a platform without POSIX file permissions",
));
#[cfg(unix)]
{
let dir = File::open(&self.dir).map_err(|error| io_failure(&error))?;
dir.lock().map_err(|error| io_failure(&error))?;
Ok(CredentialsLock { _dir: dir })
}
}
pub(crate) fn read_text(&self) -> Result<Option<String>, WyrdClientError> {
if !self.check_dir(false)? {
return Ok(None);
}
let path = self.path();
let metadata = match std::fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_failure(&error)),
};
if !metadata.is_file() {
return Err(unsafe_store(&path, "is not a regular file"));
}
check_owned(
&path,
&metadata,
if self.filename == CREDENTIALS_FILE {
0o077
} else {
0o022
},
)?;
std::fs::read_to_string(&path)
.map(Some)
.map_err(|error| io_failure(&error))
}
}
pub(crate) fn unsafe_store(path: &Path, problem: &str) -> WyrdClientError {
saved_login("unsafe_store", format!("{} {problem}", path.display()))
}
pub(crate) fn io_failure(error: &std::io::Error) -> WyrdClientError {
saved_login("io", format!("credential file IO failed: {error}"))
}
fn sync_dir(dir: &Path) -> Result<(), WyrdClientError> {
#[cfg(unix)]
{
File::open(dir)
.and_then(|handle| handle.sync_all())
.map_err(|error| io_failure(&error))?;
}
#[cfg(not(unix))]
let _ = dir;
Ok(())
}
fn create_private_dir(dir: &Path) -> Result<(), WyrdClientError> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir).map_err(|error| io_failure(&error))
}
fn check_owned(
path: &Path,
metadata: &std::fs::Metadata,
forbidden: u32,
) -> Result<(), WyrdClientError> {
if metadata.file_type().is_symlink() {
return Err(unsafe_store(path, "is a symlink"));
}
#[cfg(not(unix))]
let _ = forbidden;
#[cfg(unix)]
{
if std::os::unix::fs::MetadataExt::uid(metadata) != rustix::process::geteuid().as_raw() {
return Err(unsafe_store(path, "is owned by another user"));
}
if std::os::unix::fs::MetadataExt::mode(metadata) & forbidden != 0 {
return Err(unsafe_store(path, "is accessible to other users"));
}
}
Ok(())
}
#[cfg(test)]
pub(crate) fn private_tempdir() -> tempfile::TempDir {
let mut builder = tempfile::Builder::new();
#[cfg(unix)]
builder
.permissions(<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o700));
builder.tempdir().expect("private tempdir")
}