Implement Rust-backed Android name search (#101)
This commit is contained in:
1 parent
a06f39af23
commit
c7703d8efd
21 files changed
+910
-27
No files matched your search
@@ -121,7 +121,7 @@ impl MobilePasswordPage {
|
||||
})
|
||||
}
|
||||
|
||||
fn search_repository(
|
||||
pub(crate) fn search_repository(
|
||||
repository: &Repository,
|
||||
query: &str,
|
||||
) -> Result<Self, MobilePasswordError> {
|
||||
|
||||
@@ -46,6 +46,82 @@ pub fn navigate(
|
||||
navigate_repository(&repository, path, selected, revision, activate)
|
||||
}
|
||||
|
||||
/// Name-only search with identities that are rechecked before opening a result.
|
||||
pub fn search(
|
||||
storage: &PrivateStorage,
|
||||
query: &str,
|
||||
) -> Result<Vec<NavigationRow>, MobilePasswordError> {
|
||||
let root =
|
||||
Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?;
|
||||
let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?;
|
||||
let config = storage
|
||||
.configuration()
|
||||
.map_err(|_| unavailable())?
|
||||
.ok_or_else(unavailable)?;
|
||||
let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
|
||||
search_repository(&repository, query)
|
||||
}
|
||||
|
||||
fn search_repository(
|
||||
repository: &Repository,
|
||||
query: &str,
|
||||
) -> Result<Vec<NavigationRow>, MobilePasswordError> {
|
||||
MobilePasswordPage::search_repository(repository, query)?
|
||||
.rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let revision = repository
|
||||
.navigation_revision(std::path::Path::new(row.path()), true)
|
||||
.map_err(MobilePasswordError::repository)?;
|
||||
Ok(NavigationRow {
|
||||
row,
|
||||
revision: HEXLOWER.encode(&Sha256::digest(revision.as_bytes())),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn open_search_result(
|
||||
storage: &PrivateStorage,
|
||||
id: &str,
|
||||
revision: &str,
|
||||
) -> Result<PasswordNavigation, MobilePasswordError> {
|
||||
let root =
|
||||
Dir::open_ambient_dir(storage.root(), ambient_authority()).map_err(|_| unavailable())?;
|
||||
let _lock = exclusive_lock(&root, "runtime.lock").map_err(|_| unavailable())?;
|
||||
let config = storage
|
||||
.configuration()
|
||||
.map_err(|_| unavailable())?
|
||||
.ok_or_else(unavailable)?;
|
||||
let repository = Repository::open(config.vault()).map_err(MobilePasswordError::repository)?;
|
||||
open_search_repository(&repository, id, revision)
|
||||
}
|
||||
|
||||
fn open_search_repository(
|
||||
repository: &Repository,
|
||||
id: &str,
|
||||
revision: &str,
|
||||
) -> Result<PasswordNavigation, MobilePasswordError> {
|
||||
let path = id
|
||||
.strip_prefix("entry:")
|
||||
.ok_or_else(MobilePasswordError::invalid_path)?;
|
||||
let path =
|
||||
crate::repository::EntryPath::parse(path).map_err(MobilePasswordError::repository)?;
|
||||
let parent = path
|
||||
.as_path()
|
||||
.parent()
|
||||
.and_then(|p| p.to_str())
|
||||
.unwrap_or("");
|
||||
let mut page = navigate_repository(repository, parent, None, None, false)?;
|
||||
page.selected = page
|
||||
.rows
|
||||
.iter()
|
||||
.find(|row| row.row.id() == id && row.revision == revision)
|
||||
.cloned();
|
||||
page.stale = page.selected.is_none();
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn unavailable() -> MobilePasswordError {
|
||||
MobilePasswordError {
|
||||
kind: MobilePasswordErrorKind::Configuration,
|
||||
@@ -137,6 +213,117 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn search_uses_shared_unicode_path_rules_without_decryption_and_rejects_stale_objects() {
|
||||
let temporary = tempdir().unwrap();
|
||||
for directory in ["仕事/銀行", "personal", ".hidden", ".extensions"] {
|
||||
fs::create_dir_all(temporary.path().join(directory)).unwrap();
|
||||
}
|
||||
for path in [
|
||||
"仕事/銀行/LOGIN",
|
||||
"personal/login",
|
||||
"personal/Äccount",
|
||||
".hidden/login",
|
||||
".extensions/login",
|
||||
] {
|
||||
fs::write(
|
||||
temporary.path().join(format!("{path}.gpg")),
|
||||
b"invalid ciphertext",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let repository = Repository::open(temporary.path()).unwrap();
|
||||
let rows = search_repository(&repository, " LOGIN ").unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert!(rows.iter().any(|row| row.row.path() == ".hidden/login"));
|
||||
assert!(
|
||||
!rows
|
||||
.iter()
|
||||
.any(|row| row.row.path().starts_with(".extensions/"))
|
||||
);
|
||||
assert_eq!(
|
||||
search_repository(&repository, "銀行/loGIN").unwrap().len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(search_repository(&repository, "äCCOUNT").unwrap().len(), 1);
|
||||
assert!(
|
||||
search_repository(&repository, "銀行\\LOGIN")
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(search_repository(&repository, ".*").unwrap().is_empty());
|
||||
assert!(search_repository(&repository, " ").unwrap().is_empty());
|
||||
let shared = MobilePasswordPage::search_repository(&repository, " LOGIN ").unwrap();
|
||||
assert_eq!(
|
||||
rows.iter().map(|r| &r.row).collect::<Vec<_>>(),
|
||||
shared.rows().iter().collect::<Vec<_>>()
|
||||
);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|r| r.row.path() == "personal/login")
|
||||
.unwrap();
|
||||
let page = open_search_repository(&repository, row.row.id(), &row.revision).unwrap();
|
||||
assert_eq!(page.selected.unwrap().row.path(), "personal/login");
|
||||
fs::write(temporary.path().join("replacement"), b"invalid ciphertext").unwrap();
|
||||
fs::rename(
|
||||
temporary.path().join("replacement"),
|
||||
temporary.path().join("personal/login.gpg"),
|
||||
)
|
||||
.unwrap();
|
||||
let stale = open_search_repository(&repository, row.row.id(), &row.revision).unwrap();
|
||||
assert!(stale.stale);
|
||||
assert!(stale.selected.is_none());
|
||||
fs::remove_file(temporary.path().join("personal/login.gpg")).unwrap();
|
||||
assert!(
|
||||
open_search_repository(&repository, row.row.id(), &row.revision)
|
||||
.unwrap()
|
||||
.selected
|
||||
.is_none()
|
||||
);
|
||||
fs::write(
|
||||
temporary.path().join("personal/login-new.gpg"),
|
||||
b"not encrypted",
|
||||
)
|
||||
.unwrap();
|
||||
let refreshed = search_repository(&repository, "login").unwrap();
|
||||
assert!(
|
||||
refreshed
|
||||
.iter()
|
||||
.any(|r| r.row.path() == "personal/login-new")
|
||||
);
|
||||
assert!(open_search_repository(&repository, "entry:../escape", "revision").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_search_returns_every_identity_with_canonical_location() {
|
||||
let temporary = tempdir().unwrap();
|
||||
for folder in 0..10 {
|
||||
let directory = temporary.path().join(format!("folder-{folder}"));
|
||||
fs::create_dir(&directory).unwrap();
|
||||
for entry in 0..500 {
|
||||
fs::write(
|
||||
directory.join(format!("login-{entry}.gpg")),
|
||||
b"not ciphertext",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let repository = Repository::open(temporary.path()).unwrap();
|
||||
let rows = search_repository(&repository, "login").unwrap();
|
||||
assert_eq!(rows.len(), 5000);
|
||||
assert_eq!(
|
||||
rows.iter()
|
||||
.map(|r| r.row.id())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
5000
|
||||
);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|r| r.row.detail().starts_with("folder-") && !r.revision.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traverses_without_reading_or_decrypting_entries_and_restores_verified_selection() {
|
||||
let temporary = tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user