Share the platform-neutral Rust mobile contract (#91)
Dependency security audit / rustsec (push) Failing after 22s
Dependency security audit / rustsec (push) Failing after 22s
This commit is contained in:
1 parent
fcb36a0684
commit
180851087b
52 files changed
+8932
-4263
No files matched your search
@@ -55,6 +55,12 @@ pub trait AuthenticationClock: Send + Sync + 'static {
|
||||
fn now(&self) -> Duration;
|
||||
}
|
||||
|
||||
impl<T: AuthenticationClock + ?Sized> AuthenticationClock for Arc<T> {
|
||||
fn now(&self) -> Duration {
|
||||
(**self).now()
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-local monotonic clock used by production sessions.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SystemAuthenticationClock {
|
||||
|
||||
@@ -40,6 +40,8 @@ pub mod mobile_onboarding;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_passwords;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_platform;
|
||||
#[cfg(feature = "full")]
|
||||
pub mod mobile_totp;
|
||||
pub mod mobile_watch;
|
||||
#[cfg(feature = "full")]
|
||||
|
||||
@@ -5,6 +5,36 @@ use crate::{
|
||||
repository::{Repository, RepositoryError},
|
||||
};
|
||||
|
||||
/// Presentation intent. Native UIs choose their own assets and selected variants.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileIcon {
|
||||
Home,
|
||||
Key,
|
||||
Search,
|
||||
Timer,
|
||||
Settings,
|
||||
Warning,
|
||||
Branch,
|
||||
Success,
|
||||
Sync,
|
||||
Protected,
|
||||
Repository,
|
||||
History,
|
||||
Incoming,
|
||||
Outgoing,
|
||||
Recipients,
|
||||
Signature,
|
||||
File,
|
||||
Merge,
|
||||
Refresh,
|
||||
Folder,
|
||||
User,
|
||||
Email,
|
||||
Website,
|
||||
Text,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum MobileTab {
|
||||
#[default]
|
||||
@@ -34,23 +64,13 @@ impl MobileTab {
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn system_image(self) -> &'static str {
|
||||
pub const fn icon(self) -> MobileIcon {
|
||||
match self {
|
||||
Self::Home => "house",
|
||||
Self::Passwords => "key",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn selected_system_image(self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "house.fill",
|
||||
Self::Passwords => "key.fill",
|
||||
Self::Search => "magnifyingglass",
|
||||
Self::Totp => "timer",
|
||||
Self::Preferences => "gearshape.fill",
|
||||
Self::Home => MobileIcon::Home,
|
||||
Self::Passwords => MobileIcon::Key,
|
||||
Self::Search => MobileIcon::Search,
|
||||
Self::Totp => MobileIcon::Timer,
|
||||
Self::Preferences => MobileIcon::Settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +111,7 @@ pub enum MobileShellState {
|
||||
pub struct MobilePage {
|
||||
tab: MobileTab,
|
||||
title: String,
|
||||
system_image: String,
|
||||
selected_system_image: String,
|
||||
icon: MobileIcon,
|
||||
state: MobileShellState,
|
||||
state_title: String,
|
||||
state_detail: String,
|
||||
@@ -107,12 +126,8 @@ impl MobilePage {
|
||||
&self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
}
|
||||
|
||||
pub fn selected_system_image(&self) -> &str {
|
||||
&self.selected_system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn state(&self) -> MobileShellState {
|
||||
@@ -180,8 +195,7 @@ impl MobileShell {
|
||||
MobilePage {
|
||||
tab,
|
||||
title: tab.title().to_owned(),
|
||||
system_image: tab.system_image().to_owned(),
|
||||
selected_system_image: tab.selected_system_image().to_owned(),
|
||||
icon: tab.icon(),
|
||||
state,
|
||||
state_title,
|
||||
state_detail,
|
||||
@@ -268,8 +282,7 @@ mod tests {
|
||||
assert!(shell.pages().iter().all(|page| {
|
||||
page.state() == state
|
||||
&& !page.title().is_empty()
|
||||
&& !page.system_image().is_empty()
|
||||
&& !page.selected_system_image().is_empty()
|
||||
&& page.icon() == page.tab().icon()
|
||||
&& !page.state_title().is_empty()
|
||||
&& !page.state_detail().is_empty()
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
//! Shared mobile authentication state; Swift only supplies input and presents results.
|
||||
//! Shared mobile authentication state; native frontends supply input and present results.
|
||||
|
||||
use std::{collections::BTreeMap, error::Error, fmt, sync::Mutex, time::Duration};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
error::Error,
|
||||
fmt,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
authentication::{
|
||||
AuthenticationError, AuthenticationTimeout, NativeAuthenticationHandle,
|
||||
NativeAuthenticationSession,
|
||||
AuthenticationClock, AuthenticationError, AuthenticationHandle, AuthenticationSession,
|
||||
AuthenticationTimeout, SystemAuthenticationClock,
|
||||
},
|
||||
config::{Config, ConfigError, GitRemote, MobileAppearance},
|
||||
crypto::{CryptoError, KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
@@ -33,7 +39,8 @@ use crate::{
|
||||
DirectoryPath, EncryptedEntry, EntryPath, Repository, RepositoryError, SecretBytes,
|
||||
},
|
||||
secret_store::{
|
||||
NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStoreError,
|
||||
NativeSecretBackend, SecretCachePolicy, SecretProtectionPolicy, SecretStore,
|
||||
SecretStoreBackend, SecretStoreError,
|
||||
},
|
||||
write::{VaultWriter, WriteError},
|
||||
};
|
||||
@@ -279,7 +286,7 @@ impl MobileAuthenticationState {
|
||||
}
|
||||
|
||||
struct ActiveMobileLease {
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
key: KeyInfo,
|
||||
}
|
||||
|
||||
@@ -326,11 +333,17 @@ impl Drop for MobileRepositoryOperationGuard<'_> {
|
||||
}
|
||||
|
||||
/// One process-wide mobile authentication lease shared by every tab and viewer.
|
||||
type MobileAuthenticationSession =
|
||||
AuthenticationSession<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||
type MobileAuthenticationHandle =
|
||||
AuthenticationHandle<Arc<dyn SecretStoreBackend>, Arc<dyn AuthenticationClock>>;
|
||||
|
||||
pub struct MobileAuthentication {
|
||||
config: Config,
|
||||
repository: Repository,
|
||||
keys: KeyStore,
|
||||
session: NativeAuthenticationSession,
|
||||
session: MobileAuthenticationSession,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
status: Mutex<MobileAuthenticationStatus>,
|
||||
totp_discovery: Mutex<()>,
|
||||
}
|
||||
@@ -344,13 +357,28 @@ impl MobileAuthentication {
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
let backend = Arc::new(NativeSecretBackend::new().map_err(preference_secret_error)?);
|
||||
Self::with_platform(
|
||||
config,
|
||||
backend,
|
||||
Arc::new(SystemAuthenticationClock::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// OS mechanics are injected; configuration, protection, leases and storage stay here.
|
||||
pub fn with_platform(
|
||||
config: Config,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
clock: Arc<dyn AuthenticationClock>,
|
||||
) -> Result<Self, MobileAuthenticationError> {
|
||||
let repository = Repository::open(config.vault()).map_err(entry_error)?;
|
||||
let keys = KeyStore::load(config.key_material()).map_err(key_error)?;
|
||||
let session = NativeAuthenticationSession::system(
|
||||
let session = MobileAuthenticationSession::with_clock(
|
||||
Arc::clone(&backend),
|
||||
SecretProtectionPolicy::current_biometry_for_openpgp(),
|
||||
config.authentication_timeout(),
|
||||
)
|
||||
.map_err(MobileAuthenticationError::authentication)?;
|
||||
clock,
|
||||
);
|
||||
let watch_snapshot =
|
||||
WatchSnapshotSender::load(config.source().with_file_name("watch-snapshot.toml"));
|
||||
Ok(Self {
|
||||
@@ -371,6 +399,7 @@ impl MobileAuthentication {
|
||||
repository,
|
||||
keys,
|
||||
session,
|
||||
backend,
|
||||
totp_discovery: Mutex::new(()),
|
||||
})
|
||||
}
|
||||
@@ -599,7 +628,7 @@ impl MobileAuthentication {
|
||||
sync_configured: remote.is_some(),
|
||||
repository_title,
|
||||
repository_url: remote.map_or_else(
|
||||
|| "Stored and versioned on this iPhone".to_owned(),
|
||||
|| "Stored and versioned on this device".to_owned(),
|
||||
|remote| remote.url().to_string(),
|
||||
),
|
||||
server_title: remote
|
||||
@@ -621,7 +650,10 @@ impl MobileAuthentication {
|
||||
)
|
||||
},
|
||||
),
|
||||
application_account: remote.map(application_account).transpose()?.flatten(),
|
||||
application_account: remote
|
||||
.map(|remote| application_account(remote, Arc::clone(&self.backend)))
|
||||
.transpose()?
|
||||
.flatten(),
|
||||
default_key_title: key
|
||||
.user_ids()
|
||||
.first()
|
||||
@@ -674,7 +706,7 @@ impl MobileAuthentication {
|
||||
.find(|remote| remote.name().as_str() == "origin")
|
||||
.or_else(|| self.config.git_remotes().first())
|
||||
.ok_or_else(|| config_detail("No HTTPS Git remote is configured."))?;
|
||||
let store = preference_secret_store()?;
|
||||
let store = preference_secret_store(Arc::clone(&self.backend));
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let (server_id, application_id) = remote.https_credentials().ok_or_else(|| {
|
||||
config_detail("The configured Git remote does not use HTTPS credentials.")
|
||||
@@ -1276,6 +1308,26 @@ impl MobileAuthentication {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lifecycle(
|
||||
&self,
|
||||
event: crate::mobile_platform::MobileLifecycleEvent,
|
||||
) -> Result<MobileAuthenticationState, MobileAuthenticationError> {
|
||||
use crate::mobile_platform::MobileLifecycleEvent;
|
||||
match event {
|
||||
MobileLifecycleEvent::Foreground => {}
|
||||
MobileLifecycleEvent::Background | MobileLifecycleEvent::DeviceLocked => {
|
||||
self.manual_lock()?
|
||||
}
|
||||
MobileLifecycleEvent::UserActivity => {
|
||||
if self.state()?.unlocked() {
|
||||
self.touch_user_activity()?;
|
||||
}
|
||||
}
|
||||
MobileLifecycleEvent::Cancel => self.cancel()?,
|
||||
}
|
||||
self.state()
|
||||
}
|
||||
|
||||
pub fn cancel(&self) -> Result<(), MobileAuthenticationError> {
|
||||
self.session
|
||||
.cancel()
|
||||
@@ -1319,7 +1371,7 @@ impl MobileAuthentication {
|
||||
|
||||
fn set_active(
|
||||
&self,
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
key: KeyInfo,
|
||||
) -> Result<(), MobileAuthenticationError> {
|
||||
self.status()?.active = Some(ActiveMobileLease { handle, key });
|
||||
@@ -1529,12 +1581,12 @@ fn repository_operation_conflict(
|
||||
}
|
||||
|
||||
struct KeyOnlyProvider<'a> {
|
||||
handle: NativeAuthenticationHandle,
|
||||
handle: MobileAuthenticationHandle,
|
||||
fingerprint: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> KeyOnlyProvider<'a> {
|
||||
fn new(handle: NativeAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
||||
fn new(handle: MobileAuthenticationHandle, key: &'a KeyInfo) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
fingerprint: key.fingerprint().as_str(),
|
||||
@@ -1567,19 +1619,24 @@ fn config_detail(detail: &str) -> MobileAuthenticationError {
|
||||
)
|
||||
}
|
||||
|
||||
fn preference_secret_store() -> Result<NativeSecretStore, MobileAuthenticationError> {
|
||||
NativeSecretStore::system(
|
||||
fn preference_secret_store(
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
) -> SecretStore<Arc<dyn SecretStoreBackend>> {
|
||||
SecretStore::new(
|
||||
backend,
|
||||
SecretCachePolicy::Disabled,
|
||||
SecretProtectionPolicy::device_unlocked(),
|
||||
)
|
||||
.map_err(preference_secret_error)
|
||||
}
|
||||
|
||||
fn application_account(remote: &GitRemote) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
fn application_account(
|
||||
remote: &GitRemote,
|
||||
backend: Arc<dyn SecretStoreBackend>,
|
||||
) -> Result<Option<String>, MobileAuthenticationError> {
|
||||
let Some((server_id, application_id)) = remote.https_credentials() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let store = preference_secret_store()?;
|
||||
let store = preference_secret_store(backend);
|
||||
store.unlock().map_err(preference_secret_error)?;
|
||||
let result = store.https_git_credential_account(server_id, application_id);
|
||||
let lock_result = store.lock();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Storage-owned projection of lossless entry documents for native mobile viewers.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{collections::BTreeSet, error::Error, fmt, num::NonZeroUsize};
|
||||
|
||||
use crate::document::{
|
||||
@@ -22,7 +24,7 @@ pub enum MobileEntrySectionKind {
|
||||
pub struct MobileEntryField {
|
||||
id: u64,
|
||||
label: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
detail: Option<String>,
|
||||
@@ -42,8 +44,8 @@ impl MobileEntryField {
|
||||
&self.label
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
@@ -121,7 +123,7 @@ pub struct MobileEntryEditorField {
|
||||
kind: MobileEntryEditorFieldKind,
|
||||
name: Option<String>,
|
||||
label: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
value: Option<String>,
|
||||
masked_value: String,
|
||||
diagnostic: Option<String>,
|
||||
@@ -149,8 +151,8 @@ impl MobileEntryEditorField {
|
||||
&self.label
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
@@ -573,7 +575,7 @@ fn mobile_field(field: &EntryField) -> MobileEntryField {
|
||||
MobileEntryField {
|
||||
id: field.id().value(),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
icon: icon(metadata.kind()),
|
||||
value,
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
@@ -614,7 +616,7 @@ fn editor_field(index: usize, field: &EntryField) -> MobileEntryEditorField {
|
||||
kind,
|
||||
name: metadata.name().map(str::to_owned),
|
||||
label: label(field),
|
||||
system_image: system_image(metadata.kind()).to_owned(),
|
||||
icon: icon(metadata.kind()),
|
||||
value: String::from_utf8(field.value().to_vec()).ok(),
|
||||
masked_value: if metadata.sensitivity() == EntrySensitivity::Empty {
|
||||
"Empty"
|
||||
@@ -705,15 +707,15 @@ fn display_name(name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_image(kind: EntryFieldKind) -> &'static str {
|
||||
fn icon(kind: EntryFieldKind) -> MobileIcon {
|
||||
match kind {
|
||||
EntryFieldKind::Password => "key.fill",
|
||||
EntryFieldKind::Username => "person.fill",
|
||||
EntryFieldKind::Email => "envelope.fill",
|
||||
EntryFieldKind::Url => "globe",
|
||||
EntryFieldKind::OtpUri => "timer",
|
||||
EntryFieldKind::Field => "text.alignleft",
|
||||
EntryFieldKind::Note => "note.text",
|
||||
EntryFieldKind::Password => MobileIcon::Key,
|
||||
EntryFieldKind::Username => MobileIcon::User,
|
||||
EntryFieldKind::Email => MobileIcon::Email,
|
||||
EntryFieldKind::Url => MobileIcon::Website,
|
||||
EntryFieldKind::OtpUri => MobileIcon::Timer,
|
||||
EntryFieldKind::Field => MobileIcon::Text,
|
||||
EntryFieldKind::Note => MobileIcon::Note,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Storage-owned remote activity and pull-then-push refresh for iPhone Home.
|
||||
//! Storage-owned remote activity and pull-then-push refresh for mobile Home.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
@@ -62,7 +64,7 @@ pub enum MobileHomeActionKind {
|
||||
pub struct MobileHomeAction {
|
||||
kind: MobileHomeActionKind,
|
||||
title: &'static str,
|
||||
system_image: &'static str,
|
||||
icon: MobileIcon,
|
||||
}
|
||||
|
||||
impl MobileHomeAction {
|
||||
@@ -74,8 +76,8 @@ impl MobileHomeAction {
|
||||
self.title
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +96,7 @@ pub struct MobileHomeSummaryRow {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
}
|
||||
|
||||
@@ -111,8 +113,8 @@ impl MobileHomeSummaryRow {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn actions(&self) -> &[MobileHomeAction] {
|
||||
@@ -125,7 +127,7 @@ pub struct MobileHomeChange {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
kind: MobileHomeChangeKind,
|
||||
status: MobileHomeChangeStatus,
|
||||
}
|
||||
@@ -143,8 +145,8 @@ impl MobileHomeChange {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MobileHomeChangeKind {
|
||||
@@ -161,7 +163,7 @@ pub struct MobileHomeCommit {
|
||||
id: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
timestamp: i64,
|
||||
changes: Vec<MobileHomeChange>,
|
||||
actions: Vec<MobileHomeAction>,
|
||||
@@ -180,8 +182,8 @@ impl MobileHomeCommit {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn timestamp(&self) -> i64 {
|
||||
@@ -201,7 +203,7 @@ impl MobileHomeCommit {
|
||||
pub struct MobileHomeNotice {
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
}
|
||||
|
||||
impl MobileHomeNotice {
|
||||
@@ -213,8 +215,8 @@ impl MobileHomeNotice {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +617,7 @@ impl MobileHomeStorage {
|
||||
.to_owned(),
|
||||
detail: "The repository was updated, but the refresh time could not be cached."
|
||||
.to_owned(),
|
||||
system_image: "exclamationmark.triangle".to_owned(),
|
||||
icon: MobileIcon::Warning,
|
||||
})
|
||||
};
|
||||
self.page(MobileHomeFreshness::Current, Some(now), notice)
|
||||
@@ -938,7 +940,7 @@ fn page_from_divergence(
|
||||
id: "tracked-branch".to_owned(),
|
||||
title: format!("{}/{}", divergence.remote().name(), divergence.branch()),
|
||||
detail: "Tracked HTTPS branch".to_owned(),
|
||||
system_image: "point.3.connected.trianglepath.dotted".to_owned(),
|
||||
icon: MobileIcon::Branch,
|
||||
actions: vec![
|
||||
mobile_action(MobileHomeActionKind::Fetch),
|
||||
mobile_action(MobileHomeActionKind::Pull),
|
||||
@@ -949,10 +951,10 @@ fn page_from_divergence(
|
||||
id: "divergence".to_owned(),
|
||||
title: divergence_title,
|
||||
detail: divergence_detail,
|
||||
system_image: if ahead == 0 && behind == 0 {
|
||||
"checkmark.circle".to_owned()
|
||||
icon: if ahead == 0 && behind == 0 {
|
||||
MobileIcon::Success
|
||||
} else {
|
||||
"arrow.triangle.2.circlepath".to_owned()
|
||||
MobileIcon::Sync
|
||||
},
|
||||
actions: [
|
||||
(behind > 0).then(|| mobile_action(MobileHomeActionKind::Pull)),
|
||||
@@ -974,10 +976,10 @@ fn page_from_divergence(
|
||||
} else {
|
||||
"Commit or discard these changes before pulling".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
icon: if local_count == 0 {
|
||||
MobileIcon::Protected
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
MobileIcon::Warning
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
@@ -1040,7 +1042,7 @@ fn page_from_local_snapshot(
|
||||
id: "local-branch".to_owned(),
|
||||
title: snapshot.branch().to_owned(),
|
||||
detail: "Local Git branch · no sync remote configured".to_owned(),
|
||||
system_image: "externaldrive.fill".to_owned(),
|
||||
icon: MobileIcon::Repository,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
MobileHomeSummaryRow {
|
||||
@@ -1051,7 +1053,7 @@ fn page_from_local_snapshot(
|
||||
if commit_count == 1 { "" } else { "s" }
|
||||
),
|
||||
detail: latest.unwrap_or_else(|| "No local history yet".to_owned()),
|
||||
system_image: "clock.arrow.circlepath".to_owned(),
|
||||
icon: MobileIcon::History,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
working_tree_summary(local_paths.len()),
|
||||
@@ -1077,10 +1079,10 @@ fn working_tree_summary(local_count: usize) -> MobileHomeSummaryRow {
|
||||
} else {
|
||||
"Commit these local password-store changes".to_owned()
|
||||
},
|
||||
system_image: if local_count == 0 {
|
||||
"checkmark.shield".to_owned()
|
||||
icon: if local_count == 0 {
|
||||
MobileIcon::Protected
|
||||
} else {
|
||||
"exclamationmark.triangle".to_owned()
|
||||
MobileIcon::Warning
|
||||
},
|
||||
actions: if local_count > 0 {
|
||||
vec![mobile_action(MobileHomeActionKind::Commit)]
|
||||
@@ -1124,10 +1126,10 @@ fn mobile_commit(activity: &GitCommitActivity, incoming: bool) -> MobileHomeComm
|
||||
},
|
||||
change_count(changes.len())
|
||||
),
|
||||
system_image: if incoming {
|
||||
"arrow.down.circle".to_owned()
|
||||
icon: if incoming {
|
||||
MobileIcon::Incoming
|
||||
} else {
|
||||
"arrow.up.circle".to_owned()
|
||||
MobileIcon::Outgoing
|
||||
},
|
||||
timestamp: commit.timestamp(),
|
||||
changes,
|
||||
@@ -1147,33 +1149,33 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
||||
MobileHomeChangeStatus::Deleted => "Deleted",
|
||||
};
|
||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||
let (title, detail, system_image, change_kind) =
|
||||
let (title, detail, icon, change_kind) =
|
||||
if path.extension().is_some_and(|extension| extension == "gpg") {
|
||||
(
|
||||
display_path(&path.with_extension("")),
|
||||
format!("{status_name} password entry"),
|
||||
"key".to_owned(),
|
||||
MobileIcon::Key,
|
||||
MobileHomeChangeKind::PasswordEntry,
|
||||
)
|
||||
} else if file_name == Some(".gpg-id") {
|
||||
(
|
||||
policy_title(path, "recipients"),
|
||||
format!("{status_name} recipient policy"),
|
||||
"person.2".to_owned(),
|
||||
MobileIcon::Recipients,
|
||||
MobileHomeChangeKind::RecipientPolicy,
|
||||
)
|
||||
} else if file_name == Some(".gpg-id.sig") {
|
||||
(
|
||||
policy_title(path, "recipient signature"),
|
||||
format!("{status_name} recipient signature"),
|
||||
"signature".to_owned(),
|
||||
MobileIcon::Signature,
|
||||
MobileHomeChangeKind::RecipientSignature,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
display_path(path),
|
||||
format!("{status_name} repository file"),
|
||||
"doc".to_owned(),
|
||||
MobileIcon::File,
|
||||
MobileHomeChangeKind::RepositoryFile,
|
||||
)
|
||||
};
|
||||
@@ -1181,7 +1183,7 @@ fn mobile_change(id: String, path: &Path, kind: GitChangeKind) -> MobileHomeChan
|
||||
id,
|
||||
title,
|
||||
detail,
|
||||
system_image,
|
||||
icon,
|
||||
kind: change_kind,
|
||||
status,
|
||||
}
|
||||
@@ -1228,27 +1230,27 @@ fn change_count(count: usize) -> String {
|
||||
}
|
||||
|
||||
fn pull_notice(outcome: PullOutcome) -> MobileHomeNotice {
|
||||
let (title, detail, system_image) = match outcome {
|
||||
let (title, detail, icon) = match outcome {
|
||||
PullOutcome::UpToDate => (
|
||||
"Already Up to Date",
|
||||
"The local password store already contains every fetched commit.",
|
||||
"checkmark.circle",
|
||||
MobileIcon::Success,
|
||||
),
|
||||
PullOutcome::FastForward => (
|
||||
"Password Store Updated",
|
||||
"Remote commits were applied without changing local history.",
|
||||
"arrow.down.circle",
|
||||
MobileIcon::Incoming,
|
||||
),
|
||||
PullOutcome::Merged => (
|
||||
"Remote Changes Merged",
|
||||
"Remote and local commits were merged by storage.",
|
||||
"arrow.triangle.merge",
|
||||
MobileIcon::Merge,
|
||||
),
|
||||
};
|
||||
MobileHomeNotice {
|
||||
title: title.to_owned(),
|
||||
detail: detail.to_owned(),
|
||||
system_image: system_image.to_owned(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1256,7 +1258,7 @@ fn commit_notice(commit: &str) -> MobileHomeNotice {
|
||||
MobileHomeNotice {
|
||||
title: "Changes Committed".to_owned(),
|
||||
detail: format!("Created commit {}.", &commit[..commit.len().min(12)]),
|
||||
system_image: "checkmark.circle".to_owned(),
|
||||
icon: MobileIcon::Success,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,7 +1278,7 @@ fn fetch_notice(outcome: &FetchOutcome) -> MobileHomeNotice {
|
||||
} else {
|
||||
format!("{} has no new objects for this clone.", outcome.remote())
|
||||
},
|
||||
system_image: "arrow.clockwise.circle".to_owned(),
|
||||
icon: MobileIcon::Refresh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,22 +1304,18 @@ fn push_notice(outcome: &PushOutcome) -> MobileHomeNotice {
|
||||
outcome.branch()
|
||||
)
|
||||
},
|
||||
system_image: "arrow.up.circle".to_owned(),
|
||||
icon: MobileIcon::Outgoing,
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_action(kind: MobileHomeActionKind) -> MobileHomeAction {
|
||||
let (title, system_image) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", "checkmark.circle"),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", "arrow.clockwise"),
|
||||
MobileHomeActionKind::Pull => ("Pull", "arrow.down.circle"),
|
||||
MobileHomeActionKind::Push => ("Push", "arrow.up.circle"),
|
||||
let (title, icon) = match kind {
|
||||
MobileHomeActionKind::Commit => ("Commit", MobileIcon::Success),
|
||||
MobileHomeActionKind::Fetch => ("Fetch", MobileIcon::Refresh),
|
||||
MobileHomeActionKind::Pull => ("Pull", MobileIcon::Incoming),
|
||||
MobileHomeActionKind::Push => ("Push", MobileIcon::Outgoing),
|
||||
};
|
||||
MobileHomeAction {
|
||||
kind,
|
||||
title,
|
||||
system_image,
|
||||
}
|
||||
MobileHomeAction { kind, title, icon }
|
||||
}
|
||||
|
||||
fn repository_operation(action: MobileHomeActionKind) -> MobileRepositoryOperation {
|
||||
@@ -1393,7 +1391,10 @@ fn is_stale(refreshed_at: Option<i64>, now: i64) -> bool {
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use crate::git::{GitChangeKind, GitError};
|
||||
use crate::{
|
||||
git::{GitChangeKind, GitError},
|
||||
mobile::MobileIcon,
|
||||
};
|
||||
|
||||
use super::{
|
||||
MobileHomeActionKind, MobileHomeChangeKind, MobileHomeChangeStatus, MobileHomeErrorKind,
|
||||
@@ -1446,7 +1447,7 @@ mod tests {
|
||||
fn actions_and_progress_are_typed_before_swift_presentation() {
|
||||
let push = mobile_action(MobileHomeActionKind::Push);
|
||||
assert_eq!(push.title(), "Push");
|
||||
assert_eq!(push.system_image(), "arrow.up.circle");
|
||||
assert_eq!(push.icon(), MobileIcon::Outgoing);
|
||||
let progress = progress_copy(Some(MobileHomeActionKind::Push), MobileHomePhase::Sending);
|
||||
assert_eq!(progress.action(), Some(MobileHomeActionKind::Push));
|
||||
assert_eq!(progress.title(), "Pushing Changes");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Storage-owned first-run setup for the native iPhone application.
|
||||
//! Storage-owned first-run setup for the native mobile applications.
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
@@ -363,7 +363,7 @@ impl MobileOnboardingOperation {
|
||||
result?;
|
||||
Ok(MobileOnboardingOutcome {
|
||||
title: "Local Password Store Ready".to_owned(),
|
||||
detail: "A local Git repository and protected GPG key are ready on this iPhone."
|
||||
detail: "A local Git repository and protected GPG key are ready on this device."
|
||||
.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Storage-owned, locked password navigation for native mobile frontends.
|
||||
|
||||
use crate::mobile::MobileIcon;
|
||||
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::{
|
||||
@@ -20,7 +22,7 @@ pub struct MobilePasswordRow {
|
||||
path: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
system_image: String,
|
||||
icon: MobileIcon,
|
||||
kind: MobilePasswordRowKind,
|
||||
}
|
||||
|
||||
@@ -41,8 +43,8 @@ impl MobilePasswordRow {
|
||||
&self.detail
|
||||
}
|
||||
|
||||
pub fn system_image(&self) -> &str {
|
||||
&self.system_image
|
||||
pub fn icon(&self) -> MobileIcon {
|
||||
self.icon
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> MobilePasswordRowKind {
|
||||
@@ -168,25 +170,25 @@ fn search_row(path: &crate::repository::EntryPath, text: &str) -> MobilePassword
|
||||
path: text.to_owned(),
|
||||
title,
|
||||
detail,
|
||||
system_image: "key.fill".to_owned(),
|
||||
icon: MobileIcon::Key,
|
||||
kind: MobilePasswordRowKind::Entry,
|
||||
}
|
||||
}
|
||||
|
||||
fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
let path = node.path().to_owned();
|
||||
let (kind, prefix, detail, system_image) = match node.kind() {
|
||||
let (kind, prefix, detail, icon) = match node.kind() {
|
||||
TreeNodeKind::Directory => (
|
||||
MobilePasswordRowKind::Directory,
|
||||
"directory",
|
||||
entry_count_detail(node),
|
||||
"folder",
|
||||
MobileIcon::Folder,
|
||||
),
|
||||
TreeNodeKind::Entry => (
|
||||
MobilePasswordRowKind::Entry,
|
||||
"entry",
|
||||
"Locked password".to_owned(),
|
||||
"key.fill",
|
||||
MobileIcon::Key,
|
||||
),
|
||||
};
|
||||
MobilePasswordRow {
|
||||
@@ -194,7 +196,7 @@ fn mobile_row(node: &TreeNode) -> MobilePasswordRow {
|
||||
path,
|
||||
title: node.name().to_owned(),
|
||||
detail,
|
||||
system_image: system_image.to_owned(),
|
||||
icon,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! OS facts accepted by mobile frontends. Storage validates and interprets them.
|
||||
|
||||
use std::{
|
||||
error::Error,
|
||||
fmt,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
presentation::{QrError, QrMatrix},
|
||||
repository::SecretBytes,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobilePlatformError {
|
||||
InvalidPaths,
|
||||
InvalidCredentialRequest,
|
||||
}
|
||||
|
||||
impl fmt::Display for MobilePlatformError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InvalidPaths => "invalid mobile platform paths",
|
||||
Self::InvalidCredentialRequest => "invalid credential-provider request",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for MobilePlatformError {}
|
||||
|
||||
/// The OS supplies private roots; the repository layout is selected here.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct MobilePlatformPaths {
|
||||
data: PathBuf,
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl MobilePlatformPaths {
|
||||
pub fn new(data: PathBuf, cache: PathBuf) -> Result<Self, MobilePlatformError> {
|
||||
for path in [&data, &cache] {
|
||||
if !path.is_absolute()
|
||||
|| path.parent().is_none()
|
||||
|| path.components().any(|component| {
|
||||
!matches!(component, Component::RootDir | Component::Normal(_))
|
||||
})
|
||||
|| path
|
||||
.to_str()
|
||||
.is_none_or(|value| value.len() > 4096 || value.contains('\0'))
|
||||
{
|
||||
return Err(MobilePlatformError::InvalidPaths);
|
||||
}
|
||||
}
|
||||
if data.starts_with(&cache) || cache.starts_with(&data) {
|
||||
return Err(MobilePlatformError::InvalidPaths);
|
||||
}
|
||||
Ok(Self { data, cache })
|
||||
}
|
||||
|
||||
pub fn data(&self) -> &Path {
|
||||
&self.data
|
||||
}
|
||||
pub fn cache(&self) -> &Path {
|
||||
&self.cache
|
||||
}
|
||||
pub fn configuration(&self) -> PathBuf {
|
||||
self.data.join("config.toml")
|
||||
}
|
||||
pub fn vault(&self) -> PathBuf {
|
||||
self.data.join("vault")
|
||||
}
|
||||
pub fn keys(&self) -> PathBuf {
|
||||
self.data.join("keys")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobilePlatformPaths {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobilePlatformPaths([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// A Y plane, with OS-provided row/pixel strides. No camera or QR policy lives in the UI.
|
||||
pub struct MobileCameraFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub row_stride: u32,
|
||||
pub pixel_stride: u32,
|
||||
pub luminance: SecretBytes,
|
||||
}
|
||||
|
||||
impl MobileCameraFrame {
|
||||
pub fn decode(&self) -> Result<SecretBytes, QrError> {
|
||||
let width = self.width as usize;
|
||||
let height = self.height as usize;
|
||||
let row = self.row_stride as usize;
|
||||
let pixel = self.pixel_stride as usize;
|
||||
let bytes = self.luminance.expose();
|
||||
if !(1..=4096).contains(&width)
|
||||
|| !(1..=4096).contains(&height)
|
||||
|| !(1..=4).contains(&pixel)
|
||||
|| bytes.len() > 32 * 1024 * 1024
|
||||
{
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
let last_column = (width - 1)
|
||||
.checked_mul(pixel)
|
||||
.ok_or(QrError::InvalidImage)?;
|
||||
let last = (height - 1)
|
||||
.checked_mul(row)
|
||||
.and_then(|offset| offset.checked_add(last_column))
|
||||
.ok_or(QrError::InvalidImage)?;
|
||||
if row <= last_column || last >= bytes.len() {
|
||||
return Err(QrError::InvalidImage);
|
||||
}
|
||||
QrMatrix::decode_luminance(width, height, |x, y| bytes[y * row + x * pixel])
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileCameraFrame {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobileCameraFrame([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MobileLifecycleEvent {
|
||||
Foreground,
|
||||
Background,
|
||||
DeviceLocked,
|
||||
UserActivity,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// A caller identity is an OS assertion, never an authorization grant.
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct MobileCredentialRequest {
|
||||
request_id: String,
|
||||
package: String,
|
||||
signing_certificate_sha256: String,
|
||||
origins: Vec<String>,
|
||||
}
|
||||
|
||||
impl MobileCredentialRequest {
|
||||
pub fn new(
|
||||
request_id: String,
|
||||
package: String,
|
||||
certificate: String,
|
||||
origins: Vec<String>,
|
||||
) -> Result<Self, MobilePlatformError> {
|
||||
let invalid = MobilePlatformError::InvalidCredentialRequest;
|
||||
if request_id.is_empty()
|
||||
|| request_id.len() > 256
|
||||
|| request_id.chars().any(char::is_control)
|
||||
|| package.len() > 255
|
||||
|| !package.contains('.')
|
||||
|| package.split('.').any(|part| {
|
||||
part.is_empty()
|
||||
|| !part.bytes().enumerate().all(|(i, ch)| {
|
||||
ch == b'_' || ch.is_ascii_alphabetic() || (i > 0 && ch.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
|| certificate.len() != 64
|
||||
|| !certificate.bytes().all(|ch| ch.is_ascii_hexdigit())
|
||||
|| origins.len() > 64
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let mut validated = Vec::new();
|
||||
for origin in origins {
|
||||
if origin.len() > 4096
|
||||
|| origin.chars().any(char::is_whitespace)
|
||||
|| origin.chars().any(char::is_control)
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let url = url::Url::parse(&origin).map_err(|_| invalid)?;
|
||||
if !matches!(url.scheme(), "https" | "http")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.path() != "/"
|
||||
{
|
||||
return Err(invalid);
|
||||
}
|
||||
let origin = url.origin().ascii_serialization();
|
||||
if !validated.contains(&origin) {
|
||||
validated.push(origin);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
request_id,
|
||||
package,
|
||||
signing_certificate_sha256: certificate.to_ascii_lowercase(),
|
||||
origins: validated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn request_id(&self) -> &str {
|
||||
&self.request_id
|
||||
}
|
||||
pub fn package(&self) -> &str {
|
||||
&self.package
|
||||
}
|
||||
pub fn signing_certificate_sha256(&self) -> &str {
|
||||
&self.signing_certificate_sha256
|
||||
}
|
||||
pub fn origins(&self) -> &[String] {
|
||||
&self.origins
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for MobileCredentialRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("MobileCredentialRequest([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_and_caller_facts_are_validated_without_creating_data_or_grants() {
|
||||
let paths =
|
||||
MobilePlatformPaths::new("/private/app/no-backup".into(), "/private/app/cache".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
paths.configuration(),
|
||||
Path::new("/private/app/no-backup/config.toml")
|
||||
);
|
||||
for (data, cache) in [
|
||||
("relative", "/cache"),
|
||||
("/app/../other", "/cache"),
|
||||
("/app", "/app/cache"),
|
||||
("/", "/cache"),
|
||||
] {
|
||||
assert!(MobilePlatformPaths::new(data.into(), cache.into()).is_err());
|
||||
}
|
||||
let request = MobileCredentialRequest::new(
|
||||
"opaque-id".into(),
|
||||
"de.example.app".into(),
|
||||
"A".repeat(64),
|
||||
vec!["https://EXAMPLE.test:443/".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(request.origins(), ["https://example.test"]);
|
||||
for origin in [
|
||||
"https://user:secret@example.test",
|
||||
"file:///tmp",
|
||||
"https://example.test/path",
|
||||
"https://example.test/?token=secret",
|
||||
] {
|
||||
assert!(
|
||||
MobileCredentialRequest::new(
|
||||
"id".into(),
|
||||
"de.example.app".into(),
|
||||
"A".repeat(64),
|
||||
vec![origin.into()]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(!format!("{request:?}").contains("example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strided_camera_frame_decodes_in_rust_and_rejects_truncated_or_oversized_planes() {
|
||||
let secret = SecretBytes::new(b"otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP".to_vec());
|
||||
let qr = QrMatrix::encode(&secret).unwrap();
|
||||
let side = (qr.width() + 8) * 8;
|
||||
let row = side * 2 + 7;
|
||||
let mut plane = vec![255; side * row];
|
||||
for y in 0..side {
|
||||
for x in 0..side {
|
||||
if x / 8 >= 4 && y / 8 >= 4 && qr.is_dark(x / 8 - 4, y / 8 - 4) == Some(true) {
|
||||
plane[y * row + x * 2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut frame = MobileCameraFrame {
|
||||
width: side as u32,
|
||||
height: side as u32,
|
||||
row_stride: row as u32,
|
||||
pixel_stride: 2,
|
||||
luminance: SecretBytes::new(plane),
|
||||
};
|
||||
assert_eq!(frame.decode().unwrap().expose(), secret.expose());
|
||||
frame.row_stride = 1;
|
||||
assert!(frame.decode().is_err());
|
||||
frame.width = 4097;
|
||||
assert!(frame.decode().is_err());
|
||||
}
|
||||
}
|
||||
@@ -257,11 +257,19 @@ impl QrMatrix {
|
||||
.decode()
|
||||
.map_err(|_| QrError::InvalidImage)?
|
||||
.into_luma8();
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
|
||||
Self::decode_luminance(
|
||||
grayscale.width() as usize,
|
||||
grayscale.height() as usize,
|
||||
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_luminance(
|
||||
width: usize,
|
||||
height: usize,
|
||||
pixel: impl Fn(usize, usize) -> u8,
|
||||
) -> Result<SecretBytes, QrError> {
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(width, height, pixel);
|
||||
let grid = prepared
|
||||
.detect_grids()
|
||||
.into_iter()
|
||||
|
||||
@@ -369,6 +369,51 @@ pub trait SecretStoreBackend: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
|
||||
fn create(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).create(locator, protection, value)
|
||||
}
|
||||
fn retrieve(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<SecretBytes, SecretStoreError> {
|
||||
(**self).retrieve(locator, protection)
|
||||
}
|
||||
fn replace(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
value: &[u8],
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).replace(locator, protection, value)
|
||||
}
|
||||
fn delete(
|
||||
&self,
|
||||
locator: &SecretLocator,
|
||||
protection: SecretProtection,
|
||||
) -> Result<(), SecretStoreError> {
|
||||
(**self).delete(locator, protection)
|
||||
}
|
||||
fn platform_https_git_credential(
|
||||
&self,
|
||||
endpoint: &url::Url,
|
||||
) -> Result<Option<(String, SecretBytes)>, SecretStoreError> {
|
||||
(**self).platform_https_git_credential(endpoint)
|
||||
}
|
||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||
(**self).lock()
|
||||
}
|
||||
fn unlock(&self) -> Result<(), SecretStoreError> {
|
||||
(**self).unlock()
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedSecret {
|
||||
value: SecretBytes,
|
||||
expires_at: Instant,
|
||||
|
||||
@@ -17,9 +17,11 @@ fn apple_sources_preserve_the_mobile_security_boundary() {
|
||||
let watch = workspace_file("apple/Sources/Watch/IronStorageWatchApp.swift");
|
||||
let autofill = workspace_file("apple/Sources/AutoFill/CredentialProviderViewController.swift");
|
||||
let bridges = format!(
|
||||
"{}\n{}",
|
||||
"{}\n{}\n{}\n{}",
|
||||
workspace_file("crates/apple/src/lib.rs"),
|
||||
workspace_file("crates/watch-apple/src/lib.rs")
|
||||
workspace_file("crates/watch-apple/src/lib.rs"),
|
||||
workspace_file("crates/mobile-bridge/src/lib.rs"),
|
||||
workspace_file("crates/mobile-bridge/src/platform.rs")
|
||||
);
|
||||
|
||||
for (name, source) in [
|
||||
|
||||
@@ -26,6 +26,74 @@ use support::compatibility::FixtureSet;
|
||||
|
||||
type TestResult<T = ()> = Result<T, Box<dyn Error>>;
|
||||
|
||||
#[test]
|
||||
fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_configuration()
|
||||
-> TestResult {
|
||||
use ironstorage::{
|
||||
config::Config, mobile_authentication::MobileAuthentication,
|
||||
mobile_platform::MobileLifecycleEvent,
|
||||
};
|
||||
let fixture = FixtureSet::load()?;
|
||||
let vault = fixture.materialize_store("basic")?;
|
||||
let root = tempfile::tempdir()?;
|
||||
let source = root.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&source,
|
||||
format!(
|
||||
"vault = {:?}\nkey_material = {:?}\ndefault_key = {:?}\n[[git.remotes]]\nname = \"origin\"\nurl = \"https://example.test/team/passwords.git\"\nserver_id = \"server-example\"\napplication_id = \"repository-example\"\n",
|
||||
vault.path().to_str().expect("fixture UTF8"),
|
||||
fixture.path("keys").to_str().expect("fixture UTF8"),
|
||||
fixture.key("alice")?.primary_fingerprint,
|
||||
),
|
||||
)?;
|
||||
let backend = MemoryBackend::default();
|
||||
let clock = ManualClock::default();
|
||||
let authentication = MobileAuthentication::with_platform(
|
||||
Config::load(Some(&source))?,
|
||||
Arc::new(backend.clone()),
|
||||
Arc::new(clock.clone()),
|
||||
)?;
|
||||
assert!(
|
||||
!authentication
|
||||
.lifecycle(MobileLifecycleEvent::Foreground)?
|
||||
.unlocked()
|
||||
);
|
||||
assert_eq!(
|
||||
authentication
|
||||
.preferences(false, false, false)?
|
||||
.application_account(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
backend.retrieves(),
|
||||
1,
|
||||
"preferences must use the injected backend"
|
||||
);
|
||||
authentication.set_authentication_timeout(30)?;
|
||||
authentication.unlock_entry(
|
||||
"email/personal",
|
||||
Some(SecretBytes::new(
|
||||
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||
)),
|
||||
)?;
|
||||
assert!(authentication.state()?.unlocked());
|
||||
clock.advance(Duration::from_secs(20));
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 10);
|
||||
authentication.lifecycle(MobileLifecycleEvent::UserActivity)?;
|
||||
assert_eq!(authentication.state()?.remaining_seconds(), 30);
|
||||
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||
authentication.lifecycle(MobileLifecycleEvent::Background)?;
|
||||
assert!(!authentication.state()?.unlocked());
|
||||
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||
let baseline_locks = backend.locks();
|
||||
drop(authentication);
|
||||
assert!(
|
||||
backend.locks() > baseline_locks,
|
||||
"final owner locks the OS backend"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BackendState {
|
||||
values: BTreeMap<SecretLocator, SecretBytes>,
|
||||
|
||||
@@ -183,7 +183,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
assert_eq!(mobile_custom[1].value(), Some(""));
|
||||
let mobile_otp = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "timer")
|
||||
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Timer)
|
||||
.expect("OTP field");
|
||||
assert!(mobile_otp.sensitive());
|
||||
assert_eq!(mobile_otp.value(), None);
|
||||
@@ -192,7 +192,7 @@ fn complex_documents_round_trip_with_storage_owned_metadata() -> TestResult {
|
||||
}));
|
||||
let mobile_note = mobile_fields
|
||||
.iter()
|
||||
.find(|field| field.system_image() == "note.text")
|
||||
.find(|field| field.icon() == ironstorage::mobile::MobileIcon::Note)
|
||||
.expect("multiline note");
|
||||
assert!(!mobile_note.sensitive());
|
||||
assert!(mobile_note.multiline());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const WORKSPACE: &str = include_str!("../../../Cargo.toml");
|
||||
const STORAGE_MANIFEST: &str = include_str!("../Cargo.toml");
|
||||
const APPLE_MANIFEST: &str = include_str!("../../apple/Cargo.toml");
|
||||
const ANDROID_MANIFEST: &str = include_str!("../../android/Cargo.toml");
|
||||
const MOBILE_MANIFEST: &str = include_str!("../../mobile-bridge/Cargo.toml");
|
||||
const CLI_MANIFEST: &str = include_str!("../../../apps/cli/Cargo.toml");
|
||||
const TUI_MANIFEST: &str = include_str!("../../../apps/tui/Cargo.toml");
|
||||
const DESKTOP_MANIFEST: &str = include_str!("../../../apps/desktop/Cargo.toml");
|
||||
@@ -18,8 +20,16 @@ fn release_feature_and_advisory_boundaries_stay_explicit() {
|
||||
for manifest in [CLI_MANIFEST, TUI_MANIFEST, DESKTOP_MANIFEST] {
|
||||
assert!(manifest.contains("features = [\"ssh\"]"));
|
||||
}
|
||||
assert!(APPLE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
||||
assert!(!APPLE_MANIFEST.contains("features = [\"ssh\"]"));
|
||||
assert!(MOBILE_MANIFEST.contains("default-features = false, features = [\"full\"]"));
|
||||
assert!(
|
||||
APPLE_MANIFEST.contains(
|
||||
"ironstorage-mobile = { path = \"../mobile-bridge\", features = [\"watch\"] }"
|
||||
)
|
||||
);
|
||||
assert!(ANDROID_MANIFEST.contains("ironstorage-mobile = { path = \"../mobile-bridge\" }"));
|
||||
for manifest in [APPLE_MANIFEST, ANDROID_MANIFEST, MOBILE_MANIFEST] {
|
||||
assert!(!manifest.contains("features = [\"ssh\"]"));
|
||||
}
|
||||
assert!(AUDIT_WORKFLOW.contains("tool: rust@1.92.0,cargo-audit@0.22.2"));
|
||||
assert!(AUDIT_WORKFLOW.contains("fallback: none"));
|
||||
assert!(!AUDIT_WORKFLOW.contains("cargo install"));
|
||||
|
||||
Reference in new issue
Block a user