Implement read-only vault domains (#6)

This commit is contained in:
Hermes Agent
2026-08-09 22:22:21 +00:00
parent 34ce731716
commit dca07b7fbd
9 changed files with 1039 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ cap-tempfile.workspace = true
clap.workspace = true
pgp.workspace = true
rand.workspace = true
regex.workspace = true
serde.workspace = true
shlex.workspace = true
toml.workspace = true

View File

@@ -8,6 +8,7 @@
pub mod command;
pub mod config;
pub mod crypto;
pub mod read;
pub mod recipient;
pub mod repository;

691
crates/storage/src/read.rs Normal file
View File

@@ -0,0 +1,691 @@
//! Typed read-only password-store operations.
use std::{collections::BTreeMap, error::Error, fmt, path::Path};
use regex::bytes::{Regex, RegexBuilder};
use crate::{
command::{EXIT_FAILURE, GrepRequest, Presentation, ShowRequest},
crypto::{CryptoError, KeyStore, SecretProvider},
repository::{
DirectoryPath, EntryPath, Repository, RepositoryError, RepositorySnapshot, ResolvedObject,
SecretBytes,
},
};
const EXTENSIONS_DIRECTORY: &str = ".extensions";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TreeNodeKind {
Directory,
Entry,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeNode {
name: String,
path: String,
kind: TreeNodeKind,
children: Vec<TreeNode>,
}
impl TreeNode {
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &str {
&self.path
}
pub fn kind(&self) -> TreeNodeKind {
self.kind
}
pub fn children(&self) -> &[TreeNode] {
&self.children
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreeModel {
title: String,
root: DirectoryPath,
children: Vec<TreeNode>,
}
impl TreeModel {
pub fn title(&self) -> &str {
&self.title
}
pub fn root(&self) -> &DirectoryPath {
&self.root
}
pub fn children(&self) -> &[TreeNode] {
&self.children
}
/// Render the stable, uncolored tree used by the CLI adapter.
pub fn render_plain(&self) -> String {
let mut rendered = String::new();
rendered.push_str(&self.title);
rendered.push('\n');
render_children(&mut rendered, &self.children, "");
rendered
}
}
pub enum ShowResult {
Entry(SecretBytes),
Directory(TreeModel),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PresentationChannel {
Clipboard,
QrCode,
}
pub struct PresentationSecret {
entry: EntryPath,
line: usize,
channel: PresentationChannel,
contents: SecretBytes,
}
impl PresentationSecret {
pub fn entry(&self) -> &EntryPath {
&self.entry
}
pub fn line(&self) -> usize {
self.line
}
pub fn channel(&self) -> PresentationChannel {
self.channel
}
pub fn contents(&self) -> &SecretBytes {
&self.contents
}
}
impl fmt::Debug for PresentationSecret {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("PresentationSecret")
.field("entry", &self.entry)
.field("line", &self.line)
.field("channel", &self.channel)
.field("contents", &self.contents)
.finish()
}
}
pub enum ShowOutput {
Display(ShowResult),
Present(PresentationSecret),
}
impl fmt::Debug for ShowOutput {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Display(result) => formatter.debug_tuple("Display").field(result).finish(),
Self::Present(secret) => formatter.debug_tuple("Present").field(secret).finish(),
}
}
}
impl fmt::Debug for ShowResult {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Entry(secret) => formatter.debug_tuple("Entry").field(secret).finish(),
Self::Directory(tree) => formatter.debug_tuple("Directory").field(tree).finish(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NameMatch {
path: String,
kind: TreeNodeKind,
}
impl NameMatch {
pub fn path(&self) -> &str {
&self.path
}
pub fn kind(&self) -> TreeNodeKind {
self.kind
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FindResults {
terms: Vec<String>,
matches: Vec<NameMatch>,
tree: TreeModel,
}
impl FindResults {
pub fn terms(&self) -> &[String] {
&self.terms
}
pub fn matches(&self) -> &[NameMatch] {
&self.matches
}
pub fn tree(&self) -> &TreeModel {
&self.tree
}
pub fn render_plain(&self) -> String {
format!(
"Search Terms: {}\n{}",
self.terms.join(","),
self.tree.render_plain()
)
}
}
pub struct GrepLine {
number: usize,
contents: SecretBytes,
}
impl GrepLine {
pub fn number(&self) -> usize {
self.number
}
pub fn contents(&self) -> &SecretBytes {
&self.contents
}
}
impl fmt::Debug for GrepLine {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GrepLine")
.field("number", &self.number)
.field("contents", &self.contents)
.finish()
}
}
pub struct GrepEntry {
path: EntryPath,
lines: Vec<GrepLine>,
}
impl GrepEntry {
pub fn path(&self) -> &EntryPath {
&self.path
}
pub fn lines(&self) -> &[GrepLine] {
&self.lines
}
}
impl fmt::Debug for GrepEntry {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GrepEntry")
.field("path", &self.path)
.field("line_count", &self.lines.len())
.finish()
}
}
#[derive(Default)]
pub struct GrepResults {
entries: Vec<GrepEntry>,
line_numbers: bool,
}
impl GrepResults {
pub fn entries(&self) -> &[GrepEntry] {
&self.entries
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Render matched bytes without requiring the CLI to reconstruct entry semantics.
pub fn render_plain(&self) -> SecretBytes {
let mut rendered = Vec::new();
for entry in &self.entries {
rendered.extend_from_slice(entry.path.to_string().as_bytes());
rendered.extend_from_slice(b":\n");
for line in &entry.lines {
if self.line_numbers {
rendered.extend_from_slice(line.number.to_string().as_bytes());
rendered.push(b':');
}
rendered.extend_from_slice(line.contents.expose());
rendered.push(b'\n');
}
}
SecretBytes::new(rendered)
}
}
impl fmt::Debug for GrepResults {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GrepResults")
.field("entry_count", &self.entries.len())
.finish()
}
}
pub struct VaultReader<'a> {
repository: &'a Repository,
keys: &'a KeyStore,
}
impl<'a> VaultReader<'a> {
pub fn new(repository: &'a Repository, keys: &'a KeyStore) -> Self {
Self { repository, keys }
}
pub fn list(&self, directory: &DirectoryPath) -> Result<TreeModel, ReadError> {
let snapshot = self.repository.snapshot()?;
build_tree(&snapshot, directory, None)
}
/// Implement explicit or implicit show dispatch. No path means the root tree; a directory
/// produces a tree and an entry is decrypted in full.
pub fn show(
&self,
input: Option<&str>,
provider: &mut impl SecretProvider,
) -> Result<ShowResult, ReadError> {
let snapshot = self.repository.snapshot()?;
let Some(input) = input else {
return Ok(ShowResult::Directory(build_tree(
&snapshot,
&DirectoryPath::root(),
None,
)?));
};
match snapshot.resolve(input)? {
ResolvedObject::Entry(entry) => {
let ciphertext = self.repository.read_entry(entry.path())?;
Ok(ShowResult::Entry(self.keys.decrypt(&ciphertext, provider)?))
}
ResolvedObject::Directory(directory) => Ok(ShowResult::Directory(build_tree(
&snapshot,
directory.path(),
None,
)?)),
}
}
/// Execute the typed show request without inspecting a rendered command or display string.
pub fn execute_show(
&self,
request: &ShowRequest,
provider: &mut impl SecretProvider,
) -> Result<ShowOutput, ReadError> {
let Some(input) = request.entry.as_deref() else {
return self.show(None, provider).map(ShowOutput::Display);
};
let snapshot = self.repository.snapshot()?;
match snapshot.resolve(input)? {
ResolvedObject::Directory(_) => {
self.show(Some(input), provider).map(ShowOutput::Display)
}
ResolvedObject::Entry(entry) => match request.presentation {
Presentation::Terminal => self.show(Some(input), provider).map(ShowOutput::Display),
Presentation::Clipboard { line } => Ok(ShowOutput::Present(PresentationSecret {
entry: entry.path().clone(),
line: line.get(),
channel: PresentationChannel::Clipboard,
contents: self.select_line(entry.path(), line.get(), provider)?,
})),
Presentation::QrCode { line } => Ok(ShowOutput::Present(PresentationSecret {
entry: entry.path().clone(),
line: line.get(),
channel: PresentationChannel::QrCode,
contents: self.select_line(entry.path(), line.get(), provider)?,
})),
},
}
}
pub fn select_line(
&self,
entry: &EntryPath,
line: usize,
provider: &mut impl SecretProvider,
) -> Result<SecretBytes, ReadError> {
if line == 0 {
return Err(ReadError::LineNotFound {
entry: entry.clone(),
line,
});
}
let ciphertext = self.repository.read_entry(entry)?;
let plaintext = self.keys.decrypt(&ciphertext, provider)?;
let selected = plaintext
.expose()
.split(|byte| *byte == b'\n')
.nth(line - 1)
.filter(|selected| !selected.is_empty())
.ok_or_else(|| ReadError::LineNotFound {
entry: entry.clone(),
line,
})?;
Ok(SecretBytes::new(selected.to_vec()))
}
pub fn find(&self, terms: &[String]) -> Result<FindResults, ReadError> {
if terms.is_empty() {
return Err(ReadError::MissingSearchTerms);
}
let folded = terms
.iter()
.map(|term| term.to_lowercase())
.collect::<Vec<_>>();
let snapshot = self.repository.snapshot()?;
let mut matches = Vec::new();
for directory in snapshot.directories() {
if directory.path().as_path().as_os_str().is_empty()
|| hidden_path(directory.path().as_path())
{
continue;
}
let name = display_name(directory.path().as_path())?;
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
matches.push(NameMatch {
path: path_text(directory.path().as_path())?,
kind: TreeNodeKind::Directory,
});
}
}
for entry in snapshot.entries() {
if hidden_path(entry.path().as_path()) {
continue;
}
let name = display_name(entry.path().as_path())?;
if folded.iter().any(|term| name.to_lowercase().contains(term)) {
matches.push(NameMatch {
path: path_text(entry.path().as_path())?,
kind: TreeNodeKind::Entry,
});
}
}
matches.sort_by(|left, right| left.path.cmp(&right.path));
let included = matches
.iter()
.map(|matched| matched.path.as_str())
.collect::<Vec<_>>();
let tree = build_tree(&snapshot, &DirectoryPath::root(), Some(included.as_slice()))?;
Ok(FindResults {
terms: terms.to_vec(),
matches,
tree,
})
}
pub fn grep(
&self,
request: &GrepRequest,
provider: &mut impl SecretProvider,
) -> Result<GrepResults, ReadError> {
let regex = build_regex(request)?;
let snapshot = self.repository.snapshot()?;
let mut results = Vec::new();
for record in snapshot.entries() {
if hidden_path(record.path().as_path()) {
continue;
}
let ciphertext = self.repository.read_entry(record.path())?;
let plaintext = self.keys.decrypt(&ciphertext, provider)?;
let mut lines = Vec::new();
let mut plaintext_lines = plaintext
.expose()
.split(|byte| *byte == b'\n')
.collect::<Vec<_>>();
if plaintext.expose().ends_with(b"\n") {
plaintext_lines.pop();
}
for (index, line) in plaintext_lines.into_iter().enumerate() {
let matched = regex.is_match(line);
if matched != request.invert_match {
lines.push(GrepLine {
number: index + 1,
contents: SecretBytes::new(line.to_vec()),
});
}
}
if !lines.is_empty() {
results.push(GrepEntry {
path: record.path().clone(),
lines,
});
}
}
Ok(GrepResults {
entries: results,
line_numbers: request.line_number,
})
}
}
fn build_regex(request: &GrepRequest) -> Result<Regex, ReadError> {
let pattern = if request.fixed_strings {
regex::escape(&request.pattern)
} else {
request.pattern.clone()
};
RegexBuilder::new(&pattern)
.case_insensitive(request.ignore_case)
.build()
.map_err(|_| ReadError::InvalidRegex)
}
#[derive(Default)]
struct MutableNode {
directory: bool,
children: BTreeMap<String, MutableNode>,
}
fn build_tree(
snapshot: &RepositorySnapshot,
root: &DirectoryPath,
included: Option<&[&str]>,
) -> Result<TreeModel, ReadError> {
if snapshot
.directories()
.all(|directory| directory.path() != root)
{
return Err(ReadError::Repository(RepositoryError::NotFound {
path: root.as_path().to_owned(),
}));
}
let mut mutable = MutableNode {
directory: true,
children: BTreeMap::new(),
};
for directory in snapshot.directories() {
let path = directory.path().as_path();
if path == root.as_path() || !path.starts_with(root.as_path()) || hidden_path(path) {
continue;
}
if included.is_some_and(|included| !is_included_path(path, included, true)) {
continue;
}
insert_path(
&mut mutable,
path.strip_prefix(root.as_path()).expect("prefix"),
true,
)?;
}
for entry in snapshot.entries() {
let path = entry.path().as_path();
if !path.starts_with(root.as_path()) || hidden_path(path) {
continue;
}
if included.is_some_and(|included| !is_included_path(path, included, false)) {
continue;
}
insert_path(
&mut mutable,
path.strip_prefix(root.as_path()).expect("prefix"),
false,
)?;
}
let title = if root.as_path().as_os_str().is_empty() {
"Password Store".to_owned()
} else {
path_text(root.as_path())?
};
Ok(TreeModel {
title,
root: root.clone(),
children: finalize_children(mutable, root.as_path())?,
})
}
fn insert_path(root: &mut MutableNode, path: &Path, directory: bool) -> Result<(), ReadError> {
let components = path
.components()
.map(|component| {
component
.as_os_str()
.to_str()
.map(str::to_owned)
.ok_or(ReadError::NonUtf8Path)
})
.collect::<Result<Vec<_>, _>>()?;
let mut node = root;
for (index, component) in components.iter().enumerate() {
node = node.children.entry(component.clone()).or_default();
if index + 1 < components.len() || directory {
node.directory = true;
}
}
Ok(())
}
fn finalize_children(node: MutableNode, parent: &Path) -> Result<Vec<TreeNode>, ReadError> {
node.children
.into_iter()
.map(|(name, child)| {
let path = parent.join(&name);
let kind = if child.directory {
TreeNodeKind::Directory
} else {
TreeNodeKind::Entry
};
let children = finalize_children(child, &path)?;
Ok(TreeNode {
name,
path: path_text(&path)?,
kind,
children,
})
})
.collect()
}
fn render_children(rendered: &mut String, children: &[TreeNode], prefix: &str) {
for (index, child) in children.iter().enumerate() {
let last = index + 1 == children.len();
rendered.push_str(prefix);
rendered.push_str(if last { "└── " } else { "├── " });
rendered.push_str(child.name());
rendered.push('\n');
let continuation = format!("{prefix}{}", if last { " " } else { "" });
render_children(rendered, child.children(), &continuation);
}
}
fn is_included_path(path: &Path, included: &[&str], directory: bool) -> bool {
let text = path.to_string_lossy();
included.iter().any(|candidate| {
let candidate = Path::new(candidate);
candidate == path
|| (directory && candidate.starts_with(path))
|| path.starts_with(candidate)
}) || included.iter().any(|candidate| *candidate == text)
}
fn hidden_path(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == EXTENSIONS_DIRECTORY)
}
fn display_name(path: &Path) -> Result<&str, ReadError> {
path.file_name()
.and_then(|name| name.to_str())
.ok_or(ReadError::NonUtf8Path)
}
fn path_text(path: &Path) -> Result<String, ReadError> {
path.to_str()
.map(str::to_owned)
.ok_or(ReadError::NonUtf8Path)
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ReadError {
Repository(RepositoryError),
Crypto(CryptoError),
MissingSearchTerms,
InvalidRegex,
NonUtf8Path,
LineNotFound { entry: EntryPath, line: usize },
}
impl fmt::Display for ReadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Repository(error) => error.fmt(formatter),
Self::Crypto(error) => error.fmt(formatter),
Self::MissingSearchTerms => formatter.write_str("at least one search term is required"),
Self::InvalidRegex => formatter.write_str("decrypted grep pattern is invalid"),
Self::NonUtf8Path => formatter.write_str("password-store path is not valid UTF-8"),
Self::LineNotFound { entry, line } => {
write!(formatter, "entry {entry} has no nonempty line {line}")
}
}
}
}
impl Error for ReadError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Repository(error) => Some(error),
Self::Crypto(error) => Some(error),
_ => None,
}
}
}
impl From<RepositoryError> for ReadError {
fn from(error: RepositoryError) -> Self {
Self::Repository(error)
}
}
impl From<CryptoError> for ReadError {
fn from(error: CryptoError) -> Self {
Self::Crypto(error)
}
}
impl ReadError {
pub fn exit_code(&self) -> u8 {
EXIT_FAILURE
}
}

