Implement Android Home activity and HTTPS synchronization (#97)
This commit is contained in:
1 parent
8c9341f214
commit
5c933a99a3
29 files changed
+2008
-110
No files matched your search
@@ -4,6 +4,7 @@ use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -79,7 +80,11 @@ impl MobileAuthenticationError {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
fn new(kind: MobileAuthenticationErrorKind, title: &str, detail: impl Into<String>) -> Self {
|
||||
pub(crate) fn new(
|
||||
kind: MobileAuthenticationErrorKind,
|
||||
title: &str,
|
||||
detail: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
title: title.to_owned(),
|
||||
@@ -352,6 +357,32 @@ pub struct MobileAuthentication {
|
||||
}
|
||||
|
||||
impl MobileAuthentication {
|
||||
pub(crate) fn matches_configuration(
|
||||
&self,
|
||||
config: &Config,
|
||||
) -> Result<bool, MobileAuthenticationError> {
|
||||
let status = self.status()?;
|
||||
if self.config.source() != config.source()
|
||||
|| self.config.vault() != config.vault()
|
||||
|| self.config.key_material() != config.key_material()
|
||||
|| self.config.default_key() != config.default_key()
|
||||
|| self.config.git_remotes() != config.git_remotes()
|
||||
|| status.git_identity != *config.git_identity()
|
||||
|| status.authentication_timeout != config.authentication_timeout()
|
||||
|| status.biometric_unlock_enabled != config.biometric_unlock_enabled()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let current = Repository::open(config.vault()).map_err(entry_error)?;
|
||||
Ok(self
|
||||
.repository
|
||||
.navigation_revision(Path::new(""), false)
|
||||
.map_err(entry_error)?
|
||||
== current
|
||||
.navigation_revision(Path::new(""), false)
|
||||
.map_err(entry_error)?)
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, MobileAuthenticationError> {
|
||||
let config = Config::load(None).map_err(|error| {
|
||||
MobileAuthenticationError::new(
|
||||
@@ -913,6 +944,7 @@ impl MobileAuthentication {
|
||||
&self,
|
||||
operation: &MobileTotpOperation,
|
||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
self.ensure_active()?;
|
||||
let _discovery = self.totp_discovery.lock().map_err(|_| {
|
||||
entry_detail(
|
||||
@@ -946,6 +978,7 @@ impl MobileAuthentication {
|
||||
&self,
|
||||
query: &str,
|
||||
) -> Result<Option<MobileTotpPage>, MobileAuthenticationError> {
|
||||
self.ensure_repository_idle()?;
|
||||
let shared = self.status()?.watch_shared_totp_entries.clone();
|
||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||
let mut page = MobileTotpService::new(&self.repository, &self.keys).search_cached_page(
|
||||
|
||||
@@ -7,7 +7,10 @@ use std::{
|
||||
error::Error,
|
||||
fmt,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -21,9 +24,11 @@ use crate::{
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileRepositoryOperation, MobileRepositoryOperationError,
|
||||
},
|
||||
mobile_private_storage::{PrivateStorage, PrivateStorageError, exclusive_lock},
|
||||
repository::{Repository, RepositoryError},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -222,6 +227,7 @@ impl MobileHomeNotice {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MobileHomePage {
|
||||
activity_known: bool,
|
||||
remote_configured: bool,
|
||||
freshness: MobileHomeFreshness,
|
||||
refreshed_at: Option<i64>,
|
||||
@@ -271,7 +277,7 @@ impl MobileHomePage {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub enum MobileHomePhase {
|
||||
Validating,
|
||||
Authenticating,
|
||||
@@ -321,27 +327,113 @@ impl MobileHomeProgress {
|
||||
}
|
||||
|
||||
pub struct MobileHomeOperation {
|
||||
activity_known: AtomicBool,
|
||||
authentication: Option<Arc<MobileAuthentication>>,
|
||||
control: GitOperationControl,
|
||||
phase: Arc<Mutex<MobileHomePhase>>,
|
||||
action: Mutex<Option<MobileHomeActionKind>>,
|
||||
private: Option<PrivateHomeContext>,
|
||||
}
|
||||
|
||||
struct PrivateHomeContext {
|
||||
storage: Arc<PrivateStorage>,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
}
|
||||
|
||||
impl MobileHomeOperation {
|
||||
pub fn new(authentication: Option<Arc<MobileAuthentication>>) -> Self {
|
||||
Self::with_context(authentication, None)
|
||||
}
|
||||
|
||||
fn with_context(
|
||||
authentication: Option<Arc<MobileAuthentication>>,
|
||||
private: Option<PrivateHomeContext>,
|
||||
) -> Self {
|
||||
let phase = Arc::new(Mutex::new(MobileHomePhase::Validating));
|
||||
let observed = Arc::clone(&phase);
|
||||
let monotonic = private.is_some();
|
||||
Self {
|
||||
activity_known: AtomicBool::new(false),
|
||||
private,
|
||||
authentication,
|
||||
control: GitOperationControl::new(move |phase| {
|
||||
if let Ok(mut current) = observed.lock() {
|
||||
*current = phase.into();
|
||||
if monotonic {
|
||||
if phase != GitProgressPhase::Refreshing {
|
||||
*current = (*current).max(phase.into());
|
||||
}
|
||||
} else {
|
||||
*current = phase.into();
|
||||
}
|
||||
}
|
||||
}),
|
||||
phase,
|
||||
action: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
pub fn with_private(
|
||||
storage: Arc<PrivateStorage>,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
clock: Arc<dyn crate::authentication::AuthenticationClock>,
|
||||
) -> Result<Self, MobileHomeError> {
|
||||
let authentication = storage
|
||||
.authentication(Arc::clone(&backend), clock)
|
||||
.map_err(|error| MobileHomeError {
|
||||
kind: match error.kind() {
|
||||
crate::mobile_authentication::MobileAuthenticationErrorKind::SecureStorage => {
|
||||
MobileHomeErrorKind::SecureStorage
|
||||
}
|
||||
crate::mobile_authentication::MobileAuthenticationErrorKind::Cancelled => {
|
||||
MobileHomeErrorKind::Interrupted
|
||||
}
|
||||
crate::mobile_authentication::MobileAuthenticationErrorKind::Conflict => {
|
||||
MobileHomeErrorKind::Conflict
|
||||
}
|
||||
crate::mobile_authentication::MobileAuthenticationErrorKind::Configuration
|
||||
| crate::mobile_authentication::MobileAuthenticationErrorKind::KeyMaterial => {
|
||||
MobileHomeErrorKind::Configuration
|
||||
}
|
||||
_ => MobileHomeErrorKind::Authentication,
|
||||
},
|
||||
title: "Home Is Unavailable",
|
||||
detail: error.detail().to_owned(),
|
||||
})?;
|
||||
Ok(Self::with_context(
|
||||
Some(authentication),
|
||||
Some(PrivateHomeContext { storage, backend }),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> Result<crate::git::GitIdentity, MobileHomeError> {
|
||||
Ok(self.load()?.config.git_identity().clone())
|
||||
}
|
||||
pub fn activity_known(&self) -> bool {
|
||||
self.activity_known.load(Ordering::Acquire)
|
||||
}
|
||||
fn observe(&self, page: MobileHomePage) -> MobileHomePage {
|
||||
self.activity_known
|
||||
.store(page.activity_known, Ordering::Release);
|
||||
page
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<MobileHomeStorage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load(self.private.as_ref())?;
|
||||
if self.private.is_some()
|
||||
&& let Some(authentication) = &self.authentication
|
||||
&& !authentication
|
||||
.matches_configuration(&storage.config)
|
||||
.map_err(|_| {
|
||||
MobileHomeError::from_private(PrivateStorageError::CorruptConfiguration)
|
||||
})?
|
||||
{
|
||||
return Err(MobileHomeError {
|
||||
kind: MobileHomeErrorKind::Configuration,
|
||||
title: "Password Store Changed",
|
||||
detail: "Reload Home before starting another repository action.".into(),
|
||||
});
|
||||
}
|
||||
Ok(storage)
|
||||
}
|
||||
pub fn cancel(&self) {
|
||||
self.control.cancel();
|
||||
}
|
||||
@@ -356,31 +448,37 @@ impl MobileHomeOperation {
|
||||
}
|
||||
|
||||
pub fn cached(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load()?;
|
||||
let storage = self.load()?;
|
||||
let refreshed_at = storage.config.mobile_home_refreshed_at();
|
||||
storage.page(
|
||||
if refreshed_at.is_some() {
|
||||
MobileHomeFreshness::Cached
|
||||
} else {
|
||||
MobileHomeFreshness::NeverRefreshed
|
||||
},
|
||||
refreshed_at,
|
||||
None,
|
||||
)
|
||||
storage
|
||||
.page(
|
||||
if refreshed_at.is_some() {
|
||||
MobileHomeFreshness::Cached
|
||||
} else {
|
||||
MobileHomeFreshness::NeverRefreshed
|
||||
},
|
||||
refreshed_at,
|
||||
None,
|
||||
)
|
||||
.map(|page| self.observe(page))
|
||||
}
|
||||
|
||||
pub fn refresh_if_stale(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = MobileHomeStorage::load()?;
|
||||
let storage = self.load()?;
|
||||
let now = unix_seconds()?;
|
||||
if storage.remote.is_none() {
|
||||
return storage.page(MobileHomeFreshness::Current, Some(now), None);
|
||||
return storage
|
||||
.page(MobileHomeFreshness::Current, Some(now), None)
|
||||
.map(|page| self.observe(page));
|
||||
}
|
||||
if !is_stale(storage.config.mobile_home_refreshed_at(), now) {
|
||||
return storage.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
storage.config.mobile_home_refreshed_at(),
|
||||
None,
|
||||
);
|
||||
return storage
|
||||
.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
storage.config.mobile_home_refreshed_at(),
|
||||
None,
|
||||
)
|
||||
.map(|page| self.observe(page));
|
||||
}
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
storage.fetch(now, &self.control)
|
||||
@@ -392,35 +490,47 @@ impl MobileHomeOperation {
|
||||
return Err(MobileHomeError::invalid_commit_message());
|
||||
}
|
||||
self.run(MobileHomeActionKind::Commit, || {
|
||||
MobileHomeStorage::load()?.commit(message, &self.control)
|
||||
self.load()?.commit(message, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn fetch(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Fetch, || {
|
||||
MobileHomeStorage::load()?.fetch(unix_seconds()?, &self.control)
|
||||
self.load()?.fetch(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn pull(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Pull, || {
|
||||
MobileHomeStorage::load()?.pull(unix_seconds()?, &self.control)
|
||||
self.load()?.pull(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn push(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.run(MobileHomeActionKind::Push, || {
|
||||
MobileHomeStorage::load()?.push(unix_seconds()?, &self.control)
|
||||
self.load()?.push(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sync(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
// Hold the pull reservation across both steps: sync can replace local entries.
|
||||
self.run(MobileHomeActionKind::Pull, || {
|
||||
MobileHomeStorage::load()?.sync(unix_seconds()?, &self.control)
|
||||
self.load()?.sync(unix_seconds()?, &self.control)
|
||||
})
|
||||
}
|
||||
|
||||
/// The Android refresh gesture shares the pull-before-push contract; local stores stay offline.
|
||||
pub fn refresh_gesture(&self) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let storage = self.load()?;
|
||||
if storage.remote.is_none() {
|
||||
return storage
|
||||
.page(MobileHomeFreshness::Current, Some(unix_seconds()?), None)
|
||||
.map(|page| self.observe(page));
|
||||
}
|
||||
drop(storage);
|
||||
self.sync()
|
||||
}
|
||||
|
||||
fn run(
|
||||
&self,
|
||||
action: MobileHomeActionKind,
|
||||
@@ -439,6 +549,16 @@ impl MobileHomeOperation {
|
||||
})
|
||||
.transpose()?;
|
||||
let result = operation();
|
||||
if let Ok(page) = &result {
|
||||
self.activity_known
|
||||
.store(page.activity_known, Ordering::Release);
|
||||
}
|
||||
if self.private.is_some()
|
||||
&& result.is_ok()
|
||||
&& let Ok(mut phase) = self.phase.lock()
|
||||
{
|
||||
*phase = MobileHomePhase::Finishing;
|
||||
}
|
||||
drop(reservation);
|
||||
result
|
||||
}
|
||||
@@ -448,20 +568,57 @@ struct MobileHomeStorage {
|
||||
config: Config,
|
||||
git: GitRepository,
|
||||
remote: Option<GitRemote>,
|
||||
backend: Option<Arc<dyn SecretStoreBackend>>,
|
||||
_private_lock: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl MobileHomeStorage {
|
||||
fn load() -> Result<Self, MobileHomeError> {
|
||||
let config = Config::load(None).map_err(MobileHomeError::from_config)?;
|
||||
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
|
||||
let (config, backend, lock) = if let Some(private) = private {
|
||||
private
|
||||
.storage
|
||||
.verify_secure_storage(private.backend.as_ref())
|
||||
.map_err(MobileHomeError::from_private)?;
|
||||
let root = cap_std::fs::Dir::open_ambient_dir(
|
||||
private.storage.root(),
|
||||
cap_std::ambient_authority(),
|
||||
)
|
||||
.map_err(|_| MobileHomeError::from_private(PrivateStorageError::Io))?;
|
||||
let lock =
|
||||
exclusive_lock(&root, "runtime.lock").map_err(MobileHomeError::from_private)?;
|
||||
let config = private
|
||||
.storage
|
||||
.configuration()
|
||||
.map_err(MobileHomeError::from_private)?
|
||||
.ok_or_else(|| {
|
||||
MobileHomeError::from_private(PrivateStorageError::ConfigurationMissingWithData)
|
||||
})?;
|
||||
(config, Some(Arc::clone(&private.backend)), Some(lock))
|
||||
} else {
|
||||
(
|
||||
Config::load(None).map_err(MobileHomeError::from_config)?,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let repository =
|
||||
Repository::open(config.vault()).map_err(MobileHomeError::from_repository)?;
|
||||
let git = GitRepository::open(&repository, config.git_identity().clone())
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let remote = config.git_remote(None).cloned();
|
||||
if private.is_some()
|
||||
&& remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| remote.endpoint().as_https().is_none())
|
||||
{
|
||||
return Err(MobileHomeError::from_git(GitError::ForbiddenRemoteUrl));
|
||||
}
|
||||
Ok(Self {
|
||||
config,
|
||||
git,
|
||||
remote,
|
||||
backend,
|
||||
_private_lock: lock,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,7 +636,7 @@ impl MobileHomeStorage {
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
.map_err(|error| MobileHomeError::after_commit(MobileHomeError::from_git(error)))?;
|
||||
self.page(
|
||||
MobileHomeFreshness::Cached,
|
||||
self.config.mobile_home_refreshed_at(),
|
||||
@@ -492,15 +649,20 @@ impl MobileHomeStorage {
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.fetch_using(now, control, &crate::git::EmbeddedFetchTransport)
|
||||
}
|
||||
fn fetch_using(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
transport: &impl crate::git::GitFetchTransport,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let fetched = self.git.fetch_with_transport_controlled(
|
||||
remote,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
);
|
||||
let fetched = self
|
||||
.git
|
||||
.fetch_with_transport_controlled(remote, &store, transport, control);
|
||||
let locked = store.lock();
|
||||
let outcome = fetched.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
@@ -508,7 +670,7 @@ impl MobileHomeStorage {
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
.map_err(|error| MobileHomeError::after_refresh(MobileHomeError::from_git(error)))?;
|
||||
self.current_page(now, Some(fetch_notice(&outcome)))
|
||||
}
|
||||
|
||||
@@ -516,16 +678,20 @@ impl MobileHomeStorage {
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.pull_using(now, control, &crate::git::EmbeddedFetchTransport)
|
||||
}
|
||||
fn pull_using(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
transport: &impl crate::git::GitFetchTransport,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pulled = self.git.pull_with_transport_controlled(
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::EmbeddedFetchTransport,
|
||||
control,
|
||||
);
|
||||
let pulled = self
|
||||
.git
|
||||
.pull_with_transport_controlled(remote, None, &store, transport, control);
|
||||
let locked = store.lock();
|
||||
let outcome = pulled.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
@@ -533,7 +699,7 @@ impl MobileHomeStorage {
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
.map_err(|error| MobileHomeError::after_pull(MobileHomeError::from_git(error)))?;
|
||||
self.current_page(now, Some(pull_notice(outcome)))
|
||||
.map_err(MobileHomeError::after_pull)
|
||||
}
|
||||
@@ -542,16 +708,20 @@ impl MobileHomeStorage {
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
self.push_using(now, control, &crate::git::ReqwestGitTransport)
|
||||
}
|
||||
fn push_using(
|
||||
&self,
|
||||
now: i64,
|
||||
control: &GitOperationControl,
|
||||
transport: &impl crate::git::GitSmartHttpTransport,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
let remote = self.remote()?;
|
||||
let store = self.credentials()?;
|
||||
let pushed = self.git.push_with_transport_controlled(
|
||||
remote,
|
||||
None,
|
||||
&store,
|
||||
&crate::git::ReqwestGitTransport,
|
||||
control,
|
||||
);
|
||||
let pushed = self
|
||||
.git
|
||||
.push_with_transport_controlled(remote, None, &store, transport, control);
|
||||
let locked = store.lock();
|
||||
let outcome = pushed.map_err(MobileHomeError::from_git)?;
|
||||
if let Err(error) = locked {
|
||||
@@ -559,7 +729,7 @@ impl MobileHomeStorage {
|
||||
}
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
.map_err(|error| MobileHomeError::after_push(MobileHomeError::from_git(error)))?;
|
||||
self.current_page(now, Some(push_notice(&outcome)))
|
||||
.map_err(MobileHomeError::after_push)
|
||||
}
|
||||
@@ -577,7 +747,7 @@ impl MobileHomeStorage {
|
||||
locked.map_err(MobileHomeError::partial_secret)?;
|
||||
control
|
||||
.report(GitProgressPhase::Refreshing)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
.map_err(|error| MobileHomeError::after_push(MobileHomeError::from_git(error)))?;
|
||||
let mut notice = push_notice(&pushed);
|
||||
notice.title = "Sync Completed".to_owned();
|
||||
notice.detail = format!("Pull completed successfully. {}", notice.detail);
|
||||
@@ -585,12 +755,16 @@ impl MobileHomeStorage {
|
||||
.map_err(MobileHomeError::after_push)
|
||||
}
|
||||
|
||||
fn credentials(&self) -> Result<NativeSecretStore, MobileHomeError> {
|
||||
let store = NativeSecretStore::system(
|
||||
fn credentials(&self) -> Result<SecretStore<Arc<dyn SecretStoreBackend>>, MobileHomeError> {
|
||||
let backend = match &self.backend {
|
||||
Some(backend) => Arc::clone(backend),
|
||||
None => Arc::new(NativeSecretBackend::new().map_err(MobileHomeError::from_secret)?),
|
||||
};
|
||||
let store = SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.map_err(MobileHomeError::from_secret)?;
|
||||
);
|
||||
store.unlock().map_err(MobileHomeError::from_secret)?;
|
||||
Ok(store)
|
||||
}
|
||||
@@ -630,29 +804,90 @@ impl MobileHomeStorage {
|
||||
refreshed_at: Option<i64>,
|
||||
notice: Option<MobileHomeNotice>,
|
||||
) -> Result<MobileHomePage, MobileHomeError> {
|
||||
if let Some(remote) = self.remote.as_ref() {
|
||||
let divergence = self
|
||||
.git
|
||||
.divergence(remote, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_divergence(
|
||||
&divergence,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
let mut page = if let Some(remote) = self.remote.as_ref() {
|
||||
match self.git.divergence(remote, ACTIVITY_LIMIT) {
|
||||
Ok(divergence) => {
|
||||
page_from_divergence(&divergence, freshness, refreshed_at, notice)
|
||||
}
|
||||
Err(GitError::RemoteNotFound { .. }) if self.backend.is_some() => {
|
||||
let snapshot = self
|
||||
.git
|
||||
.snapshot(None, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
let mut page =
|
||||
page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice);
|
||||
page.remote_configured = true;
|
||||
page.summaries[0] = MobileHomeSummaryRow {
|
||||
id: "tracked-branch".into(),
|
||||
title: format!("{}/{}", remote.name().as_str(), snapshot.branch()),
|
||||
detail: if refreshed_at.is_some() {
|
||||
"HTTPS branch · tracking reference unavailable"
|
||||
} else {
|
||||
"HTTPS branch · fetch remote activity first"
|
||||
}
|
||||
.into(),
|
||||
icon: MobileIcon::Branch,
|
||||
actions: vec![
|
||||
mobile_action(MobileHomeActionKind::Fetch),
|
||||
mobile_action(MobileHomeActionKind::Pull),
|
||||
mobile_action(MobileHomeActionKind::Push),
|
||||
],
|
||||
};
|
||||
let completed = page.notice.take().map_or(String::new(), |notice| {
|
||||
format!("{} {} ", notice.title, notice.detail)
|
||||
});
|
||||
page.notice = Some(MobileHomeNotice {
|
||||
title: "Tracked Branch Is Unavailable".into(),
|
||||
detail: format!(
|
||||
"{completed}Incoming and outgoing counts are unknown because this local branch has no fetched tracking reference. Local status remains available. Fetch before checking the branch configuration or publishing it."
|
||||
),
|
||||
icon: MobileIcon::Refresh,
|
||||
});
|
||||
page
|
||||
}
|
||||
Err(error) => return Err(MobileHomeError::from_git(error)),
|
||||
}
|
||||
} else {
|
||||
let snapshot = self
|
||||
.git
|
||||
.snapshot(None, ACTIVITY_LIMIT)
|
||||
.map_err(MobileHomeError::from_git)?;
|
||||
Ok(page_from_local_snapshot(
|
||||
&snapshot,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
notice,
|
||||
))
|
||||
page_from_local_snapshot(&snapshot, freshness, refreshed_at, notice)
|
||||
};
|
||||
if self.backend.is_some() {
|
||||
let status = self.git.status().map_err(MobileHomeError::from_git)?;
|
||||
let changes = status
|
||||
.staged()
|
||||
.iter()
|
||||
.chain(status.unstaged())
|
||||
.map(|change| (change.path(), change))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
page.summaries
|
||||
.extend(changes.values().take(ACTIVITY_LIMIT).map(|change| {
|
||||
let object = mobile_change(
|
||||
format!("local:{}", change.path().display()),
|
||||
change.path(),
|
||||
change.kind(),
|
||||
);
|
||||
MobileHomeSummaryRow {
|
||||
id: object.id,
|
||||
title: object.title,
|
||||
detail: object.detail,
|
||||
icon: object.icon,
|
||||
actions: vec![mobile_action(MobileHomeActionKind::Commit)],
|
||||
}
|
||||
}));
|
||||
if changes.len() > ACTIVITY_LIMIT {
|
||||
page.summaries.push(MobileHomeSummaryRow {
|
||||
id: "local-more".into(),
|
||||
title: format!("{} more local changes", changes.len() - ACTIVITY_LIMIT),
|
||||
detail: "All changes remain included when committing.".into(),
|
||||
icon: MobileIcon::History,
|
||||
actions: vec![mobile_action(MobileHomeActionKind::Commit)],
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +914,17 @@ pub struct MobileHomeError {
|
||||
}
|
||||
|
||||
impl MobileHomeError {
|
||||
fn from_private(error: PrivateStorageError) -> Self {
|
||||
Self {
|
||||
kind: if error == PrivateStorageError::Busy {
|
||||
MobileHomeErrorKind::Interrupted
|
||||
} else {
|
||||
MobileHomeErrorKind::SecureStorage
|
||||
},
|
||||
title: "Private Storage Needs Attention",
|
||||
detail: error.detail().into(),
|
||||
}
|
||||
}
|
||||
pub fn kind(&self) -> MobileHomeErrorKind {
|
||||
self.kind
|
||||
}
|
||||
@@ -781,6 +1027,11 @@ impl MobileHomeError {
|
||||
detail: "The HTTPS server identity could not be verified. Cached activity was not replaced."
|
||||
.to_owned(),
|
||||
},
|
||||
GitError::PushOutcomeUnknown { .. } => Self {
|
||||
kind: MobileHomeErrorKind::PartialProgress,
|
||||
title: "Push Outcome Is Uncertain",
|
||||
detail: "The server may have accepted the push. Fetch and inspect current activity before deciding whether to push again.".into(),
|
||||
},
|
||||
GitError::Cancelled => Self {
|
||||
kind: MobileHomeErrorKind::Interrupted,
|
||||
title: "Refresh Interrupted",
|
||||
@@ -793,6 +1044,11 @@ impl MobileHomeError {
|
||||
detail: "The configured credential-free HTTPS remote does not match the local clone."
|
||||
.to_owned(),
|
||||
},
|
||||
GitError::RemoteNotFound { .. } => Self {
|
||||
kind: MobileHomeErrorKind::Configuration,
|
||||
title: "Tracked Branch Is Unavailable",
|
||||
detail: "Fetch remote activity, then verify that this store uses the intended repository branch in Preferences.".into(),
|
||||
},
|
||||
_ => Self {
|
||||
kind: MobileHomeErrorKind::Repository,
|
||||
title: "Remote Activity Is Unavailable",
|
||||
@@ -989,6 +1245,7 @@ fn page_from_divergence(
|
||||
},
|
||||
];
|
||||
MobileHomePage {
|
||||
activity_known: true,
|
||||
remote_configured: true,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
@@ -1034,6 +1291,7 @@ fn page_from_local_snapshot(
|
||||
})
|
||||
.map(|line| display_text(line.trim(), 160));
|
||||
MobileHomePage {
|
||||
activity_known: false,
|
||||
remote_configured: false,
|
||||
freshness,
|
||||
refreshed_at,
|
||||
@@ -1387,6 +1645,10 @@ fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mobile_home/android_tests.rs"]
|
||||
mod android_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
authentication::SystemAuthenticationClock,
|
||||
git::{GitCredential, GitFetchTransport, GitRemoteCredentialProvider, GitSmartHttpTransport},
|
||||
mobile_onboarding::android::tests::{Backend, home_fixture},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
use std::{fs, sync::mpsc, thread, time::Duration};
|
||||
|
||||
fn connected() -> (tempfile::TempDir, Arc<PrivateStorage>, Arc<Backend>) {
|
||||
let fixture = home_fixture();
|
||||
let repository = Repository::open(fixture.1.root().join("vault")).unwrap();
|
||||
GitRepository::open(&repository, crate::git::GitIdentity::ironstorage())
|
||||
.unwrap()
|
||||
.add_remote("origin", "https://example.test/team/passwords.git")
|
||||
.unwrap();
|
||||
let config = fixture.1.configuration().unwrap().unwrap();
|
||||
let (server, app) = config.git_remotes()[0].https_credentials().unwrap();
|
||||
let store = SecretStore::new(
|
||||
fixture.2.clone(),
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
);
|
||||
store.unlock().unwrap();
|
||||
store
|
||||
.store_https_git_credential(
|
||||
server,
|
||||
app,
|
||||
"fixture-account",
|
||||
SecretBytes::new(b"public-fixture-token".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
store.lock().unwrap();
|
||||
fixture
|
||||
}
|
||||
fn operation(private: &Arc<PrivateStorage>, backend: &Arc<Backend>) -> MobileHomeOperation {
|
||||
MobileHomeOperation::with_private(
|
||||
private.clone(),
|
||||
backend.clone(),
|
||||
Arc::new(SystemAuthenticationClock::default()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_keeps_transient_private_storage_retry_guidance() {
|
||||
let (_temp, private, backend) = connected();
|
||||
let root =
|
||||
cap_std::fs::Dir::open_ambient_dir(private.root(), cap_std::ambient_authority()).unwrap();
|
||||
let lock = exclusive_lock(&root, "runtime.lock").unwrap();
|
||||
let error = MobileHomeOperation::with_private(
|
||||
private.clone(),
|
||||
backend.clone(),
|
||||
Arc::new(SystemAuthenticationClock::default()),
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), MobileHomeErrorKind::Configuration);
|
||||
assert!(error.detail().contains("Retry later"));
|
||||
assert!(
|
||||
!error
|
||||
.detail()
|
||||
.contains("Protected device storage is unavailable")
|
||||
);
|
||||
drop(lock);
|
||||
assert!(operation(&private, &backend).cached().is_ok());
|
||||
}
|
||||
|
||||
struct Fetch(Option<GitError>);
|
||||
impl GitFetchTransport for Fetch {
|
||||
fn fetch(
|
||||
&self,
|
||||
repository: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
let credential = credentials.credential_for(configured)?;
|
||||
assert_eq!(credential.username(), "fixture-account");
|
||||
assert_eq!(credential.password(), b"public-fixture-token");
|
||||
if let Some(error) = &self.0 {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let snapshot = repository.snapshot(None, 1)?;
|
||||
let tracking = repository.root().join(format!(
|
||||
".git/refs/remotes/{}/{}",
|
||||
configured.name().as_str(),
|
||||
snapshot.branch()
|
||||
));
|
||||
fs::create_dir_all(tracking.parent().unwrap()).unwrap();
|
||||
fs::write(tracking, format!("{}\n", snapshot.recent()[0].id())).unwrap();
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct AbsentBranch;
|
||||
impl GitFetchTransport for AbsentBranch {
|
||||
fn fetch(
|
||||
&self,
|
||||
_: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
assert_eq!(
|
||||
credentials.credential_for(configured)?.username(),
|
||||
"fixture-account"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_successful_fetch_records_freshness_even_when_the_local_branch_is_absent() {
|
||||
let (_temp, private, backend) = connected();
|
||||
let home = operation(&private, &backend);
|
||||
let page = home
|
||||
.run(MobileHomeActionKind::Fetch, || {
|
||||
home.load()?.fetch_using(500, &home.control, &AbsentBranch)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page.freshness(), MobileHomeFreshness::Current);
|
||||
assert_eq!(page.refreshed_at(), Some(500));
|
||||
assert!(!home.activity_known());
|
||||
let notice = page.notice().unwrap();
|
||||
assert_eq!(notice.title(), "Tracked Branch Is Unavailable");
|
||||
assert!(notice.detail().contains("Remote Already Current"));
|
||||
let cached = operation(&private, &backend).cached().unwrap();
|
||||
assert_eq!(cached.freshness(), MobileHomeFreshness::Cached);
|
||||
assert_eq!(cached.refreshed_at(), Some(500));
|
||||
}
|
||||
fn packet(value: &[u8]) -> Vec<u8> {
|
||||
let mut output = format!("{:04x}", value.len() + 4).into_bytes();
|
||||
output.extend_from_slice(value);
|
||||
output
|
||||
}
|
||||
struct Push {
|
||||
old: String,
|
||||
branch: String,
|
||||
request: Mutex<Vec<u8>>,
|
||||
}
|
||||
impl GitSmartHttpTransport for Push {
|
||||
fn advertise_receive_pack(
|
||||
&self,
|
||||
_: &url::Url,
|
||||
credential: &GitCredential,
|
||||
) -> Result<Vec<u8>, GitError> {
|
||||
assert_eq!(credential.username(), "fixture-account");
|
||||
assert_eq!(credential.password(), b"public-fixture-token");
|
||||
let mut data = packet(b"# service=git-receive-pack\n");
|
||||
data.extend_from_slice(b"0000");
|
||||
data.extend(packet(
|
||||
format!("{} refs/heads/{}\0report-status\n", self.old, self.branch).as_bytes(),
|
||||
));
|
||||
data.extend_from_slice(b"0000");
|
||||
Ok(data)
|
||||
}
|
||||
fn receive_pack(
|
||||
&self,
|
||||
_: &url::Url,
|
||||
_: &GitCredential,
|
||||
request: Vec<u8>,
|
||||
) -> Result<Vec<u8>, GitError> {
|
||||
*self.request.lock().unwrap() = request;
|
||||
let mut data = packet(b"unpack ok\n");
|
||||
data.extend(packet(
|
||||
format!("ok refs/heads/{}\n", self.branch).as_bytes(),
|
||||
));
|
||||
data.extend_from_slice(b"0000");
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_fetch_pull_and_real_pack_push_use_only_the_injected_application_credential() {
|
||||
let (_temp, private, backend) = connected();
|
||||
let home = operation(&private, &backend);
|
||||
home.cached().unwrap();
|
||||
assert!(!home.activity_known());
|
||||
home.run(MobileHomeActionKind::Fetch, || {
|
||||
home.load()?.fetch_using(100, &home.control, &Fetch(None))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(home.activity_known());
|
||||
let pull = operation(&private, &backend);
|
||||
assert_eq!(
|
||||
pull.run(MobileHomeActionKind::Pull, || pull.load()?.pull_using(
|
||||
101,
|
||||
&pull.control,
|
||||
&Fetch(None)
|
||||
))
|
||||
.unwrap()
|
||||
.notice()
|
||||
.unwrap()
|
||||
.title(),
|
||||
"Already Up to Date"
|
||||
);
|
||||
let repository = Repository::open(private.root().join("vault")).unwrap();
|
||||
let git = GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()).unwrap();
|
||||
let snapshot = git.snapshot(None, 1).unwrap();
|
||||
let push = Push {
|
||||
old: snapshot.recent()[0].id().into(),
|
||||
branch: snapshot.branch().into(),
|
||||
request: Mutex::new(Vec::new()),
|
||||
};
|
||||
fs::write(
|
||||
private.root().join("vault/changed.gpg"),
|
||||
b"encrypted fixture",
|
||||
)
|
||||
.unwrap();
|
||||
operation(&private, &backend)
|
||||
.commit("Commit before push")
|
||||
.unwrap();
|
||||
let home = operation(&private, &backend);
|
||||
let page = home
|
||||
.run(MobileHomeActionKind::Push, || {
|
||||
home.load()?.push_using(102, &home.control, &push)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(page.notice().unwrap().title(), "Changes Pushed");
|
||||
assert!(
|
||||
push.request
|
||||
.lock()
|
||||
.unwrap()
|
||||
.windows(4)
|
||||
.any(|bytes| bytes == b"PACK")
|
||||
);
|
||||
assert!(git.status().unwrap().is_clean());
|
||||
assert_eq!(home.progress().phase(), MobileHomePhase::Finishing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_offline_tls_and_authentication_failures_keep_cached_activity_recoverable() {
|
||||
let (_temp, private, backend) = connected();
|
||||
for (error, expected) in [
|
||||
(GitError::NetworkUnavailable, MobileHomeErrorKind::Offline),
|
||||
(GitError::TlsFailed, MobileHomeErrorKind::Offline),
|
||||
(
|
||||
GitError::AuthenticationFailed,
|
||||
MobileHomeErrorKind::Authentication,
|
||||
),
|
||||
] {
|
||||
let home = operation(&private, &backend);
|
||||
let cached = home.cached().unwrap();
|
||||
let failed = home
|
||||
.run(MobileHomeActionKind::Fetch, || {
|
||||
home.load()?
|
||||
.fetch_using(200, &home.control, &Fetch(Some(error)))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(failed.kind(), expected);
|
||||
assert!(!failed.to_string().contains("public-fixture-token"));
|
||||
assert_eq!(cached, operation(&private, &backend).cached().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_cancellation_after_commit_reports_completion_without_replaying_it() {
|
||||
let (_temp, private, backend) = connected();
|
||||
fs::write(
|
||||
private.root().join("vault/completed.gpg"),
|
||||
b"encrypted fixture",
|
||||
)
|
||||
.unwrap();
|
||||
let observed = Arc::new(Mutex::new(None::<GitOperationControl>));
|
||||
let cancel = observed.clone();
|
||||
let control = GitOperationControl::new(move |phase| {
|
||||
if phase == GitProgressPhase::Refreshing {
|
||||
cancel.lock().unwrap().as_ref().unwrap().cancel();
|
||||
}
|
||||
});
|
||||
*observed.lock().unwrap() = Some(control.clone());
|
||||
let home = operation(&private, &backend);
|
||||
let error = home
|
||||
.load()
|
||||
.unwrap()
|
||||
.commit("Completed once", &control)
|
||||
.unwrap_err();
|
||||
observed.lock().unwrap().take();
|
||||
assert_eq!(error.kind(), MobileHomeErrorKind::PartialProgress);
|
||||
assert_eq!(error.title(), "Commit Completed, Status Unavailable");
|
||||
let repository = Repository::open(private.root().join("vault")).unwrap();
|
||||
let git = GitRepository::open(&repository, crate::git::GitIdentity::ironstorage()).unwrap();
|
||||
assert!(git.status().unwrap().is_clean());
|
||||
assert_eq!(
|
||||
git.snapshot(None, 1).unwrap().recent()[0].message(),
|
||||
"Completed once"
|
||||
);
|
||||
assert_eq!(
|
||||
operation(&private, &backend)
|
||||
.commit("Retry")
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
MobileHomeErrorKind::NoChanges
|
||||
);
|
||||
}
|
||||
|
||||
struct SlowFetch(mpsc::Sender<()>);
|
||||
impl GitFetchTransport for SlowFetch {
|
||||
fn fetch(
|
||||
&self,
|
||||
_: &GitRepository,
|
||||
_: &GitRemote,
|
||||
_: &dyn GitRemoteCredentialProvider,
|
||||
) -> Result<bool, GitError> {
|
||||
unreachable!("controlled transport required")
|
||||
}
|
||||
fn fetch_controlled(
|
||||
&self,
|
||||
_: &GitRepository,
|
||||
configured: &GitRemote,
|
||||
credentials: &dyn GitRemoteCredentialProvider,
|
||||
control: &GitOperationControl,
|
||||
) -> Result<bool, GitError> {
|
||||
assert_eq!(
|
||||
credentials.credential_for(configured)?.password(),
|
||||
b"public-fixture-token"
|
||||
);
|
||||
control.report(GitProgressPhase::Receiving)?;
|
||||
self.0.send(()).unwrap();
|
||||
while !control.is_cancelled() {
|
||||
control.report(GitProgressPhase::Authenticating)?;
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(GitError::Cancelled)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn private_home_slow_network_progress_is_monotonic_and_cancel_then_retry_preserves_the_store() {
|
||||
let (_temp, private, backend) = connected();
|
||||
let home = Arc::new(operation(&private, &backend));
|
||||
let (started, received) = mpsc::channel();
|
||||
let worker = home.clone();
|
||||
let thread = thread::spawn(move || {
|
||||
worker.run(MobileHomeActionKind::Fetch, || {
|
||||
worker
|
||||
.load()?
|
||||
.fetch_using(300, &worker.control, &SlowFetch(started))
|
||||
})
|
||||
});
|
||||
received.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(
|
||||
home.authentication
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.cached_totp_page()
|
||||
.unwrap_err()
|
||||
.title()
|
||||
.contains("Repository Action")
|
||||
);
|
||||
assert_eq!(home.progress().phase(), MobileHomePhase::Receiving);
|
||||
home.cancel();
|
||||
assert_eq!(
|
||||
thread.join().unwrap().unwrap_err().kind(),
|
||||
MobileHomeErrorKind::Interrupted
|
||||
);
|
||||
let retry = operation(&private, &backend);
|
||||
retry
|
||||
.run(MobileHomeActionKind::Fetch, || {
|
||||
retry.load()?.fetch_using(301, &retry.control, &Fetch(None))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(retry.activity_known());
|
||||
let repository = Repository::open(private.root().join("vault")).unwrap();
|
||||
assert!(
|
||||
GitRepository::open(&repository, crate::git::GitIdentity::ironstorage())
|
||||
.unwrap()
|
||||
.status()
|
||||
.unwrap()
|
||||
.is_clean()
|
||||
);
|
||||
}
|
||||
@@ -895,7 +895,7 @@ fn pending_locator(journal: &Journal) -> Option<SecretLocator> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
git::{GitFetchTransport, GitRemoteCredentialProvider},
|
||||
@@ -909,7 +909,7 @@ mod tests {
|
||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec()))
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Backend {
|
||||
pub(crate) struct Backend {
|
||||
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
||||
fail_tokens: AtomicBool,
|
||||
}
|
||||
@@ -1510,4 +1510,197 @@ mod tests {
|
||||
b"retained password store"
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn home_fixture() -> (tempfile::TempDir, Arc<PrivateStorage>, Arc<Backend>) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend = Arc::new(Backend::default());
|
||||
let private = Arc::new(storage(temp.path()));
|
||||
imported(&private, &backend)
|
||||
.setup_local(ALICE, password())
|
||||
.unwrap();
|
||||
(temp, private, backend)
|
||||
}
|
||||
fn home(
|
||||
private: &Arc<PrivateStorage>,
|
||||
backend: &Arc<Backend>,
|
||||
) -> crate::mobile_home::MobileHomeOperation {
|
||||
crate::mobile_home::MobileHomeOperation::with_private(
|
||||
Arc::clone(private),
|
||||
backend.clone(),
|
||||
Arc::new(crate::authentication::SystemAuthenticationClock::default()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_commits_with_configured_identity_without_entry_authentication() {
|
||||
use crate::mobile_home::{MobileHomeActionKind, MobileHomeFreshness, MobileHomePhase};
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
let config = private.configuration().unwrap().unwrap();
|
||||
config
|
||||
.update_git_identity(
|
||||
&GitIdentity::new("Android fixture", "fixture@example.test").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
config.vault().join("仕事.gpg"),
|
||||
b"ciphertext-only navigation fixture",
|
||||
)
|
||||
.unwrap();
|
||||
let operation = home(&private, &backend);
|
||||
let page = operation.cached().unwrap();
|
||||
assert!(
|
||||
page.summaries().iter().any(|row| row.title() == "仕事"
|
||||
&& row.actions()[0].kind() == MobileHomeActionKind::Commit)
|
||||
);
|
||||
assert_eq!(operation.identity().unwrap().name(), "Android fixture");
|
||||
let committed = operation.commit("Commit encrypted fixture").unwrap();
|
||||
assert_eq!(committed.notice().unwrap().title(), "Changes Committed");
|
||||
assert_eq!(operation.progress().phase(), MobileHomePhase::Finishing);
|
||||
let repo = Repository::open(config.vault()).unwrap();
|
||||
let git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap();
|
||||
assert!(git.status().unwrap().is_clean());
|
||||
assert_eq!(
|
||||
git.snapshot(None, 1).unwrap().recent()[0].author_name(),
|
||||
"Android fixture"
|
||||
);
|
||||
assert_eq!(
|
||||
home(&private, &backend)
|
||||
.refresh_gesture()
|
||||
.unwrap()
|
||||
.freshness(),
|
||||
MobileHomeFreshness::Current
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_shares_the_rust_owner_and_rejects_dirty_editor_pull_and_push() {
|
||||
use crate::mobile_home::MobileHomeErrorKind;
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
let clock = Arc::new(crate::authentication::SystemAuthenticationClock::default());
|
||||
let authentication = private
|
||||
.authentication(backend.clone(), clock.clone())
|
||||
.unwrap();
|
||||
let same = private.authentication(backend.clone(), clock).unwrap();
|
||||
assert!(Arc::ptr_eq(&authentication, &same));
|
||||
let draft = authentication
|
||||
.begin_create_entry("", "draft", password())
|
||||
.unwrap();
|
||||
let operation = home(&private, &backend);
|
||||
assert_eq!(
|
||||
operation.pull().unwrap_err().kind(),
|
||||
MobileHomeErrorKind::DirtyLocalChanges
|
||||
);
|
||||
assert_eq!(
|
||||
operation.push().unwrap_err().kind(),
|
||||
MobileHomeErrorKind::DirtyLocalChanges
|
||||
);
|
||||
assert!(authentication.entry_editor(draft.id()).unwrap().dirty());
|
||||
assert!(!private.root().join("vault/draft.gpg").exists());
|
||||
authentication.discard_entry_editor(draft.id()).unwrap();
|
||||
private
|
||||
.configuration()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.update_mobile_home_refresh(100)
|
||||
.unwrap();
|
||||
assert!(Arc::ptr_eq(
|
||||
&authentication,
|
||||
&private
|
||||
.authentication(
|
||||
backend.clone(),
|
||||
Arc::new(crate::authentication::SystemAuthenticationClock::default())
|
||||
)
|
||||
.unwrap()
|
||||
));
|
||||
authentication.manual_lock().unwrap();
|
||||
home(&private, &backend).cached().unwrap();
|
||||
assert!(!authentication.state().unwrap().unlocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_cancelled_commit_preserves_the_worktree_and_retry_is_explicit() {
|
||||
use crate::mobile_home::MobileHomeErrorKind;
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
let path = private.root().join("vault/retained.gpg");
|
||||
fs::write(&path, b"retained encrypted bytes").unwrap();
|
||||
let operation = home(&private, &backend);
|
||||
operation.cancel();
|
||||
assert_eq!(
|
||||
operation.commit("Cancelled commit").unwrap_err().kind(),
|
||||
MobileHomeErrorKind::Interrupted
|
||||
);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"retained encrypted bytes");
|
||||
let repo = Repository::open(private.root().join("vault")).unwrap();
|
||||
let git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap();
|
||||
assert!(!git.status().unwrap().is_clean());
|
||||
home(&private, &backend).commit("Explicit retry").unwrap();
|
||||
assert!(git.status().unwrap().is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_bounds_large_local_change_presentations_but_commits_every_object() {
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
for number in 0..200 {
|
||||
fs::write(
|
||||
private.root().join(format!("vault/item-{number}.gpg")),
|
||||
b"encrypted fixture",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let page = home(&private, &backend).cached().unwrap();
|
||||
assert_eq!(
|
||||
page.summaries()
|
||||
.iter()
|
||||
.filter(|row| row.id().starts_with("local:"))
|
||||
.count(),
|
||||
50
|
||||
);
|
||||
assert!(page.summaries().iter().any(|row| row.id() == "local-more"));
|
||||
home(&private, &backend)
|
||||
.commit("Commit all 200 encrypted objects")
|
||||
.unwrap();
|
||||
let repo = Repository::open(private.root().join("vault")).unwrap();
|
||||
assert!(
|
||||
GitRepository::open(&repo, GitIdentity::ironstorage())
|
||||
.unwrap()
|
||||
.status()
|
||||
.unwrap()
|
||||
.is_clean()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_home_unfetched_https_remote_keeps_local_status_and_network_actions_available() {
|
||||
use crate::mobile_home::{MobileHomeActionKind, MobileHomeErrorKind};
|
||||
let (_temp, private, backend) = home_fixture();
|
||||
let repo = Repository::open(private.root().join("vault")).unwrap();
|
||||
let mut git = GitRepository::open(&repo, GitIdentity::ironstorage()).unwrap();
|
||||
git.add_remote("origin", "https://example.test/team/passwords.git")
|
||||
.unwrap();
|
||||
let operation = home(&private, &backend);
|
||||
let page = operation.cached().unwrap();
|
||||
assert!(page.remote_configured());
|
||||
assert_eq!(
|
||||
page.summaries()[0]
|
||||
.actions()
|
||||
.iter()
|
||||
.map(|action| action.kind())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
MobileHomeActionKind::Fetch,
|
||||
MobileHomeActionKind::Pull,
|
||||
MobileHomeActionKind::Push
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
page.notice().unwrap().title(),
|
||||
"Tracked Branch Is Unavailable"
|
||||
);
|
||||
assert_eq!(
|
||||
operation.fetch().unwrap_err().kind(),
|
||||
MobileHomeErrorKind::Authentication
|
||||
);
|
||||
assert!(git.status().unwrap().is_clean());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ use crate::atomic_file::TempFile;
|
||||
use crate::{
|
||||
config::Config,
|
||||
mobile::MobileShell,
|
||||
mobile_authentication::{
|
||||
MobileAuthentication, MobileAuthenticationError, MobileAuthenticationErrorKind,
|
||||
},
|
||||
secret_store::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError},
|
||||
};
|
||||
use cap_std::{
|
||||
@@ -17,7 +20,7 @@ use std::{
|
||||
fmt, fs,
|
||||
io::{self, Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
sync::Mutex,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
@@ -136,6 +139,7 @@ pub struct PrivateStorage {
|
||||
data: Dir,
|
||||
cache: Dir,
|
||||
status: Mutex<PrivateStorageStatus>,
|
||||
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
|
||||
}
|
||||
|
||||
impl PrivateStorage {
|
||||
@@ -228,6 +232,7 @@ impl PrivateStorage {
|
||||
});
|
||||
let root = roots.no_backup.join("ironstorage");
|
||||
let mut storage = Self {
|
||||
authentication: Mutex::new(None),
|
||||
root,
|
||||
cache_path: roots.cache.join("ironstorage"),
|
||||
data,
|
||||
@@ -286,6 +291,39 @@ impl PrivateStorage {
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
/// One Rust owner coordinates leases, drafts and Git reservations for this runtime.
|
||||
pub fn authentication(
|
||||
&self,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
clock: Arc<dyn crate::authentication::AuthenticationClock>,
|
||||
) -> Result<Arc<MobileAuthentication>, MobileAuthenticationError> {
|
||||
let recovery = |error: PrivateStorageError| {
|
||||
MobileAuthenticationError::new(
|
||||
MobileAuthenticationErrorKind::Configuration,
|
||||
"Private Storage Recovery Required",
|
||||
error.detail(),
|
||||
)
|
||||
};
|
||||
self.verify_secure_storage(backend.as_ref())
|
||||
.map_err(recovery)?;
|
||||
let config = self
|
||||
.configuration()
|
||||
.map_err(recovery)?
|
||||
.ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?;
|
||||
let mut owner = self
|
||||
.authentication
|
||||
.lock()
|
||||
.map_err(|_| recovery(PrivateStorageError::Busy))?;
|
||||
if let Some(current) = owner.as_ref() {
|
||||
if current.matches_configuration(&config)? {
|
||||
return Ok(Arc::clone(current));
|
||||
}
|
||||
current.manual_lock()?;
|
||||
}
|
||||
let current = Arc::new(MobileAuthentication::with_platform(config, backend, clock)?);
|
||||
*owner = Some(Arc::clone(¤t));
|
||||
Ok(current)
|
||||
}
|
||||
/// Every existing default-path API now uses this explicit OS context, never HOME/cwd.
|
||||
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
||||
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
||||
|
||||
@@ -439,7 +439,11 @@ impl<'a> MobileTotpService<'a> {
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|(_, record)| record.is_totp)
|
||||
.filter_map(|(path, _)| EntryPath::parse(&path).ok())
|
||||
.filter_map(|(path, record)| {
|
||||
let path = EntryPath::parse(&path).ok()?;
|
||||
let ciphertext = self.repository.read_entry(&path).ok()?;
|
||||
(digest(ciphertext.as_bytes()) == record.ciphertext_hash).then_some(path)
|
||||
})
|
||||
.filter(|path| query.is_empty() || path.to_string().to_lowercase().contains(&query))
|
||||
.map(|path| cached_row(&path, shared.contains(&path)))
|
||||
.collect();
|
||||
|
||||
Reference in new issue
Block a user