//! Storage-owned, locked password navigation for native mobile frontends. use std::{error::Error, fmt}; use crate::{ config::{Config, ConfigError}, read::{ReadError, TreeNode, TreeNodeKind, hidden_path, list_tree}, repository::{DirectoryPath, Repository, RepositoryError}, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobilePasswordRowKind { Directory, Entry, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobilePasswordRow { id: String, path: String, title: String, detail: String, system_image: String, kind: MobilePasswordRowKind, } impl MobilePasswordRow { pub fn id(&self) -> &str { &self.id } pub fn path(&self) -> &str { &self.path } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } pub fn system_image(&self) -> &str { &self.system_image } pub fn kind(&self) -> MobilePasswordRowKind { self.kind } } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobilePasswordPage { id: String, path: String, title: String, rows: Vec, } impl MobilePasswordPage { pub fn load(path: Option<&str>) -> Result { let config = Config::load(None).map_err(MobilePasswordError::from_config)?; let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; Self::from_repository(&repository, path) } pub fn search(query: &str) -> Result { let config = Config::load(None).map_err(MobilePasswordError::from_config)?; let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?; Self::search_repository(&repository, query) } pub fn id(&self) -> &str { &self.id } pub fn path(&self) -> &str { &self.path } pub fn title(&self) -> &str { &self.title } pub fn rows(&self) -> &[MobilePasswordRow] { &self.rows } fn from_repository( repository: &Repository, path: Option<&str>, ) -> Result { let directory = match path { Some(path) => DirectoryPath::parse(path).map_err(MobilePasswordError::repository)?, None => DirectoryPath::root(), }; let tree = list_tree(repository, &directory).map_err(MobilePasswordError::from_read)?; let path = directory .as_path() .to_str() .ok_or_else(MobilePasswordError::invalid_path)? .to_owned(); let title = directory .as_path() .file_name() .and_then(|name| name.to_str()) .unwrap_or("Passwords") .to_owned(); Ok(Self { id: format!("directory:{path}"), path, title, rows: tree.children().iter().map(mobile_row).collect(), }) } fn search_repository( repository: &Repository, query: &str, ) -> Result { let query = query.trim(); let folded = query.to_lowercase(); let snapshot = repository .snapshot() .map_err(MobilePasswordError::repository)?; let rows = if folded.is_empty() { Vec::new() } else { snapshot .entries() .filter(|entry| !hidden_path(entry.path().as_path())) .filter_map(|entry| { let path = entry.path().as_path().to_str()?; path.to_lowercase() .contains(&folded) .then(|| search_row(entry.path(), path)) }) .collect() }; Ok(Self { id: format!("search:{query}"), path: String::new(), title: "Search".to_owned(), rows, }) } } fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePasswordRow { let title = path .as_path() .file_name() .and_then(|name| name.to_str()) .unwrap_or(text) .to_owned(); let detail = path .as_path() .parent() .filter(|parent| !parent.as_os_str().is_empty()) .and_then(|parent| parent.to_str()) .unwrap_or("Password Store") .to_owned(); MobilePasswordRow { id: format!("entry:{text}"), path: text.to_owned(), title, detail, system_image: "key.fill".to_owned(), kind: MobilePasswordRowKind::Entry, } } fn mobile_row(node: &TreeNode) -> MobilePasswordRow { let path = node.path().to_owned(); let (kind, prefix, detail, system_image) = match node.kind() { TreeNodeKind::Directory => ( MobilePasswordRowKind::Directory, "directory", "Folder", "folder", ), TreeNodeKind::Entry => ( MobilePasswordRowKind::Entry, "entry", "Locked password", "key.fill", ), }; MobilePasswordRow { id: format!("{prefix}:{path}"), path, title: node.name().to_owned(), detail: detail.to_owned(), system_image: system_image.to_owned(), kind, } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum MobilePasswordErrorKind { MissingConfiguration, Configuration, InvalidPath, DirectoryMissing, Repository, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct MobilePasswordError { kind: MobilePasswordErrorKind, title: String, detail: String, } impl MobilePasswordError { pub fn kind(&self) -> MobilePasswordErrorKind { self.kind } pub fn title(&self) -> &str { &self.title } pub fn detail(&self) -> &str { &self.detail } fn from_config(error: ConfigError) -> Self { let kind = if matches!(error, ConfigError::NotFound { .. }) { MobilePasswordErrorKind::MissingConfiguration } else { MobilePasswordErrorKind::Configuration }; Self { kind, title: "Passwords Are Unavailable".to_owned(), detail: error.to_string(), } } fn repository(error: RepositoryError) -> Self { let kind = match error { RepositoryError::InvalidPath { .. } => MobilePasswordErrorKind::InvalidPath, RepositoryError::NotFound { .. } => MobilePasswordErrorKind::DirectoryMissing, _ => MobilePasswordErrorKind::Repository, }; Self { kind, title: match kind { MobilePasswordErrorKind::DirectoryMissing => "Folder No Longer Exists", MobilePasswordErrorKind::InvalidPath => "Invalid Password Folder", _ => "Passwords Are Unavailable", } .to_owned(), detail: error.to_string(), } } fn from_read(error: ReadError) -> Self { match error { ReadError::Repository(error) => Self::repository(error), _ => Self { kind: MobilePasswordErrorKind::Repository, title: "Passwords Are Unavailable".to_owned(), detail: error.to_string(), }, } } fn invalid_path() -> Self { Self { kind: MobilePasswordErrorKind::InvalidPath, title: "Invalid Password Folder".to_owned(), detail: "The password-store path is not valid UTF-8.".to_owned(), } } } impl fmt::Display for MobilePasswordError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "{}: {}", self.title, self.detail) } } impl Error for MobilePasswordError {} #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; use super::{MobilePasswordErrorKind, MobilePasswordPage, MobilePasswordRowKind}; use crate::repository::Repository; #[test] fn locked_pages_preserve_storage_identity_across_nested_refreshes() -> Result<(), Box> { let temporary = tempdir()?; fs::create_dir_all(temporary.path().join("Personal/Finance/Very Deep/Empty"))?; fs::write( temporary.path().join("Personal/Finance/bank.gpg"), b"ciphertext", )?; fs::write( temporary .path() .join("Personal/a very long password entry name.gpg"), b"ciphertext", )?; let repository = Repository::open(temporary.path())?; let root = MobilePasswordPage::from_repository(&repository, None)?; assert_eq!(root.title(), "Passwords"); assert_eq!(root.rows()[0].id(), "directory:Personal"); assert_eq!(root.rows()[0].kind(), MobilePasswordRowKind::Directory); let finance = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?; assert_eq!(finance.id(), "directory:Personal/Finance"); assert_eq!(finance.title(), "Finance"); assert_eq!( finance.rows()[0].id(), "directory:Personal/Finance/Very Deep" ); assert_eq!(finance.rows()[1].id(), "entry:Personal/Finance/bank"); assert_eq!(finance.rows()[1].detail(), "Locked password"); let refreshed = MobilePasswordPage::from_repository(&repository, Some("Personal/Finance"))?; assert_eq!(finance, refreshed); let empty = MobilePasswordPage::from_repository( &repository, Some("Personal/Finance/Very Deep/Empty"), )?; assert!(empty.rows().is_empty()); fs::remove_dir(temporary.path().join("Personal/Finance/Very Deep/Empty"))?; let error = MobilePasswordPage::from_repository( &repository, Some("Personal/Finance/Very Deep/Empty"), ) .unwrap_err(); assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing); Ok(()) } #[test] fn search_matches_entry_and_folder_names_and_preserves_location() -> Result<(), Box> { let temporary = tempdir()?; fs::create_dir_all(temporary.path().join("Personal/Finance"))?; fs::create_dir_all(temporary.path().join("Work/Finance"))?; fs::create_dir_all(temporary.path().join(".extensions"))?; fs::write( temporary.path().join("Personal/Finance/bank.gpg"), b"ciphertext", )?; fs::write( temporary.path().join("Work/Finance/bank.gpg"), b"ciphertext", )?; fs::write(temporary.path().join("Personal/mail.gpg"), b"ciphertext")?; fs::write( temporary.path().join(".extensions/leaked.gpg"), b"ciphertext", )?; let repository = Repository::open(temporary.path())?; let folder = MobilePasswordPage::search_repository(&repository, "FINANCE")?; assert_eq!( folder .rows() .iter() .map(|row| (row.title(), row.detail(), row.path())) .collect::>(), vec![ ("bank", "Personal/Finance", "Personal/Finance/bank"), ("bank", "Work/Finance", "Work/Finance/bank"), ] ); assert!( MobilePasswordPage::search_repository(&repository, "mail")? .rows() .iter() .any(|row| row.path() == "Personal/mail") ); assert!( MobilePasswordPage::search_repository(&repository, " ")? .rows() .is_empty() ); assert!( MobilePasswordPage::search_repository(&repository, "leaked")? .rows() .is_empty() ); Ok(()) } }