Implement read-only vault domains (#6)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2718,6 +2718,7 @@ dependencies = [
|
|||||||
"pgp",
|
"pgp",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"rand_chacha 0.3.1",
|
"rand_chacha 0.3.1",
|
||||||
|
"regex",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ iced = "0.14"
|
|||||||
ironstorage = { path = "crates/storage" }
|
ironstorage = { path = "crates/storage" }
|
||||||
pgp = { version = "0.20", default-features = false }
|
pgp = { version = "0.20", default-features = false }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
regex = "1.13"
|
||||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
shlex = "1.3"
|
shlex = "1.3"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ The current direct dependencies are:
|
|||||||
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
|
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
|
||||||
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
|
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
|
||||||
| [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 |
|
| [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 |
|
||||||
|
| [regex 1.13](https://crates.io/crates/regex/1.13.1) | Linear-time byte-oriented decrypted grep matching | MIT OR Apache-2.0 |
|
||||||
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
|
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
|
||||||
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
|
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
|
||||||
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
|
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ GnuPG compatibility evidence are documented in
|
|||||||
Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and
|
Hierarchical `.gpg-id` resolution, signed policies, selective reencryption, and
|
||||||
the rollback/commit contract are documented in
|
the rollback/commit contract are documented in
|
||||||
[`docs/recipient-policies.md`](docs/recipient-policies.md).
|
[`docs/recipient-policies.md`](docs/recipient-policies.md).
|
||||||
|
Typed list/show/find/decrypted-grep models and secret presentation selection are
|
||||||
|
documented in [`docs/read-domains.md`](docs/read-domains.md).
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ cap-tempfile.workspace = true
|
|||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
pgp.workspace = true
|
pgp.workspace = true
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
shlex.workspace = true
|
shlex.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
|
pub mod read;
|
||||||
pub mod recipient;
|
pub mod recipient;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
|
||||||
|
|||||||
691
crates/storage/src/read.rs
Normal file
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
docs/read-domains.md
Normal file
43
docs/read-domains.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Read-only password-store domains
|
||||||
|
|
||||||
|
`VaultReader` in `crates/storage` owns the semantics for `list`/`ls`, explicit
|
||||||
|
and implicit `show`, `find`/`search`, and decrypted `grep`. Frontends receive
|
||||||
|
typed models and render storage-provided paths; they do not inspect tree text,
|
||||||
|
ciphertext names, or decrypted display strings to infer domain state.
|
||||||
|
|
||||||
|
## Trees and dispatch
|
||||||
|
|
||||||
|
Tree models contain separate entry and directory node kinds, logical paths,
|
||||||
|
names, and deterministic children. Entry names never contain the `.gpg` storage
|
||||||
|
suffix. Policy files, signatures, other metadata, `.git`, and `.extensions`
|
||||||
|
trees are not nodes. The plain renderer produces the stable uncolored tree used
|
||||||
|
by command-line output.
|
||||||
|
|
||||||
|
No show path selects the root tree. A path is resolved against the repository
|
||||||
|
snapshot: an entry decrypts to redacted `SecretBytes`, while a directory returns
|
||||||
|
its subtree. Missing and ambiguous paths remain typed repository errors and map
|
||||||
|
to the normal `pass` failure status.
|
||||||
|
|
||||||
|
Clipboard and QR requests use the parsed `Presentation` enum and a nonzero line
|
||||||
|
number. Storage selects that line directly from decrypted bytes and returns a
|
||||||
|
`PresentationSecret` containing the logical entry, line, channel, and redacted
|
||||||
|
zeroizing contents. The adapter never parses rendered terminal output, and a
|
||||||
|
missing or empty requested line is an explicit failure.
|
||||||
|
|
||||||
|
## Name and plaintext search
|
||||||
|
|
||||||
|
Find performs Unicode case-insensitive substring matching against logical entry
|
||||||
|
and directory names. Multiple terms are alternatives, as in upstream `pass`.
|
||||||
|
Results include typed matches plus a pruned deterministic tree with the
|
||||||
|
necessary ancestors. Hidden implementation directories, metadata, and
|
||||||
|
ciphertext suffixes cannot become matches.
|
||||||
|
|
||||||
|
Decrypted grep uses the Rust `regex` byte engine, which provides linear-time
|
||||||
|
matching without invoking GNU grep. The accepted command contract is limited to
|
||||||
|
case-insensitive, inverted, line-number, and fixed-string behavior; unsupported
|
||||||
|
GNU options fail during command parsing. Entries are visited in repository
|
||||||
|
order, each plaintext is decrypted only while its lines are examined, and the
|
||||||
|
full plaintext buffer is zeroed when that iteration ends. Matched lines are
|
||||||
|
copied into redacted `SecretBytes`; rendered grep output is also returned as
|
||||||
|
`SecretBytes`, and both are zeroed with their result values. Debug output
|
||||||
|
includes paths and counts but never matched contents.
|
||||||
Reference in New Issue
Block a user