use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::backend::{BackendKey, KeychainBackend};
use crate::error::{KeystoreError, Result};
const EXT: &str = "dks";
pub struct FileBackend {
root: PathBuf,
}
impl FileBackend {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
fn path_for(&self, key: &BackendKey) -> PathBuf {
let mut p = self.root.clone();
p.push(format!("{}.{}", key.as_str(), EXT));
p
}
fn ensure_root(&self) -> Result<()> {
if self.root.exists() {
return Ok(());
}
fs::create_dir_all(&self.root)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700));
}
Ok(())
}
}
impl KeychainBackend for FileBackend {
fn read(&self, key: &BackendKey) -> Result<Vec<u8>> {
let path = self.path_for(key);
let mut f = fs::File::open(&path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
Ok(buf)
}
fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
self.ensure_root()?;
let final_path = self.path_for(key);
let mut tmp_path = final_path.clone();
let rand_suffix: u64 = fastrand_suffix();
tmp_path.set_extension(format!("{EXT}.tmp.{rand_suffix:016x}"));
{
let mut f = fs::File::create(&tmp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
}
f.write_all(data)?;
f.sync_all()?;
}
fs::rename(&tmp_path, &final_path).map_err(|e| {
let _ = fs::remove_file(&tmp_path);
KeystoreError::from(e)
})?;
#[cfg(unix)]
{
if let Ok(dir) = fs::File::open(&self.root) {
let _ = dir.sync_all();
}
}
Ok(())
}
fn delete(&self, key: &BackendKey) -> Result<()> {
let path = self.path_for(key);
if !path.exists() {
return Ok(());
}
if let Ok(metadata) = fs::metadata(&path) {
let len = metadata.len();
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(&path) {
let zeros = vec![0u8; 4096];
let mut remaining = len as usize;
while remaining > 0 {
let n = remaining.min(zeros.len());
if f.write_all(&zeros[..n]).is_err() {
break;
}
remaining -= n;
}
let _ = f.sync_all();
}
}
fs::remove_file(&path)?;
Ok(())
}
fn list(&self, prefix: &str) -> Result<Vec<BackendKey>> {
if !self.root.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(&self.root)? {
let entry = entry?;
let name = entry.file_name();
let name = match name.to_str() {
Some(s) => s,
None => continue,
};
let Some(stem) = name.strip_suffix(&format!(".{EXT}")) else {
continue;
};
if stem.starts_with(prefix) {
out.push(BackendKey::new(stem.to_string()));
}
}
Ok(out)
}
fn exists(&self, key: &BackendKey) -> Result<bool> {
Ok(self.path_for(key).exists())
}
}
fn fastrand_suffix() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
let ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let pid = std::process::id() as u64;
ns.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(pid)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn write_then_read_roundtrip() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
let key = BackendKey::new("test");
be.write(&key, b"hello").unwrap();
let out = be.read(&key).unwrap();
assert_eq!(out, b"hello");
}
#[test]
fn write_is_atomic_on_rename_failure() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
let key = BackendKey::new("atomic");
be.write(&key, b"first").unwrap();
be.write(&key, b"second").unwrap();
assert_eq!(be.read(&key).unwrap(), b"second");
let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
for e in entries {
let name = e.unwrap().file_name();
let s = name.to_string_lossy().into_owned();
assert!(!s.contains(".tmp."), "leftover tmp file: {s}");
}
}
#[test]
fn delete_removes_file() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
let key = BackendKey::new("delete_me");
be.write(&key, b"bye").unwrap();
assert!(be.exists(&key).unwrap());
be.delete(&key).unwrap();
assert!(!be.exists(&key).unwrap());
}
#[test]
fn delete_is_idempotent() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
be.delete(&BackendKey::new("never_existed")).unwrap();
}
#[test]
fn list_with_prefix() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
be.write(&BackendKey::new("alpha"), b"a").unwrap();
be.write(&BackendKey::new("alpha2"), b"a").unwrap();
be.write(&BackendKey::new("beta"), b"b").unwrap();
let mut keys = be.list("alph").unwrap();
keys.sort_by_key(|k| k.0.clone());
assert_eq!(
keys,
vec![BackendKey::new("alpha"), BackendKey::new("alpha2")]
);
}
#[test]
fn read_nonexistent_returns_error() {
let dir = TempDir::new().unwrap();
let be = FileBackend::new(dir.path().to_path_buf());
let err = be.read(&BackendKey::new("missing")).unwrap_err();
let is_not_found = match &err {
KeystoreError::Backend(io) => io.kind() == std::io::ErrorKind::NotFound,
_ => false,
};
assert!(is_not_found);
}
#[test]
fn creates_root_dir() {
let dir = TempDir::new().unwrap();
let sub = dir.path().join("nested/keys");
let be = FileBackend::new(sub.clone());
assert!(!sub.exists());
be.write(&BackendKey::new("k"), b"x").unwrap();
assert!(sub.exists());
}
}