use crate::{Result, config::DEFAULT_KEY_USER, prelude::*};
use snafu::prelude::*;
use std::{
fmt,
path::{Path, PathBuf},
};
use tracing::{debug, error, warn};
#[derive(Clone, PartialEq, Eq)]
pub enum KeyStoreType {
File,
Keyring,
None,
#[cfg(feature = "keystore-ext")]
Other(String),
}
impl fmt::Display for KeyStoreType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
KeyStoreType::File => "file",
KeyStoreType::Keyring => "keyring",
KeyStoreType::None => "none",
#[cfg(feature = "keystore-ext")]
KeyStoreType::Other(s) => s.as_str(),
})
}
}
impl fmt::Debug for KeyStoreType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!(
"KeyStoreType({})",
match self {
KeyStoreType::File => "File",
KeyStoreType::Keyring => "Keyring",
KeyStoreType::None => "None",
#[cfg(feature = "keystore-ext")]
KeyStoreType::Other(s) => s.as_str(),
}
))
}
}
#[derive(Clone)]
pub struct SecretApiKey(String);
impl SecretApiKey {
pub fn new(key: impl Into<String>) -> Self {
SecretApiKey(key.into())
}
#[cfg(feature = "keystore-ext")]
pub fn get_key(&self) -> &str {
&self.0
}
pub(crate) fn set_auth_header(
&self,
request_builder: reqwest::RequestBuilder,
) -> reqwest::RequestBuilder {
request_builder.bearer_auth(&self.0)
}
#[cfg(test)]
#[doc(hidden)]
pub fn check_key(&self, value: &str) -> bool {
self.0 == value
}
}
impl<S: Into<String>> From<S> for SecretApiKey {
fn from(value: S) -> Self {
SecretApiKey::new(value)
}
}
impl Drop for SecretApiKey {
fn drop(&mut self) {
use zeroize::Zeroize;
self.0.zeroize()
}
}
impl fmt::Display for SecretApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&format!("SecretApiKey(REDACTED[len={}])", self.0.len()))
}
}
impl fmt::Debug for SecretApiKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&format!("SecretApiKey(REDACTED[len={}])", self.0.len()))
}
}
pub trait KeyStore: fmt::Debug + Send + Sync {
fn load_key(&self) -> std::result::Result<Option<SecretApiKey>, KeyStoreError>;
fn save_key(&self, api_key: &SecretApiKey) -> std::result::Result<(), KeyStoreError>;
fn remove_key(&self) -> std::result::Result<(), KeyStoreError>;
fn is_configured(&self) -> bool {
self.store_type() != KeyStoreType::None
}
fn store_type(&self) -> KeyStoreType;
}
#[derive(Clone, Debug)]
pub struct KeyStoreFile {
path: PathBuf,
}
#[derive(Clone, Debug)]
pub struct KeyStoreKeyring {
service_name: String,
user_name: String,
}
impl KeyStoreFile {
pub fn from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_owned();
if !path.is_file()
&& let Some(parent) = path.parent()
{
std::fs::create_dir_all(parent).context(FileSnafu { path: parent })?;
}
Ok(Self {
path: path.to_owned(),
})
}
pub fn new(service_name: impl AsRef<str>) -> Result<Self> {
if let Ok(path) = std::env::var(crate::config::ANYTYPE_KEY_FILE_ENV) {
return Self::from_path(path);
}
let config_dir = default_config_dir()?.join(service_name.as_ref());
std::fs::create_dir_all(&config_dir).context(FileSnafu {
path: config_dir.clone(),
})?;
let path = config_dir.join(DEFAULT_KEY_USER);
Ok(KeyStoreFile { path })
}
pub fn store_type(&self) -> KeyStoreType {
KeyStoreType::File
}
}
impl KeyStoreKeyring {
pub fn new(service_name: impl AsRef<str>, user_name: Option<String>) -> Self {
let service_name = service_name.as_ref().to_string();
let user_name = user_name.unwrap_or_else(|| DEFAULT_KEY_USER.to_string());
KeyStoreKeyring {
service_name,
user_name,
}
}
}
impl KeyStore for KeyStoreFile {
fn load_key(&self) -> std::result::Result<Option<SecretApiKey>, KeyStoreError> {
if !self.path.exists() {
return Ok(None);
}
let api_key = std::fs::read_to_string(&self.path)
.context(FileSnafu {
path: self.path.clone(),
})?
.trim()
.to_string();
if api_key.is_empty() {
warn!("keystore file {:?} is empty", self.path.display());
return Ok(None);
}
debug!(path=?self.path, "load_key: key found in file");
Ok(Some(SecretApiKey::new(api_key)))
}
fn save_key(&self, api_key: &SecretApiKey) -> std::result::Result<(), KeyStoreError> {
std::fs::write(&self.path, &api_key.0).context(FileSnafu { path: &self.path })?;
#[cfg(target_family = "unix")]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&self.path)
.context(FileSnafu { path: &self.path })?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&self.path, perms).context(FileSnafu { path: &self.path })?;
}
debug!(path=?self.path, "key saved");
Ok(())
}
fn remove_key(&self) -> std::result::Result<(), KeyStoreError> {
if self.path.exists() {
std::fs::remove_file(&self.path).context(FileSnafu { path: &self.path })?;
}
debug!(path=?self.path, "key removed");
Ok(())
}
fn store_type(&self) -> KeyStoreType {
KeyStoreType::File
}
}
impl KeyStore for KeyStoreKeyring {
fn load_key(&self) -> std::result::Result<Option<SecretApiKey>, KeyStoreError> {
let entry =
keyring::Entry::new(&self.service_name, &self.user_name).context(KeyringSnafu {
service: &self.service_name,
user: &self.user_name,
})?;
match entry.get_password() {
Ok(password) => {
debug!("key loaded from keyring");
Ok(Some(SecretApiKey::new(password)))
}
Err(keyring::Error::NoEntry) => {
debug!("key undefined in keyring");
Ok(None)
}
Err(e) => {
error!("keyring {e:?}");
Err(KeyStoreError::Keyring {
source: e,
service: self.service_name.clone(),
user: self.user_name.clone(),
})
}
}
}
fn save_key(&self, api_key: &SecretApiKey) -> std::result::Result<(), KeyStoreError> {
let entry =
keyring::Entry::new(&self.service_name, &self.user_name).context(KeyringSnafu {
service: self.service_name.clone(),
user: self.user_name.clone(),
})?;
entry.set_password(&api_key.0).context(KeyringSnafu {
service: self.service_name.clone(),
user: self.user_name.clone(),
})?;
debug!("key saved in keyring");
Ok(())
}
fn remove_key(&self) -> std::result::Result<(), KeyStoreError> {
let entry =
keyring::Entry::new(&self.service_name, &self.user_name).context(KeyringSnafu {
service: self.service_name.clone(),
user: self.user_name.clone(),
})?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => {
debug!(service_name=?self.service_name, user_name=?self.user_name, "key removed from keyring");
Ok(())
}
Err(e) => {
error!(service_name=?self.service_name, user_name=?self.user_name, ?e, "key remove");
Err(KeyStoreError::Keyring {
source: e,
service: self.service_name.clone(),
user: self.user_name.clone(),
})
}
}
}
fn store_type(&self) -> KeyStoreType {
KeyStoreType::Keyring
}
}
fn default_config_dir() -> std::result::Result<PathBuf, KeyStoreError> {
use std::io;
match dirs::config_dir() {
Some(d) => Ok(d),
None => match dirs::home_dir() {
Some(d) => Ok(d.join(".config")),
None => Err(KeyStoreError::File {
source: io::Error::other("cannot determine config directory"),
path: PathBuf::new(),
}),
},
}
}
#[derive(Clone, Debug, Default)]
pub struct NoKeyStore {}
impl KeyStore for NoKeyStore {
fn load_key(&self) -> std::result::Result<Option<SecretApiKey>, KeyStoreError> {
Ok(None)
}
fn save_key(&self, _api_key: &SecretApiKey) -> std::result::Result<(), KeyStoreError> {
Ok(())
}
fn remove_key(&self) -> std::result::Result<(), KeyStoreError> {
Ok(())
}
fn store_type(&self) -> KeyStoreType {
KeyStoreType::None
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::config::DEFAULT_SERVICE_NAME;
#[test]
fn test_file_storage_save_and_load() -> Result<()> {
let temp_dir = std::env::temp_dir().join(format!(
"anytype_rust_api_test_storage_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&temp_dir);
let file_path = temp_dir.join(&format!("{DEFAULT_SERVICE_NAME}.test.key"));
let storage = KeyStoreFile::from_path(&file_path)?;
let no_exist = storage.load_key()?;
assert!(no_exist.is_none());
let test_key = "test-key-123";
storage.save_key(&SecretApiKey::new(test_key))?;
let load_key = storage.load_key();
assert!(matches!(load_key, Ok(Some(_))));
let load_key = load_key.unwrap().unwrap();
assert!(load_key.check_key(test_key), "save+load returns same key");
storage.remove_key()?;
assert!(!file_path.exists(), "file deleted");
let check_file = storage.load_key();
assert!(matches!(check_file, Ok(None)), "expected file removed");
fs::remove_dir_all(&temp_dir).ok();
Ok(())
}
#[test]
#[cfg(unix)]
fn test_file_permissions() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = std::env::temp_dir().join(format!(
"anytype_rust_api_test_perms_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&temp_dir);
let file_path = temp_dir.join(&format!("{DEFAULT_SERVICE_NAME}.test.key"));
let storage = KeyStoreFile::from_path(&file_path)?;
let test_key = "test-key-123";
storage.save_key(&SecretApiKey::new(test_key))?;
let metadata = fs::metadata(&file_path).unwrap();
let permissions = metadata.permissions();
assert_eq!(permissions.mode() & 0o777, 0o600);
fs::remove_dir_all(&temp_dir).ok();
Ok(())
}
#[test]
#[ignore] fn test_keyring_storage_end_to_end() -> Result<()> {
let service_name = format!("{DEFAULT_SERVICE_NAME}.e2etest");
let user_name = "test_api_key";
let storage = KeyStoreKeyring::new(service_name, Some(user_name.to_string()));
let _ = storage.remove_key();
let test_key = "test-keyring-api-key-12345";
storage.save_key(&SecretApiKey::new(test_key))?;
std::thread::sleep(std::time::Duration::from_millis(100));
let loaded_key = storage.load_key()?.expect("loaded key");
assert!(loaded_key.check_key(test_key), "load key from keyring");
storage.remove_key().expect("Should remove from keyring");
println!("✓ Removed test key from keyring");
let after_delete = storage.load_key();
assert!(
matches!(after_delete, Ok(None)),
"after removal from keyring"
);
Ok(())
}
}