Implement iPhone password search
This commit is contained in:
@@ -10,17 +10,25 @@ pub enum MobileTab {
|
||||
#[default]
|
||||
Home,
|
||||
Passwords,
|
||||
Search,
|
||||
Totp,
|
||||
Preferences,
|
||||
}
|
||||
|
||||
impl MobileTab {
|
||||
pub const ALL: [Self; 4] = [Self::Home, Self::Passwords, Self::Totp, Self::Preferences];
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Home,
|
||||
Self::Passwords,
|
||||
Self::Search,
|
||||
Self::Totp,
|
||||
Self::Preferences,
|
||||
];
|
||||
|
||||
pub const fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "Home",
|
||||
Self::Passwords => "Passwords",
|
||||
Self::Search => "Search",
|
||||
Self::Totp => "TOTP",
|
||||
Self::Preferences => "Preferences",
|
||||
}
|
||||
@@ -30,6 +38,7 @@ impl MobileTab {
|
||||
match self {
|
||||
Self::Home => "house",
|
||||
Self::Passwords => "key",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape",
|
||||
}
|
||||
@@ -39,6 +48,7 @@ impl MobileTab {
|
||||
match self {
|
||||
Self::Home => "house.fill",
|
||||
Self::Passwords => "key.fill",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape.fill",
|
||||
}
|
||||
@@ -48,6 +58,7 @@ impl MobileTab {
|
||||
match self {
|
||||
Self::Home => "home",
|
||||
Self::Passwords => "passwords",
|
||||
Self::Search => "search",
|
||||
Self::Totp => "totp",
|
||||
Self::Preferences => "preferences",
|
||||
}
|
||||
@@ -57,6 +68,7 @@ impl MobileTab {
|
||||
match value {
|
||||
"home" => Ok(Self::Home),
|
||||
"passwords" => Ok(Self::Passwords),
|
||||
"search" => Ok(Self::Search),
|
||||
"totp" => Ok(Self::Totp),
|
||||
"preferences" => Ok(Self::Preferences),
|
||||
_ => Err(ConfigError::InvalidField {
|
||||
@@ -234,7 +246,7 @@ mod tests {
|
||||
use super::{MobilePage, MobileShell, MobileShellState, MobileTab, store_selected_tab_from};
|
||||
|
||||
#[test]
|
||||
fn every_fixture_is_view_ready_for_all_four_tabs() {
|
||||
fn every_fixture_is_view_ready_for_all_five_tabs() {
|
||||
for state in [
|
||||
MobileShellState::Loading,
|
||||
MobileShellState::Empty,
|
||||
@@ -244,7 +256,7 @@ mod tests {
|
||||
] {
|
||||
let shell = MobileShell::fixture(state);
|
||||
assert_eq!(shell.selected_tab(), MobileTab::Home);
|
||||
assert_eq!(shell.pages().len(), 4);
|
||||
assert_eq!(shell.pages().len(), 5);
|
||||
assert_eq!(
|
||||
shell
|
||||
.pages()
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
config::{Config, ConfigError},
|
||||
read::{ReadError, TreeNode, TreeNodeKind, list_tree},
|
||||
read::{ReadError, TreeNode, TreeNodeKind, hidden_path, list_tree},
|
||||
repository::{DirectoryPath, Repository, RepositoryError},
|
||||
};
|
||||
|
||||
@@ -66,6 +66,13 @@ impl MobilePasswordPage {
|
||||
Self::from_repository(&repository, path)
|
||||
}
|
||||
|
||||
pub fn search(query: &str) -> Result<Self, MobilePasswordError> {
|
||||
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
|
||||
}
|
||||
@@ -109,6 +116,61 @@ impl MobilePasswordPage {
|
||||
rows: tree.children().iter().map(mobile_row).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn search_repository(
|
||||
repository: &Repository,
|
||||
query: &str,
|
||||
) -> Result<Self, MobilePasswordError> {
|
||||
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 {
|
||||
@@ -283,4 +345,57 @@ mod tests {
|
||||
assert_eq!(error.kind(), MobilePasswordErrorKind::DirectoryMissing);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_entry_and_folder_names_and_preserves_location()
|
||||
-> Result<(), Box<dyn std::error::Error>> {
|
||||
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<_>>(),
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,7 +715,7 @@ fn is_included_path(path: &Path, included: &[&str], directory: bool) -> bool {
|
||||
}) || included.iter().any(|candidate| *candidate == text)
|
||||
}
|
||||
|
||||
fn hidden_path(path: &Path) -> bool {
|
||||
pub(crate) fn hidden_path(path: &Path) -> bool {
|
||||
path.components()
|
||||
.any(|component| component.as_os_str() == EXTENSIONS_DIRECTORY)
|
||||
}
|
||||
|
||||
@@ -221,6 +221,10 @@ fn mobile_tab_defaults_and_persists_through_storage_configuration() -> TestResul
|
||||
fs::read_to_string(fixture.explicit_path())?.contains("selected_mobile_tab = \"totp\"")
|
||||
);
|
||||
|
||||
reloaded.update_mobile_tab(MobileTab::Search)?;
|
||||
let reloaded = fixture.loader().load(Some(&fixture.explicit_path()))?;
|
||||
assert_eq!(reloaded.mobile_tab(), MobileTab::Search);
|
||||
|
||||
fixture.write_explicit(&format!(
|
||||
"{}\n[ui]\nselected_mobile_tab = \"unknown\"\n",
|
||||
fixture.valid_contents()
|
||||
|
||||
Reference in New Issue
Block a user