View File

@@ -0,0 +1,298 @@
#![forbid(unsafe_code)]
mod support;
use std::{collections::BTreeMap, fs, num::NonZeroUsize, path::Path};
use ironstorage::{
command::{EXIT_FAILURE, GrepRequest, Presentation, ShowRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
read::{PresentationChannel, ReadError, ShowOutput, ShowResult, TreeNodeKind, VaultReader},
repository::{DirectoryPath, EntryPath, Repository, RepositoryError, SecretBytes},
};
use support::compatibility::{FixtureSet, TestResult};
struct FixtureSecrets {
values: BTreeMap<String, Vec<u8>>,
}
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self {
values: fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
}
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
self.values
.get(key.fingerprint().as_str())
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
#[test]
fn deterministic_root_and_subtree_models_render_without_ciphertext_suffixes() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let root = reader.list(&DirectoryPath::root())?;
assert_eq!(root.title(), "Password Store");
assert_eq!(
root.render_plain(),
concat!(
"Password Store\n",
"├── email\n",
"│ └── personal\n",
"├── otp\n",
"│ ├── hotp\n",
"│ └── totp\n",
"├── shared\n",
"│ └── multiple\n",
"├── team\n",
"│ └── service\n",
"└── unicode\n",
" └── 咖啡\n",
)
);
assert!(!root.render_plain().contains(".gpg"));
assert!(!root.render_plain().contains(".gpg-id"));
let team = reader.list(&DirectoryPath::parse("team")?)?;
assert_eq!(team.title(), "team");
assert_eq!(team.render_plain(), "team\n└── service\n");
assert!(matches!(
reader.list(&DirectoryPath::parse("missing")?),
Err(ReadError::Repository(RepositoryError::NotFound { .. }))
));
Ok(())
}
#[test]
fn explicit_and_implicit_show_dispatch_to_entry_or_directory() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let expected = fixture.read("expected/basic/email/personal.txt")?;
match reader.show(Some("email/personal"), &mut provider)? {
ShowResult::Entry(secret) => assert_eq!(secret.expose(), expected),
ShowResult::Directory(_) => panic!("entry dispatched as directory"),
}
match reader.show(Some("team"), &mut provider)? {
ShowResult::Directory(tree) => assert_eq!(tree.render_plain(), "team\n└── service\n"),
ShowResult::Entry(_) => panic!("directory dispatched as entry"),
}
match reader.show(None, &mut provider)? {
ShowResult::Directory(tree) => assert_eq!(tree.title(), "Password Store"),
ShowResult::Entry(_) => panic!("default show did not list root"),
}
assert!(matches!(
reader.show(Some("does-not-exist"), &mut provider),
Err(ReadError::Repository(RepositoryError::NotFound { .. }))
));
Ok(())
}
#[test]
fn typed_clipboard_and_qr_requests_select_lines_without_rendered_state() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let clipboard = ShowRequest {
entry: Some("email/personal".to_owned()),
presentation: Presentation::Clipboard {
line: NonZeroUsize::new(1).expect("nonzero"),
},
};
let ShowOutput::Present(clipboard) = reader.execute_show(&clipboard, &mut provider)? else {
panic!("clipboard request was not selected")
};
assert_eq!(clipboard.channel(), PresentationChannel::Clipboard);
assert_eq!(clipboard.line(), 1);
assert_eq!(clipboard.contents().expose(), b"correct horse fixture");
assert!(!format!("{clipboard:?}").contains("correct horse"));
let qr = ShowRequest {
entry: Some("email/personal".to_owned()),
presentation: Presentation::QrCode {
line: NonZeroUsize::new(2).expect("nonzero"),
},
};
let ShowOutput::Present(qr) = reader.execute_show(&qr, &mut provider)? else {
panic!("QR request was not selected")
};
assert_eq!(qr.channel(), PresentationChannel::QrCode);
assert_eq!(qr.contents().expose(), b"login: alice@example.test");
let missing = reader.select_line(&EntryPath::parse("email/personal")?, 99, &mut provider);
assert!(matches!(
&missing,
Err(ReadError::LineNotFound { line: 99, .. })
));
assert_eq!(missing.unwrap_err().exit_code(), EXIT_FAILURE);
Ok(())
}
#[test]
fn find_matches_entry_and_directory_names_case_insensitively() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let results = reader.find(&["service".to_owned(), "咖啡".to_owned()])?;
assert_eq!(
results
.matches()
.iter()
.map(|matched| (matched.path(), matched.kind()))
.collect::<Vec<_>>(),
vec![
("team/service", TreeNodeKind::Entry),
("unicode/咖啡", TreeNodeKind::Entry),
]
);
assert_eq!(
results.tree().render_plain(),
concat!(
"Password Store\n",
"├── team\n",
"│ └── service\n",
"└── unicode\n",
" └── 咖啡\n",
)
);
assert!(
results
.render_plain()
.starts_with("Search Terms: service,咖啡\nPassword Store\n")
);
let personal = reader.find(&["PERSONAL".to_owned()])?;
assert_eq!(personal.matches()[0].path(), "email/personal");
assert!(matches!(
reader.find(&[]),
Err(ReadError::MissingSearchTerms)
));
Ok(())
}
#[test]
fn decrypted_grep_supports_regex_case_inversion_fixed_strings_and_lines() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let mut numbered = grep("alice@example\\.test");
numbered.line_number = true;
let basic = reader.grep(&numbered, &mut provider)?;
assert_eq!(basic.entries().len(), 1);
assert_eq!(basic.entries()[0].path().to_string(), "email/personal");
assert_eq!(basic.entries()[0].lines()[0].number(), 2);
assert_eq!(
basic.entries()[0].lines()[0].contents().expose(),
b"login: alice@example.test"
);
assert!(!format!("{basic:?}").contains("alice@example"));
assert_eq!(
basic.render_plain().expose(),
b"email/personal:\n2:login: alice@example.test\n"
);
let mut insensitive = grep("FIXTURE");
insensitive.ignore_case = true;
assert!(reader.grep(&insensitive, &mut provider)?.entries().len() >= 4);
let mut fixed = grep("alice@example.test");
fixed.fixed_strings = true;
assert_eq!(reader.grep(&fixed, &mut provider)?.entries().len(), 1);
let mut inverted = grep("fixture");
inverted.invert_match = true;
let inverted = reader.grep(&inverted, &mut provider)?;
assert!(inverted.entries().iter().any(|entry| {
entry.path().to_string() == "email/personal"
&& entry
.lines()
.iter()
.any(|line| line.contents().expose() == b"login: alice@example.test")
}));
let invalid = grep("[");
assert!(matches!(
reader.grep(&invalid, &mut provider),
Err(ReadError::InvalidRegex)
));
Ok(())
}
#[test]
fn search_excludes_extensions_git_metadata_and_ciphertext_names() -> TestResult {
let fixture = FixtureSet::load()?;
let store = fixture.materialize_store("basic")?;
fs::create_dir_all(store.path().join(".extensions/nested"))?;
fs::copy(
store.path().join("email/personal.gpg"),
store.path().join(".extensions/nested/leaked-secret.gpg"),
)?;
fs::create_dir(store.path().join(".git"))?;
fs::write(store.path().join(".git/secret.gpg"), b"not a packet")?;
fs::write(store.path().join("visible-metadata.txt"), b"fixture")?;
let repository = Repository::open(store.path())?;
let keys = KeyStore::load(fixture.path("keys"))?;
let reader = VaultReader::new(&repository, &keys);
let mut provider = FixtureSecrets::all(&fixture);
let tree = reader.list(&DirectoryPath::root())?.render_plain();
assert!(!tree.contains("extensions"));
assert!(!tree.contains(".git"));
assert!(!tree.contains("metadata"));
assert!(!tree.contains(".gpg"));
assert!(reader.find(&["leaked".to_owned()])?.matches().is_empty());
assert!(reader.find(&["metadata".to_owned()])?.matches().is_empty());
assert!(
reader
.grep(&grep("correct horse"), &mut provider)?
.entries()
.iter()
.all(|entry| !entry.path().as_path().starts_with(Path::new(".extensions")))
);
Ok(())
}
fn grep(pattern: &str) -> GrepRequest {
GrepRequest {
pattern: pattern.to_owned(),
ignore_case: false,
invert_match: false,
line_number: false,
fixed_strings: false,
}
}