diff --git a/docs/identity.md b/docs/identity.md index 3a3680f96a..6a7308bbd1 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -102,12 +102,23 @@ events retry them, including a coalesced follow-up when an event arrives during active pass. Upload success requires a matching stored receipt. Backup failure never replaces or deletes the local identity. +The main WebView can observe the uploader through `identity.backup`, which +registers on first read and stays out of identity snapshots. Its status says +whether Builderlab backup can run in this build and whether the latest pass is +idle, running, complete, waiting for consent, a session or a key binding, or +failed with a named kind; no service response body reaches the renderer. A login +handoff or retry resolves to the number of the pass it starts, or to none, so a +caller can wait for that pass's own outcome. A pass superseded by a key install +reports nothing; reloading the window mid-pass reports idle. + When secure storage confirms absence, native recovery commands support explicit browser sign-in. The session and recovered keys remain native; the renderer sees only validated public identities and attempt-scoped handles. Recovery prefers the account's current associated identity. A previous identity requires explicit selection and does not change the account association. Failed mapping requests, -unavailable endpoints, failed backups and an empty successful result remain distinct. +unavailable endpoints, an account refused recovery (outside a private +organization, or without Buzz access), failed backups and an empty successful +result remain distinct. Installation uses the existing create-only secure store; cancel, reload, signout or a competing create/import prevents stale installation. Remembering recovered completion is best effort; retry-file failure does not block diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 89c54b79c0..8aeaef62ce 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -72,6 +72,8 @@ fn main() { "nsec_backup_available", "nsec_backup_session", "nsec_backup_retry", + "nsec_backup_state", + "nsec_backup_watch", "nsec_restore_begin", "nsec_restore_wait", "nsec_restore_install", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 35b56db814..a52f685161 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -25,6 +25,8 @@ "allow-nsec-backup-available", "allow-nsec-backup-session", "allow-nsec-backup-retry", + "allow-nsec-backup-state", + "allow-nsec-backup-watch", "allow-nsec-restore-begin", "allow-nsec-restore-wait", "allow-nsec-restore-install", diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index e7be18869e..d861b26372 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -81,6 +81,8 @@ fn native_command_permissions_isolate_webviews() { "nsec_backup_available", "nsec_backup_session", "nsec_backup_retry", + "nsec_backup_state", + "nsec_backup_watch", "nsec_restore_begin", "nsec_restore_wait", "nsec_restore_install", diff --git a/src-tauri/src/identity.rs b/src-tauri/src/identity.rs index 3bb9706f14..28d385e375 100644 --- a/src-tauri/src/identity.rs +++ b/src-tauri/src/identity.rs @@ -719,19 +719,33 @@ pub struct IdentityHost(Arc>); impl IdentityHost { #[cfg(test)] pub(crate) fn fixture() -> Self { + Self::fixture_in(State::Ready(Key(Zeroizing::new([1; 32])))) + } + /// No saved key, or a key that cannot be read (a signing-out host refuses every read). + #[cfg(test)] + pub(crate) fn fixture_without_key(readable: bool) -> Self { + Self::fixture_in(if readable { + State::Missing + } else { + State::Closed + }) + } + #[cfg(test)] + fn fixture_in(state: State) -> Self { Self(Arc::new(Mutex::new(Identity { - state: State::Ready(Key(Zeroizing::new([1; 32]))), + state, store: Box::new(OsStore), }))) } + /// The saved key's public key, or `None` when secure storage confirms none. + pub(crate) async fn saved_viewer(&self) -> Result> { + with_identity(self.clone(), Identity::restore).await + } pub(crate) async fn viewer(&self) -> Result { - with_identity(self.clone(), |identity| { - identity - .restore()? - .ok_or_else(|| "Set up your identity first".into()) - }) - .await + self.saved_viewer() + .await? + .ok_or_else(|| "Set up your identity first".into()) } pub(crate) async fn require_missing(&self) -> Result<()> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae40edb003..1b3a8b4bdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,8 +45,9 @@ mod nsec_backup; mod nsec_backup_choice; mod nsec_backup_config; use nsec_backup::{ - nsec_backup_available, nsec_backup_retry, nsec_backup_session, nsec_restore_begin, - nsec_restore_cancel, nsec_restore_install, nsec_restore_wait, NsecBackupHost, + nsec_backup_available, nsec_backup_retry, nsec_backup_session, nsec_backup_state, + nsec_backup_watch, nsec_restore_begin, nsec_restore_cancel, nsec_restore_install, + nsec_restore_wait, NsecBackupHost, }; use nsec_backup_choice::{nsec_backup_choice, nsec_backup_set_choice}; mod image_clipboard; @@ -583,6 +584,8 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen nsec_backup_available, nsec_backup_session, nsec_backup_retry, + nsec_backup_state, + nsec_backup_watch, nsec_restore_begin, nsec_restore_wait, nsec_restore_install, diff --git a/src-tauri/src/nsec_backup.rs b/src-tauri/src/nsec_backup.rs index 53156097cc..ab53366d79 100644 --- a/src-tauri/src/nsec_backup.rs +++ b/src-tauri/src/nsec_backup.rs @@ -19,14 +19,17 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tauri::async_runtime::JoinHandle; +use tauri::{async_runtime::JoinHandle, ipc::Channel}; use tauri_plugin_opener::OpenerExt; use tokio::sync::oneshot; use zeroize::Zeroizing; const RETRY: &str = "Could not restore your identity. Try again."; const CANCELED: &str = "Restore was canceled. Try again."; -const UNAVAILABLE: &str = "Backup recovery is unavailable. Try again later."; +// The renderer matches these two to offer manual key entry instead of a retry +// (`recoveryOff` in `src/features/identity/service.ts`). +const UNAVAILABLE: &str = "Backup recovery is unavailable."; +const REFUSED: &str = "Backup recovery is not offered for this account."; type Result = std::result::Result; #[derive(Clone, Copy, Deserialize, PartialEq, Eq)] @@ -91,6 +94,144 @@ impl Drop for Restore { let _ = self.callback.cancel(&self.callback_id); } } +/// What the uploader last did for this device's key. `seq` orders snapshots +/// against pushed messages; `pass` answers a handoff's ticket. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BackupStatus { + seq: u64, + pass: u64, + /// Storage root present and `source_allowed(Builderlab)`. + builderlab: bool, + #[serde(flatten)] + progress: Progress, +} +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum Progress { + #[default] + Idle, + Running, + Waiting { + reason: Waiting, + }, + Complete, + Failed { + kind: Failure, + }, +} +/// A pass that stopped before upload; the next wake (consent, a session, a bind's +/// retry) resolves it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum Waiting { + NoConsent, + NoSession, + NotLinked, +} +/// Why a pass failed. Every kind is retried on the next wake; which ones the UI +/// offers to retry, and their copy, belong to the renderer. No body crosses IPC. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum Failure { + Busy, + SignedOut, + NotOffered, + OtherKeyLinked, + InvalidService, + Refused, + Unreachable, + Unconfirmed, + Storage, +} +#[derive(Debug, PartialEq)] +enum Outcome { + Idle, + Complete, + Waiting(Waiting), +} +#[derive(Debug, PartialEq)] +enum Stop { + /// Retired or closed; the pass reports nothing. + Canceled, + Failed(Failure), +} +impl From<&str> for Stop { + fn from(message: &str) -> Self { + match message { + CANCELED => Self::Canceled, + crate::nsec_backup_choice::BUSY => Self::Failed(Failure::Busy), + // Shared helpers fail with fixed strings for consent, record and key I/O. + _ => Self::Failed(Failure::Storage), + } + } +} +impl From for Stop { + fn from(message: String) -> Self { + message.as_str().into() + } +} +/// Why an `Api` read failed. Recovery shows a refused account as `REFUSED` and +/// anything else as `RETRY`; a backup pass reports a rejected session as signed +/// out, a refused account as not offered and anything else as unreachable. +#[derive(Debug)] +enum ReadError { + /// kgoose answers 401 for every expired, revoked, unknown or missing session + /// credential on `me` and `current`; `me` also reports a session that has + /// expired by this device's clock. + SignedOut, + /// A 403 on `current` (`me` only ever answers 401): the session lacks the + /// Buzz user capability or the account its USER role (kgoose's + /// `unauthorized`), which no retry or new sign-in changes. + NotOffered, + Other, +} +impl From<&str> for ReadError { + fn from(_: &str) -> Self { + Self::Other + } +} +impl From for ReadError { + fn from(_: String) -> Self { + Self::Other + } +} +impl From for String { + fn from(error: ReadError) -> Self { + match error { + ReadError::NotOffered => REFUSED, + // A new sign-in may clear a rejected session. + ReadError::SignedOut | ReadError::Other => RETRY, + } + .into() + } +} +impl From for Stop { + fn from(error: ReadError) -> Self { + Self::Failed(match error { + ReadError::SignedOut => Failure::SignedOut, + ReadError::NotOffered => Failure::NotOffered, + ReadError::Other => Failure::Unreachable, + }) + } +} +/// Enrollment or upload errors, which keep the HTTP status but never the body. +fn classify(error: buzz_nsec_backup::Error, upload: bool) -> Failure { + use buzz_nsec_backup::Error::*; + match error { + HttpStatus(401) => Failure::SignedOut, + HttpStatus(403) if upload => Failure::OtherKeyLinked, + HttpStatus(403 | 404) => Failure::NotOffered, + Transport | HttpStatus(429 | 500..) => Failure::Unreachable, + HttpStatus(400..) => Failure::Refused, + HttpStatus(_) => Failure::Unreachable, + InvalidResponse | BodyTooLarge(_) if upload => Failure::Unconfirmed, + InvalidServiceUrl | InvalidCredential | InvalidResponse | BodyTooLarge(_) => { + Failure::InvalidService + } + } +} + #[derive(Default)] struct State { closed: bool, @@ -101,6 +242,12 @@ struct State { rerun: bool, restore: Option, restore_epoch: u64, + /// Wake counter: every ticket handed out, and the number of the pass it starts. + requested: u64, + seq: u64, + pass: u64, + progress: Progress, + watcher: Option>, } impl State { fn retire(&mut self) { @@ -160,6 +307,10 @@ impl NsecBackupHost { state.sessions.clear(); // Source counters restart with each renderer document. state.generations = [None, None]; + // The watcher stays: a stale channel drops sends, and the new document registers. + if state.progress == Progress::Running { + self.publish(&mut state, Progress::Idle); + } } pub(crate) fn close(&self) { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -183,6 +334,34 @@ impl NsecBackupHost { Ok(()) } } + fn builderlab(&self) -> bool { + self.root.is_some() && self.source_allowed(Source::Builderlab) + } + fn status(&self) -> BackupStatus { + let state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + self.snapshot(&state) + } + fn snapshot(&self, state: &State) -> BackupStatus { + BackupStatus { + seq: state.seq, + pass: state.pass, + builderlab: self.builderlab(), + progress: state.progress.clone(), + } + } + /// Callers hold the state lock, so pushed messages leave in `seq` order. + fn publish(&self, state: &mut State, progress: Progress) { + state.seq += 1; + state.progress = progress; + let status = self.snapshot(state); + if let Some(watcher) = &state.watcher { + // A reloaded webview's stale channel drops the message; the new page reads the snapshot. + let _ = watcher.send(status); + } + } + fn watch(&self, channel: Channel) { + self.state.lock().unwrap_or_else(|e| e.into_inner()).watcher = Some(channel); + } fn source_allowed(&self, source: Source) -> bool { let Some(config) = self.config.as_ref() else { return false; @@ -203,116 +382,153 @@ impl NsecBackupHost { generation: u64, credential: Option, identity: IdentityHost, - ) -> Result<()> { + ) -> Result> { // Reject a foreign service before copying a caller's already-held credential. let credential = credential.map(Zeroizing::new); if !self.source_allowed(source) { - return Ok(()); + return Ok(None); } if credential.as_ref().is_some_and(|c| !valid_credential(c)) { return Err("Invalid backup session".into()); } let mut state = self.state.lock().map_err(|_| "Backup unavailable")?; if state.closed { - return Ok(()); + return Ok(None); } let previous = state.generations[source.index()]; if previous.is_some_and(|seen| generation <= seen) { - return Ok(()); + return Ok(None); } state.generations[source.index()] = Some(generation); let active = state.sessions.last().is_some_and(|s| s.source == source); state.sessions.retain(|s| s.source != source); if credential.is_none() && !active { - return Ok(()); + return Ok(None); } state.retire(); if let Some(credential) = credential { state.sessions.push(Session { source, credential }); } drop(state); - self.trigger(identity); - Ok(()) + Ok(self.trigger(identity)) } - fn retry(&self, source: Source, identity: IdentityHost) { - let Ok(mut state) = self.state.lock() else { - return; - }; - let Some(index) = state.sessions.iter().position(|s| s.source == source) else { - return; - }; + fn retry(&self, source: Source, identity: IdentityHost) -> Option { + let mut state = self.state.lock().ok()?; + let index = state.sessions.iter().position(|s| s.source == source)?; if index + 1 != state.sessions.len() { state.retire(); let session = state.sessions.remove(index); state.sessions.push(session); } drop(state); - self.trigger(identity); + self.trigger(identity) } - fn trigger(&self, identity: IdentityHost) { + /// Wake the uploader. The ticket is the number of a pass that starts after this + /// call, or `None` when no pass will. + fn trigger(&self, identity: IdentityHost) -> Option { if self.config.is_none() || self.root.is_none() { - return; + return None; } - let Ok(mut state) = self.state.lock() else { - return; - }; + let mut state = self.state.lock().ok()?; if state.closed { - return; + return None; } if state .worker .as_ref() .is_some_and(|task| !task.inner().is_finished()) { + state.requested += 1; state.rerun = true; - return; + return Some(state.requested); } let Some(session) = state.sessions.last().cloned() else { - return; + // A pass retired by a null handoff ends here, with no outcome of its own; + // numbering it as the latest ticket settles a queued rerun's ticket too. + if state.progress == Progress::Running { + state.pass = state.requested; + self.publish( + &mut state, + Progress::Waiting { + reason: Waiting::NoSession, + }, + ); + } + return None; }; let epoch = state.epoch; state.rerun = false; + state.requested += 1; + state.pass = state.requested; + self.publish(&mut state, Progress::Running); let host = self.clone(); state.worker = Some(tauri::async_runtime::spawn(async move { loop { - if host.backup(&identity, &session, epoch).await.is_err() { + let outcome = host.backup(&identity, &session, epoch).await; + if outcome.is_err() { eprintln!("Automatic identity backup deferred"); } let Ok(mut state) = host.state.lock() else { return; }; + // A retired pass never reports; the next pass's `running` replaces it. if state.closed || state.epoch != epoch { return; } + let progress = match outcome { + Ok(Outcome::Complete) => Progress::Complete, + Ok(Outcome::Waiting(reason)) => Progress::Waiting { reason }, + Err(Stop::Failed(kind)) => Progress::Failed { kind }, + Ok(Outcome::Idle) | Err(Stop::Canceled) => Progress::Idle, + }; + host.publish(&mut state, progress); // Consume overlapping lifecycle events in this worker, without a timer. if std::mem::take(&mut state.rerun) { + state.pass = state.requested; + host.publish(&mut state, Progress::Running); continue; } state.worker = None; return; } })); + Some(state.pass) } - async fn backup(&self, identity: &IdentityHost, session: &Session, epoch: u64) -> Result<()> { + async fn backup( + &self, + identity: &IdentityHost, + session: &Session, + epoch: u64, + ) -> std::result::Result { + let unreachable = |_| Stop::Failed(Failure::Unreachable); let config = self.config.as_ref().as_ref().ok_or(UNAVAILABLE)?; self.valid(epoch)?; - let pubkey = identity.viewer().await?; + let pubkey = match identity.saved_viewer().await { + Ok(Some(pubkey)) => pubkey, + // Without a local key there is nothing to describe. + Ok(None) => return Ok(Outcome::Idle), + // A denied, busy or locked store is not an absent key. + Err(_) => return Err(Stop::Failed(Failure::Storage)), + }; let consent = crate::nsec_backup_choice::read( &self.root.as_ref().ok_or(UNAVAILABLE)?.join("choice.json"), config, &pubkey, )?; if consent.automatic != Some(true) { - return Ok(()); + return Ok(Outcome::Waiting(Waiting::NoConsent)); } // Hold consent's shared lease until this pass ends, including persisted retries. self.valid(epoch)?; - let api = Api::new(&config.service_url, &session.credential)?; + let api = Api::new(&config.service_url, &session.credential).map_err(unreachable)?; let account = api.me().await?; self.valid(epoch)?; - let current = api.current().await?; - if current.as_deref() != Some(&pubkey) { - return Err("Backup identity mismatch".into()); + match api.current().await? { + None => return Ok(Outcome::Waiting(Waiting::NotLinked)), + Some(current) if current != pubkey => { + return Err(Stop::Failed(Failure::OtherKeyLinked)) + } + Some(_) => {} } let locked = { let state = self.state.lock().map_err(|_| RETRY)?; @@ -331,18 +547,27 @@ impl NsecBackupHost { if let Some(record) = record.as_ref() { record.matches_identity(config, &account.subject, &pubkey)?; if record.envelope.is_none() { - return Ok(()); + return Ok(Outcome::Complete); } } - let client = Client::new(&config.service_url, &session.credential).map_err(|_| RETRY)?; - let enrollment = client.enroll().await.map_err(|_| RETRY)?; + let client = Client::new(&config.service_url, &session.credential) + .map_err(|error| Stop::Failed(classify(error, false)))?; + let enrollment = match client.enroll().await { + Ok(enrollment) => enrollment, + // Enrollment refuses an account with no binding; a later bind resolves it. + Err(buzz_nsec_backup::Error::HttpStatus(409)) => { + return Ok(Outcome::Waiting(Waiting::NotLinked)) + } + Err(error) => return Err(Stop::Failed(classify(error, false))), + }; self.valid(epoch)?; crypto::validate_enrollment( &enrollment, &config.namespace, &enrollment.owner_id, &pubkey, - )?; + ) + .map_err(|_| Stop::Failed(Failure::InvalidService))?; let mut record = match record { Some(record) if record.matches(config, &account.subject, &enrollment)? => record, _ => { @@ -355,9 +580,10 @@ impl NsecBackupHost { if local != expected { return Err("Backup identity mismatch".into()); } - crypto::seal(&enrollment, &namespace, &owner, local, secret) + Ok(crypto::seal(&enrollment, &namespace, &owner, local, secret)) }) - .await?; + .await? + .map_err(|_| Stop::Failed(Failure::InvalidService))?; record.envelope = Some(String::from_utf8(bytes).map_err(|_| RETRY)?); let state = self.state.lock().map_err(|_| RETRY)?; if state.closed || state.epoch != epoch { @@ -369,15 +595,15 @@ impl NsecBackupHost { } }; let Some(envelope) = record.envelope.as_ref() else { - return Ok(()); + return Ok(Outcome::Complete); }; self.valid(epoch)?; let receipt = client .upload(envelope.as_bytes()) .await - .map_err(|_| RETRY)?; + .map_err(|error| Stop::Failed(classify(error, true)))?; if !receipt.stored || receipt.backup_id != record.backup_id { - return Err("Backup receipt mismatch".into()); + return Err(Stop::Failed(Failure::Unconfirmed)); } let state = self.state.lock().map_err(|_| RETRY)?; if state.closed || state.epoch != epoch { @@ -385,7 +611,7 @@ impl NsecBackupHost { } record.envelope = None; locked.write(&record)?; - Ok(()) + Ok(Outcome::Complete) } fn valid_restore(&self, epoch: u64) -> Result<()> { let state = self.state.lock().map_err(|_| CANCELED)?; @@ -511,6 +737,8 @@ impl NsecBackupHost { let client = Client::new(&config.service_url, &credential).map_err(|_| RETRY)?; let recovered = client.recover().await.map_err(|error| match error { buzz_nsec_backup::Error::HttpStatus(404) => UNAVAILABLE.to_owned(), + // Not a private org, or no Buzz user role: no retry changes it. + buzz_nsec_backup::Error::HttpStatus(403) => REFUSED.to_owned(), _ => RETRY.to_owned(), })?; let (candidates, failed_count) = candidates(recovered); @@ -775,7 +1003,11 @@ impl Api { serde_json::from_slice(&Self::body(response).await?).map_err(|_| RETRY)?; Ok(value.session_credential) } - async fn request(&self, path: &str, post: bool) -> Result>> { + async fn request( + &self, + path: &str, + post: bool, + ) -> std::result::Result>, ReadError> { let url = format!("{}{path}", self.root); let request = if post { self.http @@ -790,7 +1022,12 @@ impl Api { .send() .await .map_err(|_| RETRY)?; - Self::body(response).await + match response.status() { + reqwest::StatusCode::UNAUTHORIZED => return Err(ReadError::SignedOut), + reqwest::StatusCode::FORBIDDEN => return Err(ReadError::NotOffered), + _ => {} + } + Ok(Self::body(response).await?) } async fn body(mut response: reqwest::Response) -> Result>> { if !response.status().is_success() @@ -807,7 +1044,7 @@ impl Api { } Ok(body) } - async fn me(&self) -> Result { + async fn me(&self) -> std::result::Result { let account: Account = serde_json::from_slice(&self.request("/v1/auth/me", false).await?) .map_err(|_| RETRY)?; let expiry = chrono::DateTime::parse_from_rfc3339(&account.expires_at) @@ -820,13 +1057,16 @@ impl Api { if account.subject.is_empty() || account.subject.len() > 1024 || account.subject.chars().any(char::is_control) - || expiry <= now as i64 { return Err(RETRY.into()); } + // Expired by this device's clock; a new sign-in clears it, a retry cannot. + if expiry <= now as i64 { + return Err(ReadError::SignedOut); + } Ok(account) } - async fn current(&self) -> Result> { + async fn current(&self) -> std::result::Result, ReadError> { let value: serde_json::Value = serde_json::from_slice( &self .request("/v1/buzz/nostr-identities/current", true) @@ -842,13 +1082,12 @@ impl Api { .is_some_and(|id| !id.is_empty()); match value.get("identity") { Some(serde_json::Value::Null) if known => Ok(None), - Some(identity) => public_key( + Some(identity) => Ok(Some(public_key( identity .get("pubkey_hex") .and_then(|key| key.as_str()) .ok_or(RETRY)?, - ) - .map(Some), + )?)), None if known => Ok(None), _ => Err(RETRY.into()), } @@ -859,6 +1098,7 @@ impl Api { pub(crate) fn nsec_backup_available(host: tauri::State<'_, NsecBackupHost>) -> bool { host.config.is_some() } +/// The ticket of the pass this handoff starts, if any. #[tauri::command] pub(crate) fn nsec_backup_session( host: tauri::State<'_, NsecBackupHost>, @@ -866,7 +1106,7 @@ pub(crate) fn nsec_backup_session( source: Source, generation: u64, credential: Option, -) -> Result<()> { +) -> Result> { host.session(source, generation, credential, identity.inner().clone()) } #[tauri::command] @@ -874,8 +1114,26 @@ pub(crate) fn nsec_backup_retry( host: tauri::State<'_, NsecBackupHost>, identity: tauri::State<'_, IdentityHost>, source: Source, -) { - host.retry(source, identity.inner().clone()); +) -> Option { + host.retry(source, identity.inner().clone()) +} +#[tauri::command] +pub(crate) fn nsec_backup_state(host: tauri::State<'_, NsecBackupHost>) -> BackupStatus { + host.status() +} +/// Replace the status watcher. Register before reading `nsec_backup_state`, then keep +/// whichever status has the higher `seq`. +#[tauri::command] +pub(crate) fn nsec_backup_watch( + window: tauri::WebviewWindow, + host: tauri::State<'_, NsecBackupHost>, + on_event: Channel, +) -> Result<()> { + if window.label() != "main" { + return Err("Backup status belongs to the main window".into()); + } + host.watch(on_event); + Ok(()) } #[tauri::command] pub(crate) async fn nsec_restore_begin( diff --git a/src-tauri/src/nsec_backup/persistence.rs b/src-tauri/src/nsec_backup/persistence.rs index 3a96ce9781..c35b5d3058 100644 --- a/src-tauri/src/nsec_backup/persistence.rs +++ b/src-tauri/src/nsec_backup/persistence.rs @@ -91,7 +91,7 @@ impl Record { } } pub(super) struct LockedRecord { - _lock: File, + _lock: crate::nsec_backup_choice::Lease, root: PathBuf, path: PathBuf, } @@ -122,9 +122,10 @@ impl LockedRecord { .truncate(false) .open(root.join(format!("{name}.lock"))) .map_err(|_| FAILED)?; - fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| FAILED)?; Ok(Self { - _lock: lock, + // The file opened, so a refused lock is another pass holding this key's record. + _lock: crate::nsec_backup_choice::lease(lock, true) + .map_err(|_| crate::nsec_backup_choice::BUSY)?, root: root.to_owned(), path: root.join(format!("{name}.json")), }) @@ -194,3 +195,28 @@ pub(super) fn remember_restored( }; slot.write(&record) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_released_record_lock_is_free_even_while_a_spawned_child_still_shares_it() { + let root = tempfile::tempdir().unwrap(); + let config = Config { + service_url: "https://backup.example".into(), + namespace: "buzz-test".into(), + }; + let open = || LockedRecord::open(root.path(), &config, "subject", "pubkey"); + let held = open().unwrap(); + let child = held._lock.inherited(); + // Another copy still holding the record is refused at once, as busy. + assert_eq!( + open().err().as_deref(), + Some(crate::nsec_backup_choice::BUSY) + ); + drop(held); + assert!(open().is_ok()); + drop(child); + } +} diff --git a/src-tauri/src/nsec_backup/tests.rs b/src-tauri/src/nsec_backup/tests.rs index 14db4fb51b..84a31cc871 100644 --- a/src-tauri/src/nsec_backup/tests.rs +++ b/src-tauri/src/nsec_backup/tests.rs @@ -3,6 +3,7 @@ use std::{ collections::HashMap, sync::atomic::{AtomicBool, Ordering}, }; +use tauri::ipc::InvokeResponseBody; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, @@ -24,10 +25,14 @@ fn recovery(keys: &[[u8; 32]], failed: usize) -> serde_json::Value { })).collect::>(), "failed_backup_ids":(0..failed).map(|_|uuid::Uuid::new_v4().to_string()).collect::>()}) } struct FixtureState { + me_status: u16, + expires_at: String, mapping: Option, mapping_status: u16, mapping_body: Option, owner: String, + namespace: String, + enrollment_status: u16, recipient_id: String, recipient_key: Option, subject: String, @@ -59,10 +64,14 @@ impl Service { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let root = format!("http://{}", listener.local_addr().unwrap()); let state = Arc::new(Mutex::new(FixtureState { + me_status: 200, + expires_at: "2999-01-01T00:00:00Z".into(), mapping: Some(pubkey([1; 32])), mapping_status: 200, mapping_body: None, owner: "subject:synthetic-account".into(), + namespace: "backup/staging".into(), + enrollment_status: 200, recipient_id: "backup-key-2026-09".into(), recipient_key: None, subject: "synthetic-account".into(), @@ -190,12 +199,12 @@ async fn serve( *state.requests.entry(path.clone()).or_default() += 1; match path.as_str() { "/v1/auth/login/exchange" => (200, serde_json::json!({"session_credential":"synthetic-cli-credential"})), - "/v1/auth/me" => (200, serde_json::json!({"subject":state.subject,"expires_at":"2999-01-01T00:00:00Z"})), + "/v1/auth/me" => (state.me_status, serde_json::json!({"subject":state.subject,"expires_at":state.expires_at})), "/v1/buzz/nostr-identities/current" => (state.mapping_status,state.mapping_body.clone().unwrap_or_else(||serde_json::json!({"correlation_id":"synthetic-correlation","identity":state.mapping.as_ref().map(|key|serde_json::json!({"pubkey_hex":key}))}))), "/v1/buzz/nsec-backups/enrollment" => { let fixture: serde_json::Value = serde_json::from_str(include_str!("fixtures/hpke_nsec_backup_v1.json")).unwrap(); - (200, serde_json::json!({"version":1,"suite":{"kem_id":16,"kdf_id":1,"aead_id":2}, - "recipient_key_id":state.recipient_id,"service_namespace":"backup/staging","owner_id":state.owner, + (state.enrollment_status, serde_json::json!({"version":1,"suite":{"kem_id":16,"kdf_id":1,"aead_id":2}, + "recipient_key_id":state.recipient_id,"service_namespace":state.namespace,"owner_id":state.owner, "backup_id":uuid::Uuid::new_v4().to_string(),"nostr_pubkey":state.mapping, "recipient_public_key":state.recipient_key.clone().unwrap_or_else(||URL_SAFE_NO_PAD.encode(hex::decode(fixture["recipient_public_key_sec1_hex"].as_str().unwrap()).unwrap()))})) }, @@ -317,10 +326,11 @@ async fn restart_retry_reuses_persisted_ciphertext_and_completion_prevents_more_ let host = service.host(directory.path()); service.enable(&host).await; service.state.lock().unwrap().upload_status = 503; - assert!(host - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Unreachable)) + ); let first = service.state.lock().unwrap().uploads[0].clone(); service.state.lock().unwrap().upload_status = 200; let restarted = service.host(directory.path()); @@ -329,20 +339,22 @@ async fn restart_retry_reuses_persisted_ciphertext_and_completion_prevents_more_ .await .unwrap(); let baseline = service.state.lock().unwrap().requests.clone(); - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); + assert_eq!( + restarted + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Ok(Outcome::Waiting(Waiting::NoConsent)) + ); assert_eq!(service.state.lock().unwrap().requests, baseline); service.enable(&restarted).await; - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); - restarted - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .unwrap(); + for _ in 0..2 { + assert_eq!( + restarted + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Ok(Outcome::Complete) + ); + } let state = service.state.lock().unwrap(); assert_eq!(state.uploads, vec![first.clone(), first]); assert_eq!(state.rows.len(), 1); @@ -390,10 +402,11 @@ async fn mismatched_mapping_and_unwritable_pending_state_never_upload() { let host = service.host(directory.path()); service.enable(&host).await; service.state.lock().unwrap().mapping = Some(pubkey([2; 32])); - assert!(host - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::OtherKeyLinked)) + ); service.state.lock().unwrap().mapping = Some(pubkey([1; 32])); service.pause_current.store(true, Ordering::SeqCst); let task_host = host.clone(); @@ -411,7 +424,7 @@ async fn mismatched_mapping_and_unwritable_pending_state_never_upload() { let result = backup.await; std::fs::remove_file(directory.path()).unwrap(); std::fs::rename(&retained, directory.path()).unwrap(); - assert!(result.unwrap().is_err()); + assert_eq!(result.unwrap(), Err(Stop::Failed(Failure::Storage))); assert!(service.state.lock().unwrap().uploads.is_empty()); } @@ -430,14 +443,17 @@ async fn competing_workers_and_signout_keep_an_admitted_upload_pending() { .await }); service.upload_started.notified().await; - assert!(service - .host(directory.path()) - .backup(&IdentityHost::fixture(), &service.session(), 0) - .await - .is_err()); + // Another copy holding this key's record lock is `busy`, retried on the next wake. + assert_eq!( + service + .host(directory.path()) + .backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Busy)) + ); host.close(); service.release_upload.add_permits(1); - assert!(upload.await.unwrap().is_err()); + assert_eq!(upload.await.unwrap(), Err(Stop::Canceled)); service.pause_upload.store(false, Ordering::SeqCst); service .host(directory.path()) @@ -533,12 +549,13 @@ async fn recover_with(service: &Service, host: &NsecBackupHost) -> Result Arc>> { + let seen = Arc::new(Mutex::new(Vec::new())); + let output = seen.clone(); + host.watch(Channel::new(move |body| { + if let InvokeResponseBody::Json(json) = body { + output + .lock() + .unwrap() + .push(serde_json::from_str(&json).unwrap()); + } + if delivered { + Ok(()) + } else { + Err(tauri::Error::WebviewNotFound) + } + })); + seen +} +/// Each message as `" "`, with `:` or `:` when present. +fn statuses(seen: &Mutex>) -> Vec { + seen.lock() + .unwrap() + .iter() + .map(|status| { + let name = status["status"].as_str().unwrap(); + let pass = status["pass"].as_u64().unwrap(); + match status.get("reason").or(status.get("kind")) { + Some(detail) => format!("{pass} {name}:{}", detail.as_str().unwrap()), + None => format!("{pass} {name}"), + } + }) + .collect() +} +fn worker(host: &NsecBackupHost) -> tokio::task::AbortHandle { + host.state + .lock() + .unwrap() + .worker + .as_ref() + .unwrap() + .inner() + .abort_handle() +} +async fn retired(worker: &tokio::task::AbortHandle) { + tokio::time::timeout(Duration::from_secs(5), async { + while !worker.is_finished() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); +} +/// A pass that stops before uploading fails here with what it reported, not a hang. +async fn upload_started(service: &Service, seen: &Mutex>) { + let started = service.upload_started.notified(); + if tokio::time::timeout(Duration::from_secs(5), started) + .await + .is_err() + { + panic!("No upload started; the pass reported {:?}", statuses(seen)); + } +} +fn production() -> Arc> { + Arc::new(Some(Config { + service_url: "https://app.builderlab.xyz/api/goose".into(), + namespace: "backup/staging".into(), + })) +} + +#[tokio::test] +async fn a_pass_publishes_running_then_its_outcome_under_the_returned_ticket() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(IdentityHost::fixture()), Some(1)); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 complete"]); + let messages = seen.lock().unwrap().clone(); + assert!(messages[0]["seq"].as_u64() < messages[1]["seq"].as_u64()); + assert_eq!(serde_json::to_value(host.status()).unwrap(), messages[1]); + // A later wake finds the record complete and uploads nothing more. + assert_eq!(host.trigger(IdentityHost::fixture()), Some(2)); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running", "1 complete", "2 running", "2 complete"] + ); + let messages = seen.lock().unwrap().clone(); + assert!(messages + .windows(2) + .all(|pair| pair[0]["seq"].as_u64() < pair[1]["seq"].as_u64())); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn each_stopped_pass_publishes_its_waiting_reason_or_failure_kind() { + type Knob = fn(&mut FixtureState); + let cases: [(Knob, &str); 24] = [ + (|s| s.mapping = None, "waiting:not_linked"), + ( + |s| s.mapping = Some(pubkey([2; 32])), + "failed:other_key_linked", + ), + (|s| s.mapping_status = 500, "failed:unreachable"), + // kgoose's answer to an expired, revoked or unknown session credential. + (|s| s.me_status = 401, "failed:signed_out"), + (|s| s.mapping_status = 401, "failed:signed_out"), + // A session `me` accepts but this device's clock has already expired. + ( + |s| s.expires_at = "2000-01-01T00:00:00Z".into(), + "failed:signed_out", + ), + // A missing capability or USER role on `current`, which no retry or + // sign-in changes; kgoose's own refusal for the role is `unauthorized`. + (|s| s.mapping_status = 403, "failed:not_offered"), + ( + |s| { + s.mapping_status = 403; + s.mapping_body = Some(serde_json::json!({ + "error": {"code": "unauthorized", "message": "synthetic"}, + "correlation_id": "synthetic-correlation", + })); + }, + "failed:not_offered", + ), + // Every other status stays unreachable. + (|s| s.me_status = 500, "failed:unreachable"), + (|s| s.enrollment_status = 409, "waiting:not_linked"), + (|s| s.enrollment_status = 403, "failed:not_offered"), + (|s| s.enrollment_status = 404, "failed:not_offered"), + (|s| s.enrollment_status = 401, "failed:signed_out"), + (|s| s.enrollment_status = 503, "failed:unreachable"), + ( + |s| s.recipient_key = Some("malformed".into()), + "failed:invalid_service", + ), + ( + |s| s.namespace = "backup/other".into(), + "failed:invalid_service", + ), + (|s| s.upload_status = 400, "failed:refused"), + (|s| s.upload_status = 409, "failed:refused"), + (|s| s.upload_status = 413, "failed:refused"), + (|s| s.upload_status = 403, "failed:other_key_linked"), + (|s| s.upload_status = 404, "failed:not_offered"), + (|s| s.upload_status = 503, "failed:unreachable"), + (|s| s.receipt_matches = false, "failed:unconfirmed"), + (|s| s.receipt_stored = false, "failed:unconfirmed"), + ]; + for (index, (knob, expected)) in cases.into_iter().enumerate() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + knob(&mut service.state.lock().unwrap()); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running".to_owned(), format!("1 {expected}")], + "case {index}" + ); + } + // Another app copy holding the consent lock is `busy`, not a storage failure. + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + service.enable(&host).await; + let lock = std::fs::OpenOptions::new() + .write(true) + .open(directory.path().join("choice.lock")) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&lock).unwrap(); + assert_eq!( + host.backup(&IdentityHost::fixture(), &service.session(), 0) + .await, + Err(Stop::Failed(Failure::Busy)) + ); + // Only a confirmed absent key is idle; a key that cannot be read is storage. + for (identity, expected) in [ + (IdentityHost::fixture_without_key(true), Ok(Outcome::Idle)), + ( + IdentityHost::fixture_without_key(false), + Err(Stop::Failed(Failure::Storage)), + ), + ] { + assert_eq!( + host.backup(&identity, &service.session(), 0).await, + expected + ); + } + assert!(service.state.lock().unwrap().requests.is_empty()); +} + +#[tokio::test] +async fn wakes_during_a_pass_queue_one_rerun_whose_pass_covers_their_tickets() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + let identity = IdentityHost::fixture(); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(identity.clone()), Some(1)); + upload_started(&service, &seen).await; + assert_eq!(host.trigger(identity.clone()), Some(2)); + assert_eq!(host.retry(Source::Hosted, identity), Some(3)); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + ["1 running", "1 complete", "3 running", "3 complete"] + ); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn a_key_install_supersedes_a_pass_that_then_reports_nothing() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &seen).await; + let first = worker(&host); + // Hold the next pass until the retired one has released its record lock. + service.pause_current.store(true, Ordering::SeqCst); + host.local_key_installed(IdentityHost::fixture()); + retired(&first).await; + upload_started(&service, &seen).await; + service.pause_current.store(false, Ordering::SeqCst); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(2); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "2 running", "2 complete"]); +} + +#[tokio::test] +async fn a_null_handoff_reports_no_session_only_for_a_running_pass() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + // Admit hosted handoffs on the production origin; HTTP stays on the loopback fixture. + let login = NsecBackupHost { + state: host.state.clone(), + root: Some(directory.path().to_owned()), + config: production(), + }; + let seen = record(&host, true); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &seen).await; + let first = worker(&host); + // A wake during the pass holds a rerun ticket, which the retirement settles. + assert_eq!(host.trigger(IdentityHost::fixture()), Some(2)); + assert_eq!( + login.session(Source::Hosted, 1, None, IdentityHost::fixture()), + Ok(None) + ); + retired(&first).await; + assert_eq!(statuses(&seen), ["1 running", "2 waiting:no_session"]); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!( + login.session(Source::Hosted, 2, None, IdentityHost::fixture()), + Ok(None) + ); + assert_eq!( + statuses(&seen), + [ + "1 running", + "2 waiting:no_session", + "3 running", + "3 complete" + ] + ); +} + +#[tokio::test] +async fn a_document_load_mid_pass_reports_idle_and_the_new_watcher_sees_later_passes() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let stale = record(&host, false); + service.enable(&host).await; + service.pause_upload.store(true, Ordering::SeqCst); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + upload_started(&service, &stale).await; + let first = worker(&host); + host.document_load(); + retired(&first).await; + assert_eq!(statuses(&stale), ["1 running", "1 idle"]); + let current = record(&host, true); + service.pause_upload.store(false, Ordering::SeqCst); + service.release_upload.add_permits(1); + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!(statuses(&stale).len(), 2); + assert_eq!(statuses(¤t), ["2 running", "2 complete"]); +} + +#[tokio::test] +async fn a_pass_without_consent_waits_without_a_request() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + host.state.lock().unwrap().sessions.push(service.session()); + assert_eq!(host.trigger(IdentityHost::fixture()), Some(1)); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 waiting:no_consent"]); + assert!(service.state.lock().unwrap().requests.is_empty()); +} + +#[tokio::test] +async fn an_unlinked_pass_waits_until_a_retry_after_binding_completes_it() { + let service = Service::new().await; + let directory = tempfile::tempdir().unwrap(); + let host = service.host(directory.path()); + let seen = record(&host, true); + service.enable(&host).await; + service.state.lock().unwrap().mapping = None; + host.state.lock().unwrap().sessions.push(service.session()); + host.trigger(IdentityHost::fixture()); + finish_background(&host).await; + assert_eq!(statuses(&seen), ["1 running", "1 waiting:not_linked"]); + service.state.lock().unwrap().mapping = Some(pubkey([1; 32])); + assert_eq!(host.retry(Source::Hosted, IdentityHost::fixture()), Some(2)); + finish_background(&host).await; + assert_eq!( + statuses(&seen), + [ + "1 running", + "1 waiting:not_linked", + "2 running", + "2 complete" + ] + ); + assert_eq!(service.state.lock().unwrap().uploads.len(), 1); +} + +#[tokio::test] +async fn handoffs_and_retries_return_a_ticket_only_when_a_pass_will_start() { + // No consent is saved, so admitted passes stop before any request. + let directory = tempfile::tempdir().unwrap(); + let host = NsecBackupHost { + state: Default::default(), + root: Some(directory.path().to_owned()), + config: production(), + }; + let foreign = NsecBackupHost { + state: Default::default(), + root: Some(directory.path().to_owned()), + config: Arc::new(Some(Config { + service_url: "https://backup.example/api/goose".into(), + namespace: "backup/staging".into(), + })), + }; + let identity = IdentityHost::fixture(); + let credential = || Some("synthetic-session".to_owned()); + assert_eq!(host.retry(Source::Hosted, identity.clone()), None); + assert_eq!( + host.session(Source::Hosted, 1, None, identity.clone()), + Ok(None) + ); + assert_eq!( + host.session(Source::Hosted, 2, credential(), identity.clone()), + Ok(Some(1)) + ); + assert_eq!( + host.session(Source::Hosted, 2, credential(), identity.clone()), + Ok(None) + ); + assert_eq!(host.retry(Source::Hosted, identity.clone()), Some(2)); + assert_eq!( + foreign.session(Source::Hosted, 3, credential(), identity.clone()), + Ok(None) + ); + finish_background(&host).await; + assert_eq!( + host.session(Source::Hosted, 4, None, identity.clone()), + Ok(None) + ); + host.close(); + assert_eq!( + host.session(Source::Hosted, 5, credential(), identity.clone()), + Ok(None) + ); + assert_eq!(host.retry(Source::Hosted, identity), None); + assert_eq!(host.status().pass, 2); +} + +#[test] +fn statuses_serialize_flat_and_report_whether_builderlab_backup_can_run() { + let status = |progress| { + serde_json::to_value(BackupStatus { + seq: 7, + pass: 3, + builderlab: true, + progress, + }) + .unwrap() + }; + let wire = |fields: serde_json::Value| { + let mut value = serde_json::json!({"seq":7,"pass":3,"builderlab":true}); + value + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + value + }; + for (progress, name) in [ + (Progress::Idle, "idle"), + (Progress::Running, "running"), + (Progress::Complete, "complete"), + ] { + assert_eq!(status(progress), wire(serde_json::json!({"status":name}))); + } + for (reason, name) in [ + (Waiting::NoConsent, "no_consent"), + (Waiting::NoSession, "no_session"), + (Waiting::NotLinked, "not_linked"), + ] { + assert_eq!( + status(Progress::Waiting { reason }), + wire(serde_json::json!({"status":"waiting","reason":name})) + ); + } + for (kind, name) in [ + (Failure::Busy, "busy"), + (Failure::SignedOut, "signed_out"), + (Failure::NotOffered, "not_offered"), + (Failure::OtherKeyLinked, "other_key_linked"), + (Failure::InvalidService, "invalid_service"), + (Failure::Refused, "refused"), + (Failure::Unreachable, "unreachable"), + (Failure::Unconfirmed, "unconfirmed"), + (Failure::Storage, "storage"), + ] { + assert_eq!( + status(Progress::Failed { kind }), + wire(serde_json::json!({"status":"failed","kind":name})) + ); + } + let directory = tempfile::tempdir().unwrap(); + let granted = format!("{}/api/goose", buzzodz_plugins::BUILDERLAB_ORIGIN); + for (root, service_url, builderlab) in [ + (None, granted.as_str(), false), + ( + Some(directory.path()), + "https://backup.example/api/goose", + false, + ), + ( + Some(directory.path()), + granted.as_str(), + !buzzodz_plugins::BUILDERLAB_ORIGIN.is_empty(), + ), + ] { + let host = NsecBackupHost { + state: Default::default(), + root: root.map(ToOwned::to_owned), + config: Arc::new(Some(Config { + service_url: service_url.into(), + namespace: "backup/staging".into(), + })), + }; + assert_eq!( + serde_json::to_value(host.status()).unwrap(), + serde_json::json!({"seq":0,"pass":0,"builderlab":builderlab,"status":"idle"}), + "{service_url}" + ); + } +} diff --git a/src-tauri/src/nsec_backup_choice.rs b/src-tauri/src/nsec_backup_choice.rs index 1eecf21c3a..30b502bae4 100644 --- a/src-tauri/src/nsec_backup_choice.rs +++ b/src-tauri/src/nsec_backup_choice.rs @@ -9,6 +9,9 @@ use std::{ type Result = std::result::Result; const FAILED: &str = "Could not read or save your identity backup choice."; +/// Another pass holds the consent lock or a key's record lock; the uploader +/// reports it as `busy`. +pub(crate) const BUSY: &str = "A backup is in progress. Wait for it to finish and try again."; #[derive(Serialize)] pub(crate) struct BackupChoice { @@ -17,7 +20,33 @@ pub(crate) struct BackupChoice { pub(crate) automatic: Option, // Retain this value through an upload. A successful choice change cannot race it. #[serde(skip)] - _lease: Option, + _lease: Option, +} + +/// A held advisory lock, released when dropped. +pub(crate) struct Lease(fs::File); +impl Drop for Lease { + fn drop(&mut self) { + // fork/dup can retain the open-file description after our descriptor + // closes. Explicitly release ownership when the operation finishes. + let _ = fs2::FileExt::unlock(&self.0); + } +} +#[cfg(test)] +impl Lease { + /// The descriptor a child spawned while this is held inherits until it execs. + pub(crate) fn inherited(&self) -> fs::File { + self.0.try_clone().unwrap() + } +} + +pub(crate) fn lease(file: fs::File, exclusive: bool) -> std::io::Result { + if exclusive { + fs2::FileExt::try_lock_exclusive(&file)?; + } else { + fs2::FileExt::try_lock_shared(&file)?; + } + Ok(Lease(file)) } #[derive(Deserialize, Serialize)] @@ -60,7 +89,7 @@ fn config() -> Result> { ) } -fn lock(path: &Path, exclusive: bool) -> Result { +fn lock(path: &Path, exclusive: bool) -> Result { fs::create_dir_all(path.parent().ok_or(FAILED)?).map_err(|_| FAILED)?; let file = fs::OpenOptions::new() .read(true) @@ -69,13 +98,7 @@ fn lock(path: &Path, exclusive: bool) -> Result { .truncate(false) .open(path.with_extension("lock")) .map_err(|_| FAILED)?; - let result = if exclusive { - fs2::FileExt::try_lock_exclusive(&file) - } else { - fs2::FileExt::try_lock_shared(&file) - }; - result.map_err(|_| "A backup is in progress. Wait for it to finish and try again.")?; - Ok(file) + lease(file, exclusive).map_err(|_| BUSY.into()) } /// Only `Some(true)` authorizes backup. Keep the returned value alive until completion. diff --git a/src-tauri/src/nsec_backup_choice/tests.rs b/src-tauri/src/nsec_backup_choice/tests.rs index ad79d12052..c3af4ab268 100644 --- a/src-tauri/src/nsec_backup_choice/tests.rs +++ b/src-tauri/src/nsec_backup_choice/tests.rs @@ -123,6 +123,19 @@ async fn choice_changes_serialize_with_admitted_readers_across_copies() { ); } +#[test] +fn a_released_lease_is_free_even_while_a_spawned_child_still_shares_it() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("choice.json"); + let held = lock(&path, true).unwrap(); + let child = held.inherited(); + // Another copy still holding its lease is busy at once. + assert_eq!(lock(&path, false).err().as_deref(), Some(BUSY)); + drop(held); + assert!(lock(&path, false).is_ok()); + drop(child); +} + #[tokio::test] async fn failed_storage_does_not_report_saved_consent() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/features/identity/RestoreIdentity.tsx b/src/features/identity/RestoreIdentity.tsx index 3dd9e63a9d..b4b86f6c64 100644 --- a/src/features/identity/RestoreIdentity.tsx +++ b/src/features/identity/RestoreIdentity.tsx @@ -4,7 +4,7 @@ import { publicKeyLabels } from "../../shared/identity/public-key"; import { Button } from "../../shared/design-system/ui/Button"; import { Field } from "../../shared/design-system/ui/Field"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; -import type { Identity, RecoveryState } from "./service"; +import { type Identity, type RecoveryState, recoveryOff } from "./service"; export function RestoreIdentity({ identity, @@ -104,7 +104,7 @@ export function RestoreIdentity({ > Restore this identity - ) : recovery.status === "error" || + ) : (recovery.status === "error" && !recoveryOff(recovery)) || (recovery.status === "choosing" && candidates.length === 0) ? (