Navigate password folders on iPhone
This commit is contained in:
@@ -19,6 +19,11 @@ use ironstorage::{
|
||||
MobileOnboardingErrorKind as StorageOnboardingErrorKind,
|
||||
MobileOnboardingPhase as StorageOnboardingPhase,
|
||||
},
|
||||
mobile_passwords::{
|
||||
MobilePasswordError as StoragePasswordError,
|
||||
MobilePasswordErrorKind as StoragePasswordErrorKind,
|
||||
MobilePasswordRowKind as StoragePasswordRowKind,
|
||||
},
|
||||
};
|
||||
|
||||
uniffi::setup_scaffolding!();
|
||||
@@ -381,6 +386,111 @@ impl MobileHomeOperation {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobilePasswordRowKind {
|
||||
Directory,
|
||||
Entry,
|
||||
}
|
||||
|
||||
impl From<StoragePasswordRowKind> for MobilePasswordRowKind {
|
||||
fn from(kind: StoragePasswordRowKind) -> Self {
|
||||
match kind {
|
||||
StoragePasswordRowKind::Directory => Self::Directory,
|
||||
StoragePasswordRowKind::Entry => Self::Entry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePasswordRow {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub system_image: String,
|
||||
pub kind: MobilePasswordRowKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct MobilePasswordPage {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub rows: Vec<MobilePasswordRow>,
|
||||
}
|
||||
|
||||
impl From<ironstorage::mobile_passwords::MobilePasswordPage> for MobilePasswordPage {
|
||||
fn from(page: ironstorage::mobile_passwords::MobilePasswordPage) -> Self {
|
||||
Self {
|
||||
id: page.id().to_owned(),
|
||||
path: page.path().to_owned(),
|
||||
title: page.title().to_owned(),
|
||||
rows: page
|
||||
.rows()
|
||||
.iter()
|
||||
.map(|row| MobilePasswordRow {
|
||||
id: row.id().to_owned(),
|
||||
path: row.path().to_owned(),
|
||||
title: row.title().to_owned(),
|
||||
detail: row.detail().to_owned(),
|
||||
system_image: row.system_image().to_owned(),
|
||||
kind: row.kind().into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobilePasswordErrorKind {
|
||||
MissingConfiguration,
|
||||
Configuration,
|
||||
InvalidPath,
|
||||
DirectoryMissing,
|
||||
Repository,
|
||||
}
|
||||
|
||||
impl From<StoragePasswordErrorKind> for MobilePasswordErrorKind {
|
||||
fn from(kind: StoragePasswordErrorKind) -> Self {
|
||||
match kind {
|
||||
StoragePasswordErrorKind::MissingConfiguration => Self::MissingConfiguration,
|
||||
StoragePasswordErrorKind::Configuration => Self::Configuration,
|
||||
StoragePasswordErrorKind::InvalidPath => Self::InvalidPath,
|
||||
StoragePasswordErrorKind::DirectoryMissing => Self::DirectoryMissing,
|
||||
StoragePasswordErrorKind::Repository => Self::Repository,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, uniffi::Error)]
|
||||
pub enum MobilePasswordFfiError {
|
||||
Failed {
|
||||
kind: MobilePasswordErrorKind,
|
||||
title: String,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for MobilePasswordFfiError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Failed { title, detail, .. } => write!(formatter, "{title}: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobilePasswordFfiError {}
|
||||
|
||||
impl From<StoragePasswordError> for MobilePasswordFfiError {
|
||||
fn from(error: StoragePasswordError) -> Self {
|
||||
Self::Failed {
|
||||
kind: error.kind().into(),
|
||||
title: error.title().to_owned(),
|
||||
detail: error.detail().to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||
pub enum MobileOnboardingPhase {
|
||||
Validating,
|
||||
@@ -592,6 +702,15 @@ pub fn mobile_home_operation() -> Arc<MobileHomeOperation> {
|
||||
})
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_password_page(
|
||||
path: Option<String>,
|
||||
) -> Result<MobilePasswordPage, MobilePasswordFfiError> {
|
||||
ironstorage::mobile_passwords::MobilePasswordPage::load(path.as_deref())
|
||||
.map(Into::into)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn mobile_onboarding_operation(
|
||||
server_url: String,
|
||||
|
||||
@@ -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;
|
||||
|
||||
286
crates/storage/src/mobile_passwords.rs
Normal file
286
crates/storage/src/mobile_passwords.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user