diff --git a/Cargo.lock b/Cargo.lock index d473ad3..8a12235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2718,6 +2718,7 @@ dependencies = [ "pgp", "rand 0.8.7", "rand_chacha 0.3.1", + "regex", "rustix 1.1.4", "serde", "sha1", diff --git a/Cargo.toml b/Cargo.toml index db42f06..2b6bf23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ iced = "0.14" ironstorage = { path = "crates/storage" } pgp = { version = "0.20", default-features = false } rand = "0.8" +regex = "1.13" ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } serde = { version = "1", features = ["derive"] } shlex = "1.3" diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index a00f1b1..b2c9a7b 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -20,6 +20,7 @@ The current direct dependencies are: | [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 | | [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 | | [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 | diff --git a/README.md b/README.md index aeeda85..60d3ffa 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ GnuPG compatibility evidence are documented in Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and the rollback/commit contract are documented in [`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 diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 91ce2b4..933e126 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -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 diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 223f2be..2142086 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,6 +8,7 @@ pub mod command; pub mod config; pub mod crypto; +pub mod read; pub mod recipient; pub mod repository; diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs new file mode 100644 index 0000000..098819e --- /dev/null +++ b/crates/storage/src/read.rs @@ -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, +} + +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, +} + +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, + matches: Vec, + 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, +} + +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, + 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 { + 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 { + 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 { + 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 { + 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 { + if terms.is_empty() { + return Err(ReadError::MissingSearchTerms); + } + let folded = terms + .iter() + .map(|term| term.to_lowercase()) + .collect::>(); + 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::>(); + 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 { + 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::>(); + 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 { + 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, +} + +fn build_tree( + snapshot: &RepositorySnapshot, + root: &DirectoryPath, + included: Option<&[&str]>, +) -> Result { + 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::, _>>()?; + 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, 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 { + 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 for ReadError { + fn from(error: RepositoryError) -> Self { + Self::Repository(error) + } +} + +impl From for ReadError { + fn from(error: CryptoError) -> Self { + Self::Crypto(error) + } +} + +impl ReadError { + pub fn exit_code(&self) -> u8 { + EXIT_FAILURE + } +} diff --git a/crates/storage/tests/read_domains.rs b/crates/storage/tests/read_domains.rs new file mode 100644 index 0000000..76c72a8 --- /dev/null +++ b/crates/storage/tests/read_domains.rs @@ -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>, +} + +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 { + 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![ + ("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, + } +} diff --git a/docs/read-domains.md b/docs/read-domains.md new file mode 100644 index 0000000..14bb991 --- /dev/null +++ b/docs/read-domains.md @@ -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.