refactor(desktop): inject identity-persist seams into transition coordinator

The commit path in import_identity_blocking resolved app_data_dir() and
SecretStore::shared() internally, so a mock-driven fixture that drives the
coordinator to a durable commit would write under the real
~/Library/Application Support and bind the process-global keychain. Hoist
both durable-persist inputs — data_dir: PathBuf and store: &impl
IdentityKeyStore — to the run_identity_transition boundary; both production
callers (import_identity, import_recovered_identity) pass the real values
they resolved before. This is dependency injection of genuine persist inputs
(the downstream persist_imported_identity/classifier already take exactly
these), not a test-only abstraction: it lets §7's no-scope and None->Some
activation-race fixtures drive a real commit hermetically against a tempdir
and a fake store. Zero logic change; monomorphized call sites are
behavior-neutral.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-19 12:07:23 -04:00
parent 65eda545f1
commit 5766132f8f
4 changed files with 49 additions and 14 deletions

View File

@@ -888,10 +888,10 @@ fn persist_imported_identity_impl(
}
}
/// Public entry point binding [`persist_imported_identity_impl`] to the shared
/// [`crate::secret_store::SecretStore`]. See the impl for the persistence policy.
/// Public entry point for imported-identity persistence over any
/// [`IdentityKeyStore`] (production passes the shared `SecretStore`).
pub(crate) fn persist_imported_identity(
store: &crate::secret_store::SecretStore,
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,

View File

@@ -383,9 +383,24 @@ pub async fn import_identity(
password: Option<String>,
app_handle: tauri::AppHandle,
) -> Result<IdentityInfo, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
// Normal import: no supersession fence, both validity gates always pass —
// there is no queued-task currency to check.
run_identity_transition(app_handle, nsec, password, None, || Ok(()), || Ok(())).await
run_identity_transition(
app_handle,
nsec,
password,
None,
store,
data_dir,
|| Ok(()),
|| Ok(()),
)
.await
}
/// Finish an imported identity commit whose durable persist already succeeded

View File

@@ -102,14 +102,21 @@ fn compensate_import_drain<R: tauri::Runtime>(
/// runtimes compensate, but revoked owner-identity durable capabilities stay
/// revoked. This is design-conformant fail-closed churn, not a split state —
/// see `CROSS_WORKSPACE_AGENT_LIBRARY.md` §3.3a (barrier sequence is fixed).
pub(crate) async fn run_identity_transition<R: tauri::Runtime>(
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_identity_transition<R, S>(
app_handle: tauri::AppHandle<R>,
nsec: String,
password: Option<String>,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
store: &'static S,
data_dir: std::path::PathBuf,
early_validity_check: impl FnOnce() -> Result<(), String>,
late_validity_check: impl FnOnce() -> Result<(), String> + Send + 'static,
) -> Result<IdentityInfo, String> {
) -> Result<IdentityInfo, String>
where
R: tauri::Runtime,
S: crate::app_state::IdentityKeyStore + Send + Sync + 'static,
{
// ── Layer 1: identity_mutation (async serialization lock) ────────────────
// Held for the full import to prevent a concurrent stale persist from
// overwriting the imported key. Lock order: identity_mutation →
@@ -149,6 +156,8 @@ pub(crate) async fn run_identity_transition<R: tauri::Runtime>(
nsec,
password,
commit_fence,
store,
data_dir,
late_validity_check,
)
})
@@ -200,13 +209,19 @@ pub(crate) fn commit_under_fence<T>(
/// either compensates (cancelled during drain, before the fence is taken) or
/// loses to the committed recovery (cancelled after the fenced commit begins).
/// Normal import supplies `None` + an always-`Ok(())` check.
fn import_identity_blocking<R: tauri::Runtime>(
fn import_identity_blocking<R, S>(
app_handle: tauri::AppHandle<R>,
nsec: String,
password: Option<String>,
commit_fence: Option<std::sync::Arc<std::sync::Mutex<()>>>,
store: &S,
data_dir: std::path::PathBuf,
validity_check: impl FnOnce() -> Result<(), String>,
) -> Result<IdentityInfo, String> {
) -> Result<IdentityInfo, String>
where
R: tauri::Runtime,
S: crate::app_state::IdentityKeyStore,
{
// NIP-49 backups require a passphrase and decrypt entirely in Rust.
// Raw nsec/hex input follows the existing parser path unchanged.
let password = password.map(zeroize::Zeroizing::new);
@@ -217,10 +232,6 @@ fn import_identity_blocking<R: tauri::Runtime>(
let state = app_handle.state::<AppState>();
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
@@ -316,8 +327,9 @@ fn import_identity_blocking<R: tauri::Runtime>(
// check and the durable dispatch nor interleave with it. The P28-C1 journal
// is written ONLY after the validity gate passes, so a superseded recovery
// leaves no journal residue. The persist is CLASSIFIED against durable fact
// (P27-C1) — never the kernel's `Ok`/`Err`.
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
// (P27-C1) — never the kernel's `Ok`/`Err`. `store` and `data_dir` are the
// durable-persist seams injected by the caller (production: the shared
// `SecretStore` + real `app_data_dir()`; tests: a fake store + tempdir).
let pending = crate::identity_transition_journal::IdentityTransitionPending {
from_pubkey,
to_pubkey,
@@ -628,6 +640,8 @@ mod tests {
"nsec-invalid-must-never-be-parsed".to_string(),
None,
None,
crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()),
std::env::temp_dir(),
|| Err("superseded while queued".to_string()),
|| unreachable!("late gate must not run after an early-gate rejection"),
)

View File

@@ -479,11 +479,17 @@ async fn import_recovered_identity(
// identity). `ensure_pairing_task_is_current` is an idempotent currency
// read, so both gates get an independent clone of the same check.
let early_generation = Arc::clone(&generation);
let data_dir = tauri::Manager::path(app)
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::commands::identity::run_identity_transition(
app.clone(),
nsec.trim().to_string(),
None,
Some(Arc::clone(generation_fence)),
store,
data_dir,
move || ensure_pairing_task_is_current(&early_generation, task_generation),
move || ensure_pairing_task_is_current(&generation, task_generation),
)