Navigate password folders on iPhone

This commit is contained in:
2026-08-11 17:30:57 +02:00
parent 8e883f5ccf
commit 3295761bcf
7 changed files with 1144 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ pub mod kdbx;
pub mod mobile;
pub mod mobile_home;
pub mod mobile_onboarding;
pub mod mobile_passwords;
pub mod mutation;
pub mod otp;
pub mod presentation;

View File

@@ -0,0 +1,286 @@
//! Storage-owned, locked password navigation for native mobile frontends.
use std::{error::Error, fmt};
use crate::{
config::{Config, ConfigError},
read::{ReadError, TreeNode, TreeNodeKind, 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<MobilePasswordRow>,
}
impl MobilePasswordPage {
pub fn load(path: Option<&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::from_repository(&repository, path)
}
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<Self, MobilePasswordError> {
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 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<dyn std::error::Error>> {
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(())
}
}

View File

@@ -386,8 +386,7 @@ impl<'a> VaultReader<'a> {
}
pub fn list(&self, directory: &DirectoryPath) -> Result<TreeModel, ReadError> {
let snapshot = self.repository.snapshot()?;
build_tree(&snapshot, directory, None)
list_tree(self.repository, directory)
}
/// Implement explicit or implicit show dispatch. No path means the root tree; a directory
@@ -565,6 +564,15 @@ impl<'a> VaultReader<'a> {
}
}
/// Build a locked navigation model without loading key material or decrypting entries.
pub(crate) fn list_tree(
repository: &Repository,
directory: &DirectoryPath,
) -> Result<TreeModel, ReadError> {
let snapshot = repository.snapshot()?;
build_tree(&snapshot, directory, None)
}
fn build_regex(request: &GrepRequest) -> Result<Regex, ReadError> {
let pattern = if request.fixed_strings {
regex::escape(&request.pattern)