use crate::store::{Secret, Store, new_id, now};
use age::x25519;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
pub fn store_dir() -> PathBuf {
if let Some(set) = std::env::var_os("PASSWORD_STORE_DIR") {
return PathBuf::from(set);
}
std::env::var_os("HOME")
.map(|h| PathBuf::from(h).join(".password-store"))
.unwrap_or_default()
}
pub fn entries(dir: &Path) -> Result<Vec<String>> {
let mut found = Vec::new();
walk(dir, dir, &mut found)?;
found.sort();
Ok(found)
}
fn walk(root: &Path, at: &Path, out: &mut Vec<String>) -> Result<()> {
if !at.exists() {
return Ok(());
}
for entry in std::fs::read_dir(at)? {
let path = entry?.path();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if name.starts_with('.') {
continue;
}
if path.is_dir() {
walk(root, &path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("gpg")
&& let Ok(relative) = path.strip_prefix(root)
{
out.push(relative.with_extension("").to_string_lossy().to_string());
}
}
Ok(())
}
pub use crate::store::select;
fn show(entry: &str) -> Result<String> {
let out = std::process::Command::new("pass")
.arg("show")
.arg(entry)
.output()
.context("pass is not on PATH")?;
if !out.status.success() {
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
}
let body = String::from_utf8(out.stdout).context("entry is not text")?;
Ok(body.trim_end_matches('\n').to_string())
}
pub struct Report {
pub imported: usize,
pub skipped: usize,
}
pub fn import(store: &Store, names: &[String], key: Option<&x25519::Identity>) -> Result<Report> {
let recipient = store.recipient()?;
let mut report = Report {
imported: 0,
skipped: 0,
};
for name in names {
let existing = match key {
Some(key) => store.find(name, key)?,
None => None,
};
if existing.is_some() {
eprintln!("skipping {name}, already in the store");
report.skipped += 1;
continue;
}
let mut value = show(name)?;
let secret = Secret {
name: name.clone(),
value: value.clone(),
created: now(),
updated: now(),
};
value.zeroize();
store.put(&new_id(), &secret, &recipient)?;
crate::ssh::remember(store, &secret.value)?;
report.imported += 1;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, b"x").unwrap();
}
#[test]
fn entry_names_mirror_the_pass_layout() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
touch(&root.join("api-key.gpg"));
touch(&root.join("acme/signing-key.gpg"));
touch(&root.join("r2/storage/access-key-id.gpg"));
let found = entries(root).unwrap();
assert_eq!(
found,
vec!["acme/signing-key", "api-key", "r2/storage/access-key-id"]
);
}
#[test]
fn housekeeping_files_are_not_entries() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
touch(&root.join("real.gpg"));
touch(&root.join(".gpg-id"));
touch(&root.join(".git/objects/ab/cdef.gpg"));
touch(&root.join("README.md"));
assert_eq!(entries(root).unwrap(), vec!["real"]);
}
fn names() -> Vec<String> {
[
"acme/signing-key",
"acme/api-key",
"acmecorp/other",
"r2/storage/id",
"top",
]
.iter()
.map(|s| s.to_string())
.collect()
}
#[test]
fn no_prefix_takes_everything() {
assert_eq!(select(&names(), None).len(), 5);
}
#[test]
fn a_namespace_does_not_match_a_longer_name() {
let picked = select(&names(), Some("acme"));
assert_eq!(picked, vec!["acme/signing-key", "acme/api-key"]);
}
#[test]
fn a_full_entry_name_takes_just_that_one() {
assert_eq!(select(&names(), Some("top")), vec!["top"]);
assert_eq!(
select(&names(), Some("r2/storage/id")),
vec!["r2/storage/id"]
);
}
#[test]
fn a_trailing_slash_is_tolerated() {
assert_eq!(select(&names(), Some("acme/")).len(), 2);
}
#[test]
fn an_unknown_prefix_selects_nothing() {
assert!(select(&names(), Some("nope")).is_empty());
}
#[test]
fn an_empty_store_is_not_an_error() {
let tmp = tempfile::tempdir().unwrap();
assert!(entries(tmp.path()).unwrap().is_empty());
}
}