Files
IronStorage/crates/storage/src/read.rs

787 lines
22 KiB
Rust

//! 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<TreeNode>,
}
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<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 {
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<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()
}
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<TreeModel, ReadError> {
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<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 {
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::<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,
})
}
}
/// Build a locked navigation model without loading key material or decrypting entries.
pub(crate) fn list_tree(
repository: &Repository,
directory: &DirectoryPath,
) -> Result<TreeModel, ReadError> {
let snapshot = repository.snapshot()?;
build_tree(&snapshot, directory, None)
}
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,
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<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
}
}