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

1
Cargo.lock generated
View File

@@ -2718,6 +2718,7 @@ dependencies = [
"pgp", "pgp",
"rand 0.8.7", "rand 0.8.7",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"regex",
"rustix 1.1.4", "rustix 1.1.4",
"serde", "serde",
"sha1", "sha1",

View File

@@ -22,6 +22,7 @@ iced = "0.14"
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
pgp = { version = "0.20", default-features = false } pgp = { version = "0.20", default-features = false }
rand = "0.8" rand = "0.8"
regex = "1.13"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
shlex = "1.3" shlex = "1.3"

View File

@@ -20,6 +20,7 @@ The current direct dependencies are:
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 | | [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
| [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 | | [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 |
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |

View File

@@ -34,6 +34,8 @@ GnuPG compatibility evidence are documented in
Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and
the rollback/commit contract are documented in the rollback/commit contract are documented in
[`docs/recipient-policies.md`](docs/recipient-policies.md). [`docs/recipient-policies.md`](docs/recipient-policies.md).
Typed list/show/find/decrypted-grep models and secret presentation selection are
documented in [`docs/read-domains.md`](docs/read-domains.md).
## Project layout ## Project layout

View File

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

View File

@@ -8,6 +8,7 @@
pub mod command; pub mod command;
pub mod config; pub mod config;
pub mod crypto; pub mod crypto;
pub mod read;
pub mod recipient; pub mod recipient;
pub mod repository; 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,
}
}

43
docs/read-domains.md Normal file
View File

@@ -0,0 +1,43 @@
# Read-only password-store domains
`VaultReader` in `crates/storage` owns the semantics for `list`/`ls`, explicit
and implicit `show`, `find`/`search`, and decrypted `grep`. Frontends receive
typed models and render storage-provided paths; they do not inspect tree text,
ciphertext names, or decrypted display strings to infer domain state.
## Trees and dispatch
Tree models contain separate entry and directory node kinds, logical paths,
names, and deterministic children. Entry names never contain the `.gpg` storage
suffix. Policy files, signatures, other metadata, `.git`, and `.extensions`
trees are not nodes. The plain renderer produces the stable uncolored tree used
by command-line output.
No show path selects the root tree. A path is resolved against the repository
snapshot: an entry decrypts to redacted `SecretBytes`, while a directory returns
its subtree. Missing and ambiguous paths remain typed repository errors and map
to the normal `pass` failure status.
Clipboard and QR requests use the parsed `Presentation` enum and a nonzero line
number. Storage selects that line directly from decrypted bytes and returns a
`PresentationSecret` containing the logical entry, line, channel, and redacted
zeroizing contents. The adapter never parses rendered terminal output, and a
missing or empty requested line is an explicit failure.
## Name and plaintext search
Find performs Unicode case-insensitive substring matching against logical entry
and directory names. Multiple terms are alternatives, as in upstream `pass`.
Results include typed matches plus a pruned deterministic tree with the
necessary ancestors. Hidden implementation directories, metadata, and
ciphertext suffixes cannot become matches.
Decrypted grep uses the Rust `regex` byte engine, which provides linear-time
matching without invoking GNU grep. The accepted command contract is limited to
case-insensitive, inverted, line-number, and fixed-string behavior; unsupported
GNU options fail during command parsing. Entries are visited in repository
order, each plaintext is decrypted only while its lines are examined, and the
full plaintext buffer is zeroed when that iteration ends. Matched lines are
copied into redacted `SecretBytes`; rendered grep output is also returned as
`SecretBytes`, and both are zeroed with their result values. Debug output
includes paths and counts but never matched contents.