Files
IronStorage/crates/storage/src/mobile_passwords.rs

402 lines
12 KiB
Rust

//! Storage-owned, locked password navigation for native mobile frontends.
use std::{error::Error, fmt};
use crate::{
config::{Config, ConfigError},
read::{ReadError, TreeNode, TreeNodeKind, hidden_path, 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 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
}
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 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 {
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(())
}
#[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(())
}
}