Implement Rust-backed Android name search (#101)
Android ARM verification / rust (push) Failing after 47m11s
Android ARM verification / android (push) Failing after 9s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 19:30:15 +02:00
1 parent a06f39af23
commit c7703d8efd
21 files changed
+910 -27

No files matched your search

@@ -48,6 +48,23 @@ impl From<PasswordNavigation> for MobilePasswordNavigation {
}
#[uniffi::export]
impl MobilePrivateRuntime {
pub fn password_name_search(
&self,
query: String,
) -> Result<Vec<MobileNavigationRow>, MobilePasswordFfiError> {
ironstorage::mobile_passwords::navigation::search(&self.storage, &query)
.map(|rows| rows.into_iter().map(Into::into).collect())
.map_err(Into::into)
}
pub fn open_password_search_result(
&self,
id: String,
revision: String,
) -> Result<MobilePasswordNavigation, MobilePasswordFfiError> {
ironstorage::mobile_passwords::navigation::open_search_result(&self.storage, &id, &revision)
.map(Into::into)
.map_err(Into::into)
}
pub fn password_navigation(
&self,
path: String,
+1 -1
View File
@@ -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();