//! 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, Ord, PartialEq, PartialOrd)] pub enum TreeNodeKind { Directory, Entry, } /// Stable storage-owned identity for a navigation tree object. /// /// Frontends retain this value across refreshed [`TreeModel`] instances /// instead of reconstructing identity from rendered labels. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum TreeNodeId { Directory(DirectoryPath), Entry(EntryPath), } impl TreeNodeId { pub fn path(&self) -> &Path { match self { Self::Directory(path) => path.as_path(), Self::Entry(path) => path.as_path(), } } pub fn kind(&self) -> TreeNodeKind { match self { Self::Directory(_) => TreeNodeKind::Directory, Self::Entry(_) => TreeNodeKind::Entry, } } pub fn is_directory(&self) -> bool { matches!(self, Self::Directory(_)) } pub fn entry(&self) -> Option<&EntryPath> { match self { Self::Entry(path) => Some(path), Self::Directory(_) => None, } } } /// Presentation-safe state calculated by storage services for a tree object. /// Frontends render these flags and never infer them from names or paths. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TreeNodeIndicators { locked: bool, changed: bool, conflict: bool, } impl TreeNodeIndicators { pub const fn new(locked: bool, changed: bool, conflict: bool) -> Self { Self { locked, changed, conflict, } } pub const fn is_locked(self) -> bool { self.locked } pub const fn is_changed(self) -> bool { self.changed } pub const fn has_conflict(self) -> bool { self.conflict } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct TreeNode { name: String, id: TreeNodeId, indicators: TreeNodeIndicators, children: Vec, } impl TreeNode { pub fn name(&self) -> &str { &self.name } pub fn path(&self) -> &str { self.id .path() .to_str() .expect("tree construction rejects non-UTF-8 paths") } pub fn kind(&self) -> TreeNodeKind { self.id.kind() } pub fn id(&self) -> &TreeNodeId { &self.id } pub fn indicators(&self) -> TreeNodeIndicators { self.indicators } 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 { id: TreeNodeId, } impl NameMatch { pub fn path(&self) -> &str { self.id .path() .to_str() .expect("search construction rejects non-UTF-8 paths") } pub fn kind(&self) -> TreeNodeKind { self.id.kind() } pub fn id(&self) -> &TreeNodeId { &self.id } } #[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() } pub fn includes_line_numbers(&self) -> bool { self.line_numbers } /// 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 { list_tree(self.repository, directory) } /// 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 { id: TreeNodeId::Directory(directory.path().clone()), }); } } 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 { id: TreeNodeId::Entry(entry.path().clone()), }); } } matches.sort_by(|left, right| left.path().cmp(right.path())); let included = matches.iter().map(NameMatch::path).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, }) } } /// Build a locked navigation model without loading key material or decrypting entries. pub(crate) fn list_tree( repository: &Repository, directory: &DirectoryPath, ) -> Result { let snapshot = repository.snapshot()?; build_tree(&snapshot, directory, None) } 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, id: match kind { TreeNodeKind::Directory => TreeNodeId::Directory(DirectoryPath::parse(&path)?), TreeNodeKind::Entry => TreeNodeId::Entry(EntryPath::parse(&path)?), }, indicators: TreeNodeIndicators::default(), 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) } pub(crate) 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 } }