#![forbid(unsafe_code)] mod support; use std::{collections::BTreeMap, fs, num::NonZeroUsize, path::Path}; use ironstorage::{ command::{EXIT_FAILURE, GrepRequest, Presentation, ShowRequest}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, read::{PresentationChannel, ReadError, ShowOutput, ShowResult, TreeNodeKind, VaultReader}, repository::{DirectoryPath, EntryPath, Repository, RepositoryError, SecretBytes}, }; use support::compatibility::{FixtureSet, TestResult}; struct FixtureSecrets { values: BTreeMap>, } impl FixtureSecrets { fn all(fixture: &FixtureSet) -> Self { Self { values: fixture .generated .keys .iter() .map(|key| { ( key.primary_fingerprint.clone(), key.passphrase.as_bytes().to_vec(), ) }) .collect(), } } } impl SecretProvider for FixtureSecrets { fn secret_for(&mut self, key: &KeyInfo) -> Result { self.values .get(key.fingerprint().as_str()) .cloned() .map(SecretBytes::new) .ok_or(SecretProviderError::Unavailable) } } #[test] fn deterministic_root_and_subtree_models_render_without_ciphertext_suffixes() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let root = reader.list(&DirectoryPath::root())?; assert_eq!(root.title(), "Password Store"); assert_eq!( root.render_plain(), concat!( "Password Store\n", "├── email\n", "│ └── personal\n", "├── otp\n", "│ ├── hotp\n", "│ └── totp\n", "├── shared\n", "│ └── multiple\n", "├── team\n", "│ └── service\n", "└── unicode\n", " └── 咖啡\n", ) ); assert!(!root.render_plain().contains(".gpg")); assert!(!root.render_plain().contains(".gpg-id")); let team = reader.list(&DirectoryPath::parse("team")?)?; assert_eq!(team.title(), "team"); assert_eq!(team.render_plain(), "team\n└── service\n"); assert!(matches!( reader.list(&DirectoryPath::parse("missing")?), Err(ReadError::Repository(RepositoryError::NotFound { .. })) )); Ok(()) } #[test] fn explicit_and_implicit_show_dispatch_to_entry_or_directory() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let expected = fixture.read("expected/basic/email/personal.txt")?; match reader.show(Some("email/personal"), &mut provider)? { ShowResult::Entry(secret) => assert_eq!(secret.expose(), expected), ShowResult::Directory(_) => panic!("entry dispatched as directory"), } match reader.show(Some("team"), &mut provider)? { ShowResult::Directory(tree) => assert_eq!(tree.render_plain(), "team\n└── service\n"), ShowResult::Entry(_) => panic!("directory dispatched as entry"), } match reader.show(None, &mut provider)? { ShowResult::Directory(tree) => assert_eq!(tree.title(), "Password Store"), ShowResult::Entry(_) => panic!("default show did not list root"), } assert!(matches!( reader.show(Some("does-not-exist"), &mut provider), Err(ReadError::Repository(RepositoryError::NotFound { .. })) )); Ok(()) } #[test] fn typed_clipboard_and_qr_requests_select_lines_without_rendered_state() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let clipboard = ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::Clipboard { line: NonZeroUsize::new(1).expect("nonzero"), }, }; let ShowOutput::Present(clipboard) = reader.execute_show(&clipboard, &mut provider)? else { panic!("clipboard request was not selected") }; assert_eq!(clipboard.channel(), PresentationChannel::Clipboard); assert_eq!(clipboard.line(), 1); assert_eq!(clipboard.contents().expose(), b"correct horse fixture"); assert!(!format!("{clipboard:?}").contains("correct horse")); let qr = ShowRequest { entry: Some("email/personal".to_owned()), presentation: Presentation::QrCode { line: NonZeroUsize::new(2).expect("nonzero"), }, }; let ShowOutput::Present(qr) = reader.execute_show(&qr, &mut provider)? else { panic!("QR request was not selected") }; assert_eq!(qr.channel(), PresentationChannel::QrCode); assert_eq!(qr.contents().expose(), b"login: alice@example.test"); let missing = reader.select_line(&EntryPath::parse("email/personal")?, 99, &mut provider); assert!(matches!( &missing, Err(ReadError::LineNotFound { line: 99, .. }) )); assert_eq!(missing.unwrap_err().exit_code(), EXIT_FAILURE); Ok(()) } #[test] fn find_matches_entry_and_directory_names_case_insensitively() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let results = reader.find(&["service".to_owned(), "咖啡".to_owned()])?; assert_eq!( results .matches() .iter() .map(|matched| (matched.path(), matched.kind())) .collect::>(), vec![ ("team/service", TreeNodeKind::Entry), ("unicode/咖啡", TreeNodeKind::Entry), ] ); assert_eq!( results.tree().render_plain(), concat!( "Password Store\n", "├── team\n", "│ └── service\n", "└── unicode\n", " └── 咖啡\n", ) ); assert!( results .render_plain() .starts_with("Search Terms: service,咖啡\nPassword Store\n") ); let personal = reader.find(&["PERSONAL".to_owned()])?; assert_eq!(personal.matches()[0].path(), "email/personal"); assert!(matches!( reader.find(&[]), Err(ReadError::MissingSearchTerms) )); Ok(()) } #[test] fn decrypted_grep_supports_regex_case_inversion_fixed_strings_and_lines() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let mut numbered = grep("alice@example\\.test"); numbered.line_number = true; let basic = reader.grep(&numbered, &mut provider)?; assert_eq!(basic.entries().len(), 1); assert_eq!(basic.entries()[0].path().to_string(), "email/personal"); assert_eq!(basic.entries()[0].lines()[0].number(), 2); assert_eq!( basic.entries()[0].lines()[0].contents().expose(), b"login: alice@example.test" ); assert!(!format!("{basic:?}").contains("alice@example")); assert_eq!( basic.render_plain().expose(), b"email/personal:\n2:login: alice@example.test\n" ); let mut insensitive = grep("FIXTURE"); insensitive.ignore_case = true; assert!(reader.grep(&insensitive, &mut provider)?.entries().len() >= 4); let mut fixed = grep("alice@example.test"); fixed.fixed_strings = true; assert_eq!(reader.grep(&fixed, &mut provider)?.entries().len(), 1); let mut inverted = grep("fixture"); inverted.invert_match = true; let inverted = reader.grep(&inverted, &mut provider)?; assert!(inverted.entries().iter().any(|entry| { entry.path().to_string() == "email/personal" && entry .lines() .iter() .any(|line| line.contents().expose() == b"login: alice@example.test") })); let invalid = grep("["); assert!(matches!( reader.grep(&invalid, &mut provider), Err(ReadError::InvalidRegex) )); Ok(()) } #[test] fn search_excludes_extensions_git_metadata_and_ciphertext_names() -> TestResult { let fixture = FixtureSet::load()?; let store = fixture.materialize_store("basic")?; fs::create_dir_all(store.path().join(".extensions/nested"))?; fs::copy( store.path().join("email/personal.gpg"), store.path().join(".extensions/nested/leaked-secret.gpg"), )?; fs::create_dir(store.path().join(".git"))?; fs::write(store.path().join(".git/secret.gpg"), b"not a packet")?; fs::write(store.path().join("visible-metadata.txt"), b"fixture")?; let repository = Repository::open(store.path())?; let keys = KeyStore::load(fixture.path("keys"))?; let reader = VaultReader::new(&repository, &keys); let mut provider = FixtureSecrets::all(&fixture); let tree = reader.list(&DirectoryPath::root())?.render_plain(); assert!(!tree.contains("extensions")); assert!(!tree.contains(".git")); assert!(!tree.contains("metadata")); assert!(!tree.contains(".gpg")); assert!(reader.find(&["leaked".to_owned()])?.matches().is_empty()); assert!(reader.find(&["metadata".to_owned()])?.matches().is_empty()); assert!( reader .grep(&grep("correct horse"), &mut provider)? .entries() .iter() .all(|entry| !entry.path().as_path().starts_with(Path::new(".extensions"))) ); Ok(()) } fn grep(pattern: &str) -> GrepRequest { GrepRequest { pattern: pattern.to_owned(), ignore_case: false, invert_match: false, line_number: false, fixed_strings: false, } }