Implement iPhone password search

This commit is contained in:
2026-08-11 21:43:25 +02:00
parent 358ba7d46d
commit a748744425
8 changed files with 397 additions and 10 deletions

View File

@@ -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()

View File

@@ -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(())
}
}

View File

@@ -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)
}

View File

@@ -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()