use std::path::{Path, PathBuf};
use age::x25519;
use crate::config::Config;
use crate::crypto;
use crate::error::{Error, Result};
use crate::path as pathutil;
use crate::secret::Secret;
pub struct Store {
config: Config,
recipients: Vec<x25519::Recipient>,
}
impl std::fmt::Debug for Store {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Store")
.field("store_dir", &self.config.store_dir)
.field("recipients", &self.recipients.len())
.finish()
}
}
impl Store {
pub fn open(config: Config) -> Result<Self> {
if !config.store_dir.exists() {
return Err(Error::StoreNotFound(config.store_dir));
}
let recipients = crypto::load_recipients(&config.recipients_path())?;
Ok(Self { config, recipients })
}
pub fn create(
config: Config,
owner: &x25519::Identity,
extra: &[x25519::Recipient],
) -> Result<Self> {
let recipients_path = config.recipients_path();
if recipients_path.exists() {
return Err(Error::StoreExists(config.store_dir));
}
std::fs::create_dir_all(&config.store_dir)?;
let mut recipients = Vec::with_capacity(extra.len().saturating_add(1));
recipients.push(owner.to_public());
for r in extra {
if !crypto::recipients_contain(&recipients, r) {
recipients.push(r.clone());
}
}
crypto::save_recipients(&recipients_path, &recipients)?;
Ok(Self { config, recipients })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.config.store_dir
}
#[must_use]
pub fn recipients(&self) -> &[x25519::Recipient] {
&self.recipients
}
#[must_use]
pub fn exists(&self, logical: &str) -> bool {
pathutil::validate(logical).is_ok()
&& pathutil::to_file(&self.config.store_dir, logical).is_file()
}
pub fn set(&self, logical: &str, secret: &Secret) -> Result<()> {
pathutil::validate(logical)?;
let ciphertext = crypto::encrypt(secret.as_bytes(), &self.recipients)?;
crypto::write_atomic(
&pathutil::to_file(&self.config.store_dir, logical),
&ciphertext,
)
}
pub fn insert(&self, logical: &str, secret: &Secret) -> Result<()> {
if self.exists(logical) {
return Err(Error::SecretExists(logical.to_owned()));
}
self.set(logical, secret)
}
pub fn get(&self, logical: &str, identity: &x25519::Identity) -> Result<Secret> {
pathutil::validate(logical)?;
let file = pathutil::to_file(&self.config.store_dir, logical);
if !file.exists() {
return Err(Error::SecretNotFound(logical.to_owned()));
}
let plaintext = crypto::decrypt(&std::fs::read(&file)?, identity)?;
let text = std::str::from_utf8(&plaintext)
.map_err(|e| Error::Decrypt(format!("secret is not valid UTF-8: {e}")))?;
Ok(Secret::new(text))
}
pub fn delete(&self, logical: &str) -> Result<()> {
pathutil::validate(logical)?;
let file = pathutil::to_file(&self.config.store_dir, logical);
if !file.exists() {
return Err(Error::SecretNotFound(logical.to_owned()));
}
std::fs::remove_file(&file)?;
prune_empty_parents(&self.config.store_dir, file.parent());
Ok(())
}
pub fn rename(&self, from: &str, to: &str) -> Result<()> {
let (src, dst) = self.relocate_paths(from, to)?;
std::fs::rename(&src, &dst)?;
prune_empty_parents(&self.config.store_dir, src.parent());
Ok(())
}
pub fn copy(&self, from: &str, to: &str) -> Result<()> {
let (src, dst) = self.relocate_paths(from, to)?;
std::fs::copy(&src, &dst)?;
Ok(())
}
pub fn list(&self, prefix: &str) -> Result<Vec<String>> {
let mut out = Vec::new();
walk(&self.config.store_dir, &self.config.store_dir, &mut out)?;
out.sort();
if prefix.is_empty() {
return Ok(out);
}
let scope = format!("{prefix}/");
Ok(out
.into_iter()
.filter(|p| p == prefix || p.starts_with(&scope))
.collect())
}
pub fn grep(&self, query: &str, identity: Option<&x25519::Identity>) -> Result<Vec<String>> {
let needle = query.to_lowercase();
let mut hits = Vec::new();
for path in self.list("")? {
if path.to_lowercase().contains(&needle) {
hits.push(path);
continue;
}
if let Some(id) = identity
&& let Ok(secret) = self.get(&path, id)
&& secret.expose().to_lowercase().contains(&needle)
{
hits.push(path);
}
}
Ok(hits)
}
pub fn set_recipients(
&mut self,
new_recipients: Vec<x25519::Recipient>,
identity: &x25519::Identity,
) -> Result<usize> {
if !crypto::recipients_contain(&new_recipients, &identity.to_public()) {
return Err(Error::InvalidRecipient(
"recipient list must include your own public key".into(),
));
}
let paths = self.list("")?;
for path in &paths {
let secret = self.get(path, identity)?;
let ciphertext = crypto::encrypt(secret.as_bytes(), &new_recipients)?;
crypto::write_atomic(
&pathutil::to_file(&self.config.store_dir, path),
&ciphertext,
)?;
}
crypto::save_recipients(&self.config.recipients_path(), &new_recipients)?;
self.recipients = new_recipients;
Ok(paths.len())
}
fn relocate_paths(&self, from: &str, to: &str) -> Result<(PathBuf, PathBuf)> {
pathutil::validate(from)?;
pathutil::validate(to)?;
let src = pathutil::to_file(&self.config.store_dir, from);
if !src.exists() {
return Err(Error::SecretNotFound(from.to_owned()));
}
if self.exists(to) {
return Err(Error::SecretExists(to.to_owned()));
}
let dst = pathutil::to_file(&self.config.store_dir, to);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
Ok((src, dst))
}
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let entry_path = entry.path();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
if entry.file_name().to_string_lossy().starts_with('.') {
continue;
}
walk(root, &entry_path, out)?;
continue;
}
if let Some(logical) = pathutil::from_file(root, &entry_path) {
out.push(logical);
}
}
Ok(())
}
fn prune_empty_parents(root: &Path, dir: Option<&Path>) {
let Some(mut cur) = dir else { return };
let mut owned: PathBuf;
while cur != root {
let Ok(mut entries) = std::fs::read_dir(cur) else {
return;
};
if entries.next().is_some() {
return;
}
if std::fs::remove_dir(cur).is_err() {
return;
}
let Some(parent) = cur.parent() else { return };
owned = parent.to_path_buf();
cur = &owned;
}
}
#[cfg(test)]
mod tests {
use age::secrecy::SecretString;
use super::*;
fn fresh() -> (Config, x25519::Identity) {
let root = std::env::temp_dir().join(format!("ks-store-{}", rand::random::<u64>()));
std::fs::create_dir_all(&root).expect("temp");
let cfg = Config {
identity_path: root.join("identity.age"),
store_dir: root.join("store"),
};
let id = crypto::create_identity(&cfg.identity_path, SecretString::from("pw".to_owned()))
.expect("identity");
(cfg, id)
}
#[test]
fn set_needs_no_identity_get_does() {
let (cfg, id) = fresh();
let store = Store::create(cfg, &id, &[]).expect("create");
store
.set("github/token", &Secret::new("ghp_xxx\nuser: alice\n"))
.expect("set");
let got = store.get("github/token", &id).expect("get");
assert_eq!(got.password(), "ghp_xxx");
assert_eq!(got.get("user"), Some("alice"));
assert_eq!(
store.list("").expect("list"),
vec!["github/token".to_owned()]
);
}
#[test]
fn rename_and_copy_are_pure_file_ops() {
let (cfg, id) = fresh();
let store = Store::create(cfg, &id, &[]).expect("create");
store.set("a/b", &Secret::new("v")).expect("set");
store.copy("a/b", "a/c").expect("copy");
assert!(store.exists("a/b") && store.exists("a/c"));
store.rename("a/b", "x/y").expect("rename");
assert!(!store.exists("a/b") && store.exists("x/y"));
assert_eq!(store.get("x/y", &id).expect("get").password(), "v");
}
#[test]
fn grep_paths_then_values() {
let (cfg, id) = fresh();
let store = Store::create(cfg, &id, &[]).expect("create");
store.set("github/token", &Secret::new("ghp")).expect("s1");
store
.set("aws/key", &Secret::new("secret\nregion: eu-west-1\n"))
.expect("s2");
assert_eq!(
store.grep("github", None).expect("grep"),
vec!["github/token"]
);
assert!(store.grep("eu-west", None).expect("grep").is_empty());
assert_eq!(
store.grep("eu-west", Some(&id)).expect("grep values"),
vec!["aws/key"]
);
}
#[test]
fn set_recipients_reencrypts_and_guards_lockout() {
let (cfg, id) = fresh();
let mut store = Store::create(cfg, &id, &[]).expect("create");
store.set("k", &Secret::new("v")).expect("set");
let backup = x25519::Identity::generate();
let n = store
.set_recipients(vec![id.to_public(), backup.to_public()], &id)
.expect("reencrypt");
assert_eq!(n, 1);
assert_eq!(store.get("k", &id).expect("get").password(), "v");
let stranger = x25519::Identity::generate();
assert!(matches!(
store.set_recipients(vec![stranger.to_public()], &id),
Err(Error::InvalidRecipient(_))
));
}
}