Implement Android Home activity and HTTPS synchronization (#97)
Android ARM verification / rust (push) Failing after 46m50s
Android ARM verification / android (push) Failing after 8s
Dependency security audit / rustsec (push) Successful in 23s

This commit is contained in:
hugo committed 2026-10-09 14:45:57 +02:00
1 parent 8c9341f214
commit 5c933a99a3
29 files changed
+2008 -110

No files matched your search

+34 -1
View File
@@ -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(
+336 -74
View File
@@ -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()
);
}
+195 -2
View File
@@ -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());
}
}
+39 -1
View File
@@ -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(&current));
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) {
+5 -1
View File
@@ -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();