use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::config::Config;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
Config,
Store,
Missing,
}
impl KeySource {
pub fn label(&self) -> &'static str {
match self {
Self::Config => "from drep.toml",
Self::Store => "from the drep auth store",
Self::Missing => "not set - run `drep auth login` or add `api_key` to drep.toml",
}
}
}
#[derive(Default, Serialize, Deserialize)]
pub struct AuthStore {
#[serde(default)]
keys: BTreeMap<String, String>,
}
impl std::fmt::Debug for AuthStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthStore")
.field("endpoints", &self.keys.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("no config directory for this platform; set the key in drep.toml instead")]
NoConfigDir,
#[error("could not read {0}: {1}")]
Read(PathBuf, std::io::Error),
#[error("could not write {0}: {1}")]
Write(PathBuf, std::io::Error),
#[error("could not parse {0}: {1}")]
Parse(PathBuf, String),
#[error("could not serialize the auth store: {0}")]
Serialize(String),
#[error("refusing to store an empty key for {0}")]
EmptyKey(String),
#[error("the key for {0} contains a character that cannot be sent in a header")]
UnusableKey(String),
}
pub const PATH_VAR: &str = "DREP_AUTH_PATH";
pub fn default_path() -> Result<PathBuf, AuthError> {
path_from(std::env::var_os(PATH_VAR))
}
pub fn path_from(overridden: Option<std::ffi::OsString>) -> Result<PathBuf, AuthError> {
if let Some(path) = overridden {
return Ok(PathBuf::from(path));
}
directories::ProjectDirs::from("dev", "slb350", "drep")
.map(|dirs| dirs.config_dir().join("auth.toml"))
.ok_or(AuthError::NoConfigDir)
}
impl AuthStore {
pub fn new() -> Self {
Self::default()
}
pub fn load(path: &Path) -> Result<Self, AuthError> {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
Err(err) => return Err(AuthError::Read(path.to_path_buf(), err)),
};
toml::from_str(&content)
.map_err(|err: toml::de::Error| AuthError::Parse(path.to_path_buf(), err.to_string()))
}
pub fn save(&self, path: &Path) -> Result<(), AuthError> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
ensure_dir_private(parent)?;
}
let body =
toml::to_string_pretty(self).map_err(|err| AuthError::Serialize(err.to_string()))?;
write_private(path, &body)
}
pub fn get(&self, endpoint: &str) -> Option<&str> {
self.keys.get(&normalise(endpoint)).map(String::as_str)
}
pub fn set(&mut self, endpoint: &str, key: &str) -> Result<(), AuthError> {
let key = key.trim();
if key.is_empty() {
return Err(AuthError::EmptyKey(endpoint.to_string()));
}
if key.chars().any(|c| c.is_control()) {
return Err(AuthError::UnusableKey(endpoint.to_string()));
}
self.keys.insert(normalise(endpoint), key.to_string());
Ok(())
}
pub fn remove(&mut self, endpoint: &str) -> bool {
self.keys.remove(&normalise(endpoint)).is_some()
}
pub fn endpoints(&self) -> Vec<&str> {
self.keys.keys().map(String::as_str).collect()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
}
pub fn normalise(endpoint: &str) -> String {
let trimmed = endpoint.trim().trim_end_matches('/');
match trimmed.find("://") {
Some(scheme_end) => format!(
"{}://{}",
trimmed[..scheme_end].to_ascii_lowercase(),
lower_authority(&trimmed[scheme_end + 3..])
),
None => lower_authority(trimmed),
}
}
fn lower_authority(rest: &str) -> String {
let host_len = rest.find('/').unwrap_or(rest.len());
format!(
"{}{}",
rest[..host_len].to_ascii_lowercase(),
&rest[host_len..]
)
}
pub fn resolve(config: &mut Config, store: &AuthStore) -> Vec<KeySource> {
config
.llm
.iter_mut()
.map(|llm| {
let source = source_of(
llm.api_key.as_deref(),
llm.endpoint.as_deref(),
llm.enabled,
store,
);
if source == KeySource::Store
&& let Some(endpoint) = llm.endpoint.as_deref()
&& let Some(key) = store.get(endpoint)
{
llm.api_key = Some(key.to_string());
}
source
})
.collect()
}
pub fn source_of(
api_key: Option<&str>,
endpoint: Option<&str>,
enabled: bool,
store: &AuthStore,
) -> KeySource {
if !enabled {
return KeySource::Missing;
}
if api_key.is_some() {
return KeySource::Config;
}
match endpoint {
Some(endpoint) if store.get(endpoint).is_some() => KeySource::Store,
_ => KeySource::Missing,
}
}
pub(crate) fn ensure_dir_private(dir: &Path) -> Result<(), AuthError> {
let existed = dir.exists();
std::fs::create_dir_all(dir).map_err(|err| AuthError::Write(dir.to_path_buf(), err))?;
if !existed {
restrict(dir, 0o700)?;
}
Ok(())
}
fn write_private(path: &Path, body: &str) -> Result<(), AuthError> {
use std::io::Write;
let temporary = temp_beside(path);
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|err| AuthError::Write(temporary.clone(), err))?;
file.write_all(body.as_bytes())
.map_err(|err| AuthError::Write(temporary.clone(), err))?;
file.sync_all()
.map_err(|err| AuthError::Write(temporary.clone(), err))?;
drop(file);
restrict(&temporary, 0o600)?;
std::fs::rename(&temporary, path).map_err(|err| {
let _ = std::fs::remove_file(&temporary);
AuthError::Write(path.to_path_buf(), err)
})
}
fn temp_beside(path: &Path) -> std::path::PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".drep-tmp");
path.with_file_name(name)
}
#[cfg(unix)]
fn restrict(path: &Path, mode: u32) -> Result<(), AuthError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.map_err(|err| AuthError::Write(path.to_path_buf(), err))
}
#[cfg(not(unix))]
fn restrict(_path: &Path, _mode: u32) -> Result<(), AuthError> {
Ok(())
}
#[cfg(test)]
mod tests;