Implement read-only vault domains (#6)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod read;
|
||||
pub mod recipient;
|
||||
pub mod repository;
|
||||
|
||||
|
||||
691
crates/storage/src/read.rs
Normal file
691
crates/storage/src/read.rs
Normal 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
|
||||
}
|
||||
}
|
||||
298
crates/storage/tests/read_domains.rs
Normal file
298
crates/storage/tests/read_domains.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user