use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use anyhow::{Context, Result};
use uuid::Uuid;
use super::{ConnectionConfig, Credentials, SshAuth};
#[cfg_attr(test, allow(dead_code))]
const SERVICE: &str = "zippa-db";
const FILE_NAME: &str = "connections.json";
#[cfg(test)]
std::thread_local! {
static TEST_CONFIG_DIR: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_config_dir_for_test(path: PathBuf) {
TEST_CONFIG_DIR.with(|dir| *dir.borrow_mut() = Some(path));
}
pub(crate) fn config_dir() -> Result<PathBuf> {
#[cfg(test)]
return Ok({
static FALLBACK: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
TEST_CONFIG_DIR
.with(|dir| dir.borrow().clone())
.unwrap_or_else(|| {
FALLBACK
.get_or_init(|| {
std::env::temp_dir()
.join(format!("zippa-db-test-config-{}", std::process::id()))
})
.clone()
})
});
#[cfg(not(test))]
{
let dir = dirs::config_dir().context("no config directory for this platform")?;
Ok(dir.join(SERVICE))
}
}
fn config_file() -> Result<PathBuf> {
Ok(config_dir()?.join(FILE_NAME))
}
pub fn load() -> Result<Vec<ConnectionConfig>> {
load_json(&config_file()?)
}
pub(crate) fn write_restricted(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write as _;
use std::os::unix::fs::OpenOptionsExt as _;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())
}
#[cfg(not(unix))]
{
fs::write(path, contents)
}
}
pub fn save(connections: &[ConnectionConfig], ticket: Ticket) -> Result<()> {
let contents = serde_json::to_string_pretty(connections)?;
write_atomic(&config_file()?, &contents, ticket)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Ticket(u64);
pub(crate) fn ticket() -> Ticket {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
Ticket(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}
pub(crate) fn write_atomic(path: &Path, contents: &str, ticket: Ticket) -> Result<()> {
static WRITTEN: Mutex<BTreeMap<PathBuf, Ticket>> = Mutex::new(BTreeMap::new());
let mut written = WRITTEN.lock().unwrap_or_else(PoisonError::into_inner);
if written.get(path).is_some_and(|last| *last > ticket) {
return Ok(());
}
let dir = path.parent().context("no config directory")?;
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
let name = path
.file_name()
.context("no file name")?
.to_string_lossy()
.into_owned();
let temporary = dir.join(format!("{name}.tmp"));
write_restricted(&temporary, contents)
.with_context(|| format!("could not write {}", temporary.display()))?;
if let Err(error) = fs::rename(&temporary, path) {
write_restricted(path, contents)
.with_context(|| format!("could not write {}: {error:#}", path.display()))?;
let _ = fs::remove_file(&temporary);
}
written.insert(path.to_path_buf(), ticket);
Ok(())
}
pub(crate) fn load_json<T>(path: &Path) -> Result<T>
where
T: serde::de::DeserializeOwned + Default,
{
if !path.exists() {
return Ok(T::default());
}
let contents =
fs::read_to_string(path).with_context(|| format!("could not read {}", path.display()))?;
match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(error) => {
let mut aside = path.as_os_str().to_owned();
aside.push(".unreadable");
let aside = PathBuf::from(aside);
let kept = match fs::rename(path, &aside) {
Ok(()) => format!("it was kept as {}", aside.display()),
Err(rename) => format!("it could not be moved aside: {rename}"),
};
Err(anyhow::Error::new(error)
.context(format!("could not parse {}; {kept}", path.display())))
}
}
}
#[cfg(not(test))]
fn entry(id: &Uuid) -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, &id.to_string()).context("no OS credential store available")
}
pub fn password(id: &Uuid) -> Result<Option<String>> {
#[cfg(not(test))]
{
match entry(id)?.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
{
let _ = id;
Ok(None)
}
}
pub fn set_password(id: &Uuid, password: &str) -> Result<()> {
#[cfg(not(test))]
{
if password.is_empty() {
return delete_password(id);
}
entry(id)?
.set_password(password)
.context("could not save the password to the OS credential store")
}
#[cfg(test)]
{
let _ = (id, password);
Ok(())
}
}
pub fn delete_password(id: &Uuid) -> Result<()> {
#[cfg(not(test))]
{
match entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
{
let _ = id;
Ok(())
}
}
#[derive(Clone, Default)]
pub struct SecretEdits {
pub password: Option<String>,
pub ssh: Option<String>,
}
impl SecretEdits {
pub fn save(&self, id: &Uuid) -> Result<()> {
if let Some(password) = &self.password {
set_password(id, password)?;
}
if let Some(secret) = &self.ssh {
set_ssh_secret(id, secret)?;
}
Ok(())
}
}
pub fn credentials(
config: &ConnectionConfig,
edits: &SecretEdits,
saved: bool,
) -> Result<Credentials> {
let typed = |secret: &str| Some(secret.to_string()).filter(|secret| !secret.is_empty());
let server = !config.engine.is_file_based();
let password = match &edits.password {
Some(password) => typed(password),
None if saved && server => password(&config.id)?,
None => None,
};
let ssh = if server && config.ssh.enabled && config.ssh.auth != SshAuth::Agent {
match &edits.ssh {
Some(secret) => typed(secret),
None if saved => ssh_secret(&config.id)?,
None => None,
}
} else {
None
};
Ok(Credentials { password, ssh })
}
#[cfg(not(test))]
fn ssh_entry(id: &Uuid) -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, &format!("{id}/ssh")).context("no OS credential store available")
}
pub fn ssh_secret(id: &Uuid) -> Result<Option<String>> {
#[cfg(not(test))]
{
match ssh_entry(id)?.get_password() {
Ok(secret) => Ok(Some(secret)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
{
let _ = id;
Ok(None)
}
}
pub fn set_ssh_secret(id: &Uuid, secret: &str) -> Result<()> {
#[cfg(not(test))]
{
if secret.is_empty() {
return delete_ssh_secret(id);
}
ssh_entry(id)?
.set_password(secret)
.context("could not save the SSH secret to the OS credential store")
}
#[cfg(test)]
{
let _ = (id, secret);
Ok(())
}
}
pub fn delete_ssh_secret(id: &Uuid) -> Result<()> {
#[cfg(not(test))]
{
match ssh_entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(error.into()),
}
}
#[cfg(test)]
{
let _ = id;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct ScratchDir(PathBuf);
impl ScratchDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("zippa-db-store-test-{}", Uuid::new_v4()));
fs::create_dir_all(&path).expect("could not create the scratch directory");
Self(path)
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
#[cfg(unix)]
fn saved_connections_are_restricted_to_the_owner() {
use std::os::unix::fs::PermissionsExt as _;
let dir = ScratchDir::new();
set_config_dir_for_test(dir.0.clone());
save(&[], ticket()).expect("could not save the connections");
let mode = fs::metadata(dir.0.join(FILE_NAME))
.expect("the file should exist")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the file should be owner-only");
}
#[test]
#[cfg(unix)]
fn write_restricted_creates_an_owner_only_file() {
use std::os::unix::fs::PermissionsExt as _;
let dir = ScratchDir::new();
let path = dir.0.join("owner-only.json");
write_restricted(&path, "{}").expect("could not write the file");
let mode = fs::metadata(&path)
.expect("the file should exist")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the file should be owner-only");
}
}