From 51729d9f1429c075ef1db64d9402bbd566bba1b5 Mon Sep 17 00:00:00 2001 From: dmss <15844340+cwxsss@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:59:19 +0800 Subject: [PATCH 1/6] fix: persist admission transitions on Windows --- .../security/admission_space_transition.rs | 40 ++++++++++++++++--- docs/architecture/architecture-bible.md | 2 + 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/uc-infra/src/security/admission_space_transition.rs b/crates/uc-infra/src/security/admission_space_transition.rs index e0411eb..38124f1 100644 --- a/crates/uc-infra/src/security/admission_space_transition.rs +++ b/crates/uc-infra/src/security/admission_space_transition.rs @@ -2641,12 +2641,26 @@ fn write_new_file(path: &Path, bytes: &[u8]) -> Result<(), String> { file.sync_all() .map_err(|_| "sync temporary generation".to_owned())?; drop(file); - replace_file_atomically(&temporary, path) - .map_err(|_| "replace existing generation".to_owned())?; - let directory = std::fs::File::open(parent).map_err(|_| "open generation parent".to_owned())?; - directory - .sync_all() - .map_err(|_| "sync generation parent".to_owned()) + commit_replacement(&temporary, path).map_err(|_| "replace existing generation".to_owned()) +} + +fn commit_replacement(temp: &Path, destination: &Path) -> std::io::Result<()> { + replace_file_atomically(temp, destination)?; + sync_parent_directory_if_supported(destination) +} + +#[cfg(not(windows))] +fn sync_parent_directory_if_supported(destination: &Path) -> std::io::Result<()> { + let parent = destination + .parent() + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing parent"))?; + let directory = std::fs::File::open(parent)?; + directory.sync_all() +} + +#[cfg(windows)] +fn sync_parent_directory_if_supported(_: &Path) -> std::io::Result<()> { + Ok(()) } #[cfg(not(windows))] @@ -2760,6 +2774,20 @@ mod tests { assert_eq!(ensure_reset_capacity(100, 100), Ok(())); } + #[test] + fn write_new_file_replaces_existing_file_on_windows_without_directory_open_error() { + let directory = tempdir().unwrap(); + let path = directory.path().join("transition.json"); + let temporary = path.with_extension("write.tmp"); + std::fs::write(&path, b"old").unwrap(); + + let result = super::write_new_file(&path, b"new"); + + assert!(result.is_ok(), "write_new_file failed: {result:?}"); + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + assert!(!temporary.exists()); + } + #[derive(Default)] struct MemorySecureStorage(Mutex>>); diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index c10f482..ac5694b 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -957,6 +957,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> - 2026-08-21:补全已被早期修复转换为新格式的 `1.1.0-rc.3` 空间恢复。恢复判断不再依赖会随正常流程变化的完成阶段,而以本机身份、既有设备关系、未移除、无恢复失败且缺少成员历史这些持久事实为准;处于正常应用或收敛中的受影响空间也能恢复迁移来源,已移除、需要人工恢复、已有失败或证据不足的状态仍保持拒绝。不改变负责人、对外接口或密文持久化格式。 +- 2026-08-25:修正 `uc-infra` admission transition 的 Windows 文件持久化语义:`MoveFileExW` 完成原子替换后不再把父目录当普通文件打开并同步,Unix 继续在 `rename` 后同步父目录;不改变分层、公共接口或 admission 业务规则。 + - 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 From 9751ade5d117e9d3ce11188111dfaa513fb7c114 Mon Sep 17 00:00:00 2001 From: dmss <15844340+cwxsss@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:16:53 +0800 Subject: [PATCH 2/6] fix: replay Windows space transitions safely --- .../security/admission_space_transition.rs | 77 +++++++++++++++---- docs/architecture/architecture-bible.md | 2 + 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/crates/uc-infra/src/security/admission_space_transition.rs b/crates/uc-infra/src/security/admission_space_transition.rs index 38124f1..67fa0a2 100644 --- a/crates/uc-infra/src/security/admission_space_transition.rs +++ b/crates/uc-infra/src/security/admission_space_transition.rs @@ -538,6 +538,34 @@ impl DurableAdmissionSpaceTransition { advanced(SameSpaceTransitionPhaseV1::ActivationStarted) } SameSpaceTransitionPhaseV1::ActivationStarted => { + let active_manifest = self.active_manifest_for( + transition.attempt_id, + &transition.target_space_id, + transition.target_generation, + )?; + let directory = self.target_generation_directory( + &transition.target_space_id, + &transition.target_generation, + ); + let target_database = directory.join("target.sqlite"); + if self + .manifest_store + .load() + .await + .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .as_ref() + == Some(&active_manifest) + { + self.source_pool + .replace_database( + target_database + .to_str() + .ok_or(AdmissionSpaceTransitionError::Storage)?, + ) + .map_err(|_| AdmissionSpaceTransitionError::RecoveryRequired)?; + self.blob_store.replace_root(directory.join("blobs")); + return advanced(SameSpaceTransitionPhaseV1::TargetPromoted); + } let target_workspace = self.open_target_workspace( &transition.target_space_id, &transition.target_generation, @@ -545,16 +573,11 @@ impl DurableAdmissionSpaceTransition { self.session.as_ref(), )?; let target_material = self.same_space_key_material(&target_workspace).await?; - let directory = self.target_generation_directory( - &transition.target_space_id, - &transition.target_generation, - ); std::fs::create_dir_all(&directory) .map_err(|_| AdmissionSpaceTransitionError::Storage)?; let scratch = directory.join("same-space.snapshot.tmp"); let source_bytes = db_snapshot::snapshot_to_bytes(&self.source_pool, &scratch) .map_err(|_| AdmissionSpaceTransitionError::Storage)?; - let target_database = directory.join("target.sqlite"); write_new_file(&target_database, &source_bytes) .map_err(|_| AdmissionSpaceTransitionError::Storage)?; self.install_and_reopen_security_material( @@ -574,11 +597,7 @@ impl DurableAdmissionSpaceTransition { &directory.join("blobs"), )?; self.manifest_store - .promote(&self.active_manifest_for( - transition.attempt_id, - &transition.target_space_id, - transition.target_generation, - )?) + .promote(&active_manifest) .await .map_err(|_| AdmissionSpaceTransitionError::Storage)?; self.source_pool @@ -1019,12 +1038,14 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { ); std::fs::create_dir_all(&target_directory) .map_err(|_| AdmissionSpaceTransitionError::Storage)?; - let working_database = target_directory.join("reset-working.sqlite"); - remove_sqlite_database_if_present(&working_database)?; + let working_database = + reset_working_database_path(&target_directory, &source.prepared.backup_digest); let source_bytes = std::fs::read(&source.prepared.backup_path) .map_err(|_| AdmissionSpaceTransitionError::Storage)?; - write_new_file(&working_database, &source_bytes) - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + if !working_database.exists() { + write_new_file(&working_database, &source_bytes) + .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + } self.source_pool .replace_database( working_database @@ -1160,7 +1181,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { &journal.target_generation, ); remove_file_if_present(&target_directory.join("source-final.sqlite"))?; - remove_sqlite_database_if_present(&target_directory.join("reset-working.sqlite"))?; + remove_reset_working_databases_if_present(&target_directory)?; self.manifest_store .clear_device_reset_journal() .await @@ -1624,6 +1645,32 @@ fn remove_sqlite_database_if_present(path: &Path) -> Result<(), AdmissionSpaceTr Ok(()) } +fn reset_working_database_path(target_directory: &Path, source_digest: &[u8; 32]) -> PathBuf { + target_directory.join(format!("reset-working-{}.sqlite", short_hex(source_digest))) +} + +fn remove_reset_working_databases_if_present( + target_directory: &Path, +) -> Result<(), AdmissionSpaceTransitionError> { + remove_sqlite_database_if_present(&target_directory.join("reset-working.sqlite"))?; + let entries = match std::fs::read_dir(target_directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(AdmissionSpaceTransitionError::Storage), + }; + for entry in entries { + let entry = entry.map_err(|_| AdmissionSpaceTransitionError::Storage)?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + return Err(AdmissionSpaceTransitionError::Storage); + }; + if file_name.starts_with("reset-working-") && file_name.ends_with(".sqlite") { + remove_sqlite_database_if_present(&entry.path())?; + } + } + Ok(()) +} + fn remove_directory_if_present(path: &Path) -> Result<(), AdmissionSpaceTransitionError> { match std::fs::remove_dir_all(path) { Ok(()) => Ok(()), diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index ac5694b..f3e4c81 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -959,6 +959,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> - 2026-08-25:修正 `uc-infra` admission transition 的 Windows 文件持久化语义:`MoveFileExW` 完成原子替换后不再把父目录当普通文件打开并同步,Unix 继续在 `rename` 后同步父目录;不改变分层、公共接口或 admission 业务规则。 +- 2026-08-25:补全 Windows 上 admission transition 的幂等重放。设备管理重置按源快照摘要隔离 working database,旧运行单元仍持有 SQLite 句柄时,新重试不再覆盖被锁文件;同空间切换以完整 active manifest 为提交点,提交后的重复推进只重新打开已激活 generation,不再覆盖当前数据库。临时 working database 仍由 reset 收尾统一清理;不改变公共接口、加密边界或 admission 业务规则。 + - 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 From 184adbe56f9cf567b4e25929ab49e60099273130 Mon Sep 17 00:00:00 2001 From: dmss <15844340+cwxsss@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:50:11 +0800 Subject: [PATCH 3/6] fix: harden Windows transition replay recovery --- .../security/admission_space_transition.rs | 289 +++++++++++++++--- docs/architecture/architecture-bible.md | 2 + 2 files changed, 251 insertions(+), 40 deletions(-) diff --git a/crates/uc-infra/src/security/admission_space_transition.rs b/crates/uc-infra/src/security/admission_space_transition.rs index 67fa0a2..942616d 100644 --- a/crates/uc-infra/src/security/admission_space_transition.rs +++ b/crates/uc-infra/src/security/admission_space_transition.rs @@ -43,7 +43,8 @@ use crate::search::render_payload::RenderPayloadCodec; use super::{ active_space_manifest_store::DeviceManagementResetJournalV1, ActiveSpaceManifestStore, - BlobCipherAdapter, DefaultSpaceAccessAdapter, EncryptedBlobStore, InMemorySession, + ActiveSpaceManifestStoreError, BlobCipherAdapter, DefaultSpaceAccessAdapter, + EncryptedBlobStore, InMemorySession, }; struct TargetSessionSubkeyDeriver(InMemorySession); @@ -122,6 +123,45 @@ struct TargetPersistedContentKeyEntryV2 { key: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SameSpaceActivationManifestState { + TargetCommitted, + SourceActive, + LegacySourceActive, +} + +fn classify_same_space_activation_manifest( + transition: &SameSpaceTransitionV1, + target_manifest: &ActiveSpaceManifestV2, + active_manifest: Option<&ActiveSpaceManifestV2>, + legacy_source_generation: [u8; 16], +) -> Result { + match active_manifest { + Some(manifest) if manifest == target_manifest => { + Ok(SameSpaceActivationManifestState::TargetCommitted) + } + Some(manifest) + if manifest.space_id == transition.target_space_id + && manifest.database_generation == transition.source_generation => + { + Ok(SameSpaceActivationManifestState::SourceActive) + } + None if transition.source_generation == legacy_source_generation => { + Ok(SameSpaceActivationManifestState::LegacySourceActive) + } + _ => Err(AdmissionSpaceTransitionError::RecoveryRequired), + } +} + +fn map_active_space_manifest_store_error( + error: ActiveSpaceManifestStoreError, +) -> AdmissionSpaceTransitionError { + match error { + ActiveSpaceManifestStoreError::Storage => AdmissionSpaceTransitionError::Storage, + ActiveSpaceManifestStoreError::Corrupt => AdmissionSpaceTransitionError::RecoveryRequired, + } +} + fn valid_relayed_group_updates( updates: &[uc_core::membership::PendingGroupUpdate], local_device_id: &DeviceId, @@ -469,7 +509,7 @@ impl DurableAdmissionSpaceTransition { transition.target_generation, )?) .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; self.source_pool .replace_database( target_database @@ -548,14 +588,18 @@ impl DurableAdmissionSpaceTransition { &transition.target_generation, ); let target_database = directory.join("target.sqlite"); - if self + let stored_manifest = self .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? - .as_ref() - == Some(&active_manifest) - { + .map_err(map_active_space_manifest_store_error)?; + let manifest_state = classify_same_space_activation_manifest( + transition, + &active_manifest, + stored_manifest.as_ref(), + self.generation(transition.attempt_id.as_bytes(), b"legacy-source"), + )?; + if manifest_state == SameSpaceActivationManifestState::TargetCommitted { self.source_pool .replace_database( target_database @@ -599,7 +643,7 @@ impl DurableAdmissionSpaceTransition { self.manifest_store .promote(&active_manifest) .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; self.source_pool .replace_database( target_database @@ -934,7 +978,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; if active_manifest .as_ref() .is_some_and(|manifest| manifest.space_id == target_space_id.as_ref()) @@ -967,7 +1011,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load_device_reset_journal() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? { Some(journal) if journal.target_space_id == target_space_id.as_ref() @@ -991,7 +1035,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { self.manifest_store .save_device_reset_journal(&journal) .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; let reset_source_directory = self .generations .generation_directory(&reset_id, &source_generation); @@ -1020,7 +1064,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .is_some_and(|manifest| manifest.space_id == target_space_id.as_ref()) { return Ok(()); @@ -1063,7 +1107,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .is_some_and(|manifest| manifest.space_id == target_space_id.as_ref()) { return Ok(()); @@ -1116,7 +1160,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { self.manifest_store .promote(&manifest) .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; self.source_pool .replace_database( finalized @@ -1138,7 +1182,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .ok_or(AdmissionSpaceTransitionError::Inconsistent)?; if active_manifest.space_id != target_space_id.as_ref() { return Err(AdmissionSpaceTransitionError::Inconsistent); @@ -1147,7 +1191,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { .manifest_store .load_device_reset_journal() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? else { return Ok(()); }; @@ -1185,7 +1229,7 @@ impl DeviceManagementResetDataPort for DurableAdmissionSpaceTransition { self.manifest_store .clear_device_reset_journal() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage) + .map_err(map_active_space_manifest_store_error) } } @@ -1215,7 +1259,7 @@ impl AdmissionSpaceTransitionPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; let target_generation = self.generation(input.attempt_id.as_bytes(), b"target-database"); let source_space = self.session.current_space_id().ok(); if source_space @@ -1436,7 +1480,7 @@ impl AdmissionSpaceTransitionPort for DurableAdmissionSpaceTransition { self.manifest_store .promote(&manifest) .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)?; + .map_err(map_active_space_manifest_store_error)?; self.source_pool .replace_database( target_database @@ -1496,7 +1540,7 @@ impl AdmissionSpaceTransitionPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .is_some_and(|manifest| manifest.space_id == fresh.target_space_id) { return Err(AdmissionSpaceTransitionError::RecoveryRequired); @@ -1519,7 +1563,7 @@ impl AdmissionSpaceTransitionPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .is_some_and(|manifest| { manifest.space_id == same.target_space_id && manifest.database_generation == same.target_generation @@ -1547,7 +1591,7 @@ impl AdmissionSpaceTransitionPort for DurableAdmissionSpaceTransition { .manifest_store .load() .await - .map_err(|_| AdmissionSpaceTransitionError::Storage)? + .map_err(map_active_space_manifest_store_error)? .is_some_and(|manifest| manifest.space_id == transition.target_space_id) { return Err(AdmissionSpaceTransitionError::RecoveryRequired); @@ -1634,7 +1678,6 @@ fn remove_file_if_present(path: &Path) -> Result<(), AdmissionSpaceTransitionErr } fn remove_sqlite_database_if_present(path: &Path) -> Result<(), AdmissionSpaceTransitionError> { - remove_file_if_present(path)?; let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -1642,7 +1685,7 @@ fn remove_sqlite_database_if_present(path: &Path) -> Result<(), AdmissionSpaceTr for suffix in ["-wal", "-shm"] { remove_file_if_present(&path.with_file_name(format!("{file_name}{suffix}")))?; } - Ok(()) + remove_file_if_present(path) } fn reset_working_database_path(target_directory: &Path, source_digest: &[u8; 32]) -> PathBuf { @@ -1652,25 +1695,46 @@ fn reset_working_database_path(target_directory: &Path, source_digest: &[u8; 32] fn remove_reset_working_databases_if_present( target_directory: &Path, ) -> Result<(), AdmissionSpaceTransitionError> { - remove_sqlite_database_if_present(&target_directory.join("reset-working.sqlite"))?; let entries = match std::fs::read_dir(target_directory) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(_) => return Err(AdmissionSpaceTransitionError::Storage), }; + let mut databases = std::collections::BTreeSet::new(); for entry in entries { let entry = entry.map_err(|_| AdmissionSpaceTransitionError::Storage)?; let file_name = entry.file_name(); let Some(file_name) = file_name.to_str() else { return Err(AdmissionSpaceTransitionError::Storage); }; - if file_name.starts_with("reset-working-") && file_name.ends_with(".sqlite") { - remove_sqlite_database_if_present(&entry.path())?; + if let Some(database_name) = reset_working_database_name(file_name) { + databases.insert(target_directory.join(database_name)); } } + for database in databases { + remove_sqlite_database_if_present(&database)?; + } Ok(()) } +fn reset_working_database_name(file_name: &str) -> Option<&str> { + let database_name = file_name + .strip_suffix("-wal") + .or_else(|| file_name.strip_suffix("-shm")) + .unwrap_or(file_name); + if database_name == "reset-working.sqlite" { + return Some(database_name); + } + let digest = database_name + .strip_prefix("reset-working-")? + .strip_suffix(".sqlite")?; + (digest.len() == 32 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then_some(database_name) +} + fn remove_directory_if_present(path: &Path) -> Result<(), AdmissionSpaceTransitionError> { match std::fs::remove_dir_all(path) { Ok(()) => Ok(()), @@ -2770,14 +2834,16 @@ mod tests { use uc_core::file_transfer::FileTransferEvent; use uc_core::ids::{BlobId, DeviceId, EntryId, EventId, RepresentationId, SpaceId}; use uc_core::membership::{ - AdmissionAttemptId, AdmissionChangeFacts, AdmissionContentKeyCatalogV1, - AdmissionContentKeyEntryV1, AdmissionSecurityCommitmentV1, AdmissionSpaceTransitionError, - AdmissionSpaceTransitionPort, AdmissionSpaceTransitionPreparationV2, - AdmissionSpaceTransitionResultV2, AdmissionSpaceTransitionStepV2, - AdmissionSpaceTransitionV2, BaseMembershipHistoryPositionV1, ContentKeyPurpose, - CrossSpaceTransitionPhaseV2, DeviceManagementResetDataPort, MembershipCredential, - PendingGroupUpdate, RevocationRepositoryPort, ADMISSION_SECURITY_COMMITMENT_FORMAT_V1, - ED25519_SIGNATURE_ALGORITHM_V1, + ActiveSpaceManifestV2, AdmissionAttemptId, AdmissionChangeFacts, + AdmissionContentKeyCatalogV1, AdmissionContentKeyEntryV1, AdmissionSecurityCommitmentV1, + AdmissionSpaceTransitionError, AdmissionSpaceTransitionPort, + AdmissionSpaceTransitionPreparationV2, AdmissionSpaceTransitionResultV2, + AdmissionSpaceTransitionStepV2, AdmissionSpaceTransitionV2, + BaseMembershipHistoryPositionV1, ContentKeyPurpose, CrossSpaceTransitionPhaseV2, + DeviceManagementResetDataPort, MembershipCredential, PendingGroupUpdate, + RevocationRepositoryPort, SameSpaceTransitionPhaseV1, SameSpaceTransitionV1, + ADMISSION_SECURITY_COMMITMENT_FORMAT_V1, ED25519_SIGNATURE_ALGORITHM_V1, + SAME_SPACE_TRANSITION_FORMAT_V1, }; use uc_core::ports::security::current_profile::CurrentProfilePort; use uc_core::ports::security::BlobCipherPort; @@ -2821,6 +2887,125 @@ mod tests { assert_eq!(ensure_reset_capacity(100, 100), Ok(())); } + #[test] + fn same_space_activation_replay_rejects_manifest_outside_target_source_or_legacy() { + let attempt_id = AdmissionAttemptId::from_bytes([0x21; 32]); + let transition = SameSpaceTransitionV1 { + transition_format_version: SAME_SPACE_TRANSITION_FORMAT_V1, + attempt_id, + target_space_id: "same-space".to_owned(), + source_generation: [0x22; 16], + target_generation: [0x23; 16], + target_keyslot_ref: b"retained-active-keyslot-v1".to_vec(), + target_workspace_ref: b"target-workspace".to_vec(), + phase: SameSpaceTransitionPhaseV1::ActivationStarted, + }; + let target_manifest = ActiveSpaceManifestV2::new( + "same-space".to_owned(), + [0x24; 16], + transition.target_generation, + [0x25; 16], + ) + .unwrap(); + let source_manifest = ActiveSpaceManifestV2::new( + "same-space".to_owned(), + [0x26; 16], + transition.source_generation, + [0x27; 16], + ) + .unwrap(); + let newer_manifest = + ActiveSpaceManifestV2::new("same-space".to_owned(), [0x28; 16], [0x29; 16], [0x2a; 16]) + .unwrap(); + + assert_eq!( + super::classify_same_space_activation_manifest( + &transition, + &target_manifest, + Some(&target_manifest), + [0x2b; 16], + ), + Ok(super::SameSpaceActivationManifestState::TargetCommitted) + ); + assert_eq!( + super::classify_same_space_activation_manifest( + &transition, + &target_manifest, + Some(&source_manifest), + [0x2b; 16], + ), + Ok(super::SameSpaceActivationManifestState::SourceActive) + ); + assert_eq!( + super::classify_same_space_activation_manifest( + &transition, + &target_manifest, + Some(&newer_manifest), + [0x2b; 16], + ), + Err(AdmissionSpaceTransitionError::RecoveryRequired) + ); + assert_eq!( + super::classify_same_space_activation_manifest( + &transition, + &target_manifest, + None, + [0x2b; 16], + ), + Err(AdmissionSpaceTransitionError::RecoveryRequired) + ); + + let legacy_transition = SameSpaceTransitionV1 { + source_generation: [0x2b; 16], + ..transition + }; + assert_eq!( + super::classify_same_space_activation_manifest( + &legacy_transition, + &target_manifest, + None, + [0x2b; 16], + ), + Ok(super::SameSpaceActivationManifestState::LegacySourceActive) + ); + } + + #[test] + fn reset_working_cleanup_discovers_orphaned_sidecars_and_is_reentrant() { + let directory = tempdir().unwrap(); + let scoped_database = super::reset_working_database_path(directory.path(), &[0xab; 32]); + let scoped_name = scoped_database.file_name().unwrap().to_string_lossy(); + let scoped_wal = directory.path().join(format!("{scoped_name}-wal")); + let scoped_shm = directory.path().join(format!("{scoped_name}-shm")); + let unrelated = directory + .path() + .join("reset-working-not-a-digest.sqlite-wal"); + std::fs::write(&scoped_wal, b"orphan wal").unwrap(); + std::fs::write(&scoped_shm, b"orphan shm").unwrap(); + std::fs::write(&unrelated, b"unrelated").unwrap(); + + super::remove_reset_working_databases_if_present(directory.path()).unwrap(); + + assert!(!scoped_wal.exists()); + assert!(!scoped_shm.exists()); + assert!(unrelated.exists()); + super::remove_reset_working_databases_if_present(directory.path()).unwrap(); + } + + #[test] + fn corrupt_active_manifest_maps_to_recovery_required() { + use crate::security::active_space_manifest_store::ActiveSpaceManifestStoreError; + + assert_eq!( + super::map_active_space_manifest_store_error(ActiveSpaceManifestStoreError::Corrupt), + AdmissionSpaceTransitionError::RecoveryRequired + ); + assert_eq!( + super::map_active_space_manifest_store_error(ActiveSpaceManifestStoreError::Storage), + AdmissionSpaceTransitionError::Storage + ); + } + #[test] fn write_new_file_replaces_existing_file_on_windows_without_directory_open_error() { let directory = tempdir().unwrap(); @@ -3590,6 +3775,7 @@ mod tests { transition, AdmissionSpaceTransitionV2::SameSpace(_) )); + let mut activation_started = None; let result = loop { session.clear(); SpaceAccessStore::try_resume_session(access.as_ref(), &target_space) @@ -3605,7 +3791,16 @@ mod tests { let replay = transitioner.advance(&transition).await.unwrap(); assert_eq!(first, replay); match first { - AdmissionSpaceTransitionStepV2::Advanced(next) => transition = next, + AdmissionSpaceTransitionStepV2::Advanced(next) => { + if matches!( + &next, + AdmissionSpaceTransitionV2::SameSpace(same) + if same.phase == SameSpaceTransitionPhaseV1::ActivationStarted + ) { + activation_started = Some(next.clone()); + } + transition = next; + } AdmissionSpaceTransitionStepV2::Finished(result) => break result, } }; @@ -3631,10 +3826,24 @@ mod tests { .await .unwrap(); assert_eq!(plaintext.as_bytes(), b"same-space content remains readable"); - assert_eq!( - manifest_store.load().await.unwrap().unwrap().space_id, - "same-space" - ); + let committed_manifest = manifest_store.load().await.unwrap().unwrap(); + assert_eq!(committed_manifest.space_id, "same-space"); + + let newer_manifest = + ActiveSpaceManifestV2::new("same-space".to_owned(), [0xb1; 16], [0xb2; 16], [0xb3; 16]) + .unwrap(); + assert_ne!(newer_manifest, committed_manifest); + manifest_store.promote(&newer_manifest).await.unwrap(); + let error = transitioner + .advance( + activation_started + .as_ref() + .expect("same-space transition must enter ActivationStarted"), + ) + .await + .expect_err("replay must not overwrite a manifest from a newer transition"); + assert_eq!(error, AdmissionSpaceTransitionError::RecoveryRequired); + assert_eq!(manifest_store.load().await.unwrap(), Some(newer_manifest)); } #[tokio::test] diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index f3e4c81..2fdcd1b 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -961,6 +961,8 @@ node scripts/release/verify-release-bundle.mjs <产物目录> - 2026-08-25:补全 Windows 上 admission transition 的幂等重放。设备管理重置按源快照摘要隔离 working database,旧运行单元仍持有 SQLite 句柄时,新重试不再覆盖被锁文件;同空间切换以完整 active manifest 为提交点,提交后的重复推进只重新打开已激活 generation,不再覆盖当前数据库。临时 working database 仍由 reset 收尾统一清理;不改变公共接口、加密边界或 admission 业务规则。 +- 2026-08-25:加固 Windows admission transition 恢复边界。同空间 `ActivationStarted` 重放只接受精确目标 manifest、同 Space 的来源数据库世代或可验证的无 manifest 旧资料状态,遇到其他活动 manifest 进入人工恢复且绝不覆盖;设备管理重置收尾可从孤立 `-wal`/`-shm` 反向识别 digest-scoped working database,并按辅助文件优先、主库最后的顺序幂等清理;活动 manifest 或重置记录损坏统一保留为 `RecoveryRequired`,不降级成普通存储失败。不改变公共接口、加密边界或 admission 业务规则。 + - 2026-08-20:为升级后当前成员范围不可用的问题增加不含用户资料的分类诊断日志,区分成员历史读取或解析、当前加入记录读取、沿革不一致、设备资料读取,以及有效成员缺少设备对应记录;日志只记录固定分类、数量和布尔值,不改变成员判断、失败策略、持久化格式或对外接口。 - 2026-08-20:单条历史记录的发送视图改用当前成员范围与历史可信关系的交集;历史关系和旧发送事实继续保留,当前范围不可用时查询失败。该修改补齐既有当前成员范围规则,不改变分层或所有权。 From 3fffecab2a8fc488a01933d3b7dd2ec69b62f93e Mon Sep 17 00:00:00 2001 From: dmss <15844340+cwxsss@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:55:38 +0800 Subject: [PATCH 4/6] test: cover recovered sponsor readmission --- .../src/space/convergence/admission/tests.rs | 168 ++++++++++++++++-- 1 file changed, 151 insertions(+), 17 deletions(-) diff --git a/crates/uc-application/src/space/convergence/admission/tests.rs b/crates/uc-application/src/space/convergence/admission/tests.rs index f73d680..4ef128a 100644 --- a/crates/uc-application/src/space/convergence/admission/tests.rs +++ b/crates/uc-application/src/space/convergence/admission/tests.rs @@ -4613,11 +4613,18 @@ async fn admission_recovery_starts_with_legacy_migration_import() { assert_eq!(recovery.calls.load(Ordering::SeqCst), 1); } -#[tokio::test] -async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fails() { +struct RecoveredSponsorAdmissionFixture { + _sponsor_dir: tempfile::TempDir, + repository: Arc, + owner: Arc, + attempt_id: uc_core::membership::AdmissionAttemptId, + prior_member_instance: uc_core::membership::MemberInstanceId, + activation: Arc, +} + +async fn recover_interrupted_sponsor_admission() -> RecoveredSponsorAdmissionFixture { use uc_core::membership::{ - AdmissionAttemptId, AdmissionOutboxPurposeV1, MembershipCredential, - SponsorAdmissionStageV1, SponsorAdmissionStateV1, VersionedMembershipHistory, + AdmissionAttemptId, MembershipCredential, SponsorAdmissionStageV1, SponsorAdmissionStateV1, ED25519_SIGNATURE_ALGORITHM_V1, }; @@ -4630,6 +4637,13 @@ async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fai let attempt_id = AdmissionAttemptId::from_bytes([0x74; 32]); let (candidate, base_history, candidate_event, commitment, activation_receipt) = durable_candidate_verification_fixture(attempt_id); + let lineage_id = candidate.lineage_id.clone(); + let uc_core::membership::MembershipOperationV2::AddDevice { admission } = + &candidate_event.operation + else { + unreachable!("fixture always creates AddDevice") + }; + let prior_member_instance = admission.facts.member_instance; let initiated = joiner_transaction .start_join( attempt_id, @@ -4759,22 +4773,56 @@ async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fai )); let resumed_activation = Arc::new(RecordingSponsorAdmissionSecurity::default()); - let mut resumed_deps = test_deps( - Arc::new(MemoryWorkspaceRepository::default()), - "sponsor", - Vec::new(), - ); + let sponsor_device = DeviceId::new("sponsor"); + let sponsor_member_instance = sponsor_credential.member_instance_id(&sponsor_device); + let workspace_repository = MemoryWorkspaceRepository::default(); + let mut workspace_state = WorkspaceConvergenceState::fresh(lineage_id.clone(), 1); + workspace_state.own_instance = Some(sponsor_member_instance); + workspace_repository + .save_state(&workspace_state) + .await + .unwrap(); + let mut resumed_deps = test_deps(Arc::new(workspace_repository), "sponsor", Vec::new()); resumed_deps.admission_attempts = Arc::clone(&sponsor_repository); + resumed_deps.prepare_sponsor_admission_security = resumed_activation.clone(); resumed_deps.activate_sponsor_admission_security = resumed_activation.clone(); resumed_deps.member_repo = member_repo.clone(); resumed_deps.member_signatures = Arc::new(CredentialBackedSigner { - device_id: DeviceId::new("sponsor"), + device_id: sponsor_device.clone(), credential: sponsor_credential, }); + resumed_deps.membership_identity = Arc::new(FixedMembershipIdentity { + space: SpaceId::from_string(lineage_id), + device_id: sponsor_device.clone(), + }); + resumed_deps.announcement_material = Arc::new(ConfiguredAnnouncementMaterial { + device_id: sponsor_device, + }); let resumed_owner = WorkspaceConvergence::new(resumed_deps); assert_eq!(resumed_owner.recover_pending_admissions().await.unwrap(), 1); - let recovered = sponsor_repository.load(attempt_id).await.unwrap().unwrap(); + + RecoveredSponsorAdmissionFixture { + _sponsor_dir: sponsor_dir, + repository: sponsor_repository, + owner: resumed_owner, + attempt_id, + prior_member_instance, + activation: resumed_activation, + } +} + +#[tokio::test] +async fn sponsor_recovery_finishes_durable_candidate_after_restart() { + use uc_core::membership::{AdmissionOutboxPurposeV1, VersionedMembershipHistory}; + + let fixture = recover_interrupted_sponsor_admission().await; + let recovered = fixture + .repository + .load(fixture.attempt_id) + .await + .unwrap() + .unwrap(); assert!(recovered.write_ahead_recovery.is_none()); assert!(recovered.completion.is_some()); assert_eq!( @@ -4788,7 +4836,8 @@ async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fai 1 ); let recovered_history = VersionedMembershipHistory::decode_persisted_v2( - &sponsor_repository + &fixture + .repository .load_membership_history_v2() .await .unwrap() @@ -4797,19 +4846,23 @@ async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fai ) .unwrap(); assert_eq!(recovered_history.active_members().len(), 2); + assert!(recovered_history + .active_members() + .contains(&fixture.prior_member_instance)); assert_eq!( - resumed_activation.activation_requests.lock().unwrap().len(), + fixture.activation.activation_requests.lock().unwrap().len(), 1 ); - resumed_owner.recover_pending_admissions().await.unwrap(); + fixture.owner.recover_pending_admissions().await.unwrap(); assert_eq!( - resumed_activation.activation_requests.lock().unwrap().len(), + fixture.activation.activation_requests.lock().unwrap().len(), 1 ); assert_eq!( - sponsor_repository - .load(attempt_id) + fixture + .repository + .load(fixture.attempt_id) .await .unwrap() .unwrap() @@ -4823,6 +4876,87 @@ async fn sponsor_recovery_finishes_the_same_activation_after_completion_save_fai ); } +#[tokio::test] +async fn sponsor_accepts_next_candidate_after_recovery_converges() { + use uc_core::membership::{ + AdmissionAttemptId, AdmissionOutboxPurposeV1, MembershipCredential, + VersionedMembershipHistory, ED25519_SIGNATURE_ALGORITHM_V1, + }; + use uc_core::pairing::{InvitationCode, JoinerRequest, PairingSecurityCapability}; + + let fixture = recover_interrupted_sponsor_admission().await; + let recovered_base = fixture + .owner + .verified_admission_base_history() + .await + .expect("recovered durable history remains a valid sponsor admission base"); + assert_eq!( + recovered_base.active_members(), + recovered_base.effective_members() + ); + let next_attempt_id = AdmissionAttemptId::from_bytes([0x82; 32]); + let invitation = InvitationCode::new("candidate-after-sponsor-recovery"); + let next_device = DeviceId::new("next-joiner"); + let next_credential = MembershipCredential::new(ED25519_SIGNATURE_ALGORITHM_V1, vec![0x83; 32]); + let next_member_instance = next_credential.member_instance_id(&next_device); + let mut next_facts = admission_facts_for(next_member_instance, &next_device); + next_facts.identity_signature = + DeterministicHistoricalVerifier.sign(&next_credential, &next_facts.signing_payload()); + let binding = crate::space::admission::adapter::stable_join_request_binding( + &next_device, + &next_facts.identity_fingerprint, + ); + let request_message = super::admission::durable_admission_message( + next_attempt_id, + AdmissionOutboxPurposeV1::JoinRequest, + invitation.as_str().as_bytes(), + None, + &binding, + ); + let request = JoinerRequest { + attempt_id: *next_attempt_id.as_bytes(), + join_id: [0x84; 16], + request_message_id: request_message.message_id, + invitation_code: invitation, + device_id: next_device, + device_name: next_facts.device_name.clone(), + identity_fingerprint: next_facts.identity_fingerprint.clone(), + nonce: Vec::new(), + transport_address_blob: next_facts.transport_address_blob.clone(), + security_capability: PairingSecurityCapability::ReliableGroupEpochV1, + key_package: b"next-candidate-key-package".to_vec(), + member_instance: next_member_instance, + membership_credential: next_credential, + resume_public_key: vec![0x85; 32], + admission: next_facts, + }; + + let result = fixture.owner.prepare_sponsor_candidate(&request).await; + let frame = match result { + Err(WorkspaceConvergenceError::AdmissionInProgress) => { + panic!("recovered sponsor admission still blocks the next candidate") + } + result => result.expect("recovered sponsor accepts a distinct next candidate"), + }; + let payload = + super::admission::DurableAdmissionCandidatePayloadV1::decode(&frame.payload).unwrap(); + let base_history = VersionedMembershipHistory::decode_persisted_v2( + &payload.base_membership_history, + &DeterministicHistoricalVerifier, + ) + .unwrap(); + + assert_eq!( + frame.kind, + uc_core::pairing::DurableAdmissionMessageKind::Candidate + ); + assert_eq!(frame.attempt_id, *next_attempt_id.as_bytes()); + assert!(base_history + .active_members() + .contains(&fixture.prior_member_instance)); + assert_eq!(base_history.active_members().len(), 2); +} + #[tokio::test] async fn committed_admission_records_the_effective_members_in_signed_history() { let a = instance(0x0a); From 6a0acd0affee51cf9e6d849fd0361969ca5ecb93 Mon Sep 17 00:00:00 2001 From: dmss <15844340+cwxsss@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:13:28 +0800 Subject: [PATCH 5/6] test: cover sequential device admission --- .../space_membership_auto_pairing_e2e.rs | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/uc-engine/tests/space_membership_auto_pairing_e2e.rs b/crates/uc-engine/tests/space_membership_auto_pairing_e2e.rs index 8561715..7839578 100644 --- a/crates/uc-engine/tests/space_membership_auto_pairing_e2e.rs +++ b/crates/uc-engine/tests/space_membership_auto_pairing_e2e.rs @@ -200,7 +200,7 @@ impl DeviceHarness { Box::new(EmptyClipboard), Box::new(EmptyFiles), ); - let config = EngineConfig::new("space-membership-e2e") + let config = EngineConfig::new(env!("CARGO_PKG_VERSION")) .with_rendezvous_base_url(self.rendezvous_base_url.clone()) .with_test_relay_fallback(allow_relay_fallback); let (engine, _events) = Engine::start(config, host) @@ -208,6 +208,13 @@ impl DeviceHarness { .expect("start complete engine"); engine } + + fn profile_lifecycle_marker(&self) -> Vec { + self.secure_storage + .get("profile_lifecycle_marker:v1") + .expect("read profile lifecycle marker") + .expect("profile lifecycle marker is initialized") + } } async fn start_engine_from_v019_data( @@ -470,6 +477,67 @@ async fn v11_marks_a_running_v019_peer_as_upgrade_required() { .expect("shut down upgraded engine A"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 6)] +async fn sponsor_pairs_two_devices_sequentially_without_reset() { + uc_engine::init_test_tracing(); + let rendezvous = mount_rendezvous().await; + let device_a = DeviceHarness::new(rendezvous.uri()); + let device_b = DeviceHarness::new(rendezvous.uri()); + let device_c = DeviceHarness::new(rendezvous.uri()); + + let engine_a = device_a.start_local_only().await; + let engine_b = device_b.start_local_only().await; + let engine_c = device_c.start_local_only().await; + let (space_id, a_id) = create_space(&engine_a, "Device A").await; + let a_profile_before_pairing = device_a.profile_lifecycle_marker(); + + let b_id = join_through(&engine_a, &engine_b, "Device B", &space_id).await; + wait_for_same_workspace_state_with_diagnostics( + "A and B after the first sequential admission", + &[&engine_a, &engine_b], + 2, + 2, + ) + .await; + + let c_id = join_through(&engine_a, &engine_c, "Device C", &space_id).await; + wait_for_same_workspace_state_with_diagnostics( + "A, B, and C after the second sequential admission", + &[&engine_a, &engine_b, &engine_c], + 3, + 3, + ) + .await; + + let trust = device_trust_summary(&engine_a).await; + assert_eq!(trust.local_device_id, a_id); + assert_eq!( + trust.local_membership, + uc_engine::DeviceMembershipSummary::Active + ); + let mut active_member_ids = trust + .devices + .iter() + .filter(|device| device.membership == uc_engine::DeviceMembershipSummary::Active) + .map(|device| device.device_id.as_str()) + .collect::>(); + active_member_ids.sort_unstable(); + let mut expected_member_ids = vec![a_id.as_str(), b_id.as_str(), c_id.as_str()]; + expected_member_ids.sort_unstable(); + assert_eq!(active_member_ids, expected_member_ids); + assert_eq!( + device_a.profile_lifecycle_marker(), + a_profile_before_pairing + ); + + for engine in [engine_a, engine_b, engine_c] { + engine + .shutdown(SHUTDOWN_TIMEOUT) + .await + .expect("shut down sequential admission engine"); + } +} + // 场景流程: // 1. A 创建空间,B 加入并保存 A、B 的完整成员记录。 // 2. A 离线后,仍在线的 B 让 C 加入;C 必须保存 A、B、C 的完整成员记录。 From ed3c6acac72e25070d4fca4b783025b131e28861 Mon Sep 17 00:00:00 2001 From: cwxsss Date: Mon, 24 Aug 2026 23:15:19 +0800 Subject: [PATCH 6/6] fix: preserve pairing invitations across sessions --- crates/uc-application/src/facade/mod.rs | 9 +-- .../src/facade/space_setup/facade.rs | 50 ++++++++++++-- .../src/facade/space_setup/mod.rs | 2 +- crates/uc-engine/src/assembly/host.rs | 1 + crates/uc-engine/src/assembly/lifecycle.rs | 2 + crates/uc-engine/src/assembly/sync_engine.rs | 68 ++++++++++--------- crates/uc-engine/src/runtime/mod.rs | 3 + 7 files changed, 92 insertions(+), 43 deletions(-) diff --git a/crates/uc-application/src/facade/mod.rs b/crates/uc-application/src/facade/mod.rs index c49b93d..75d1c9e 100644 --- a/crates/uc-application/src/facade/mod.rs +++ b/crates/uc-application/src/facade/mod.rs @@ -181,10 +181,11 @@ pub use settings::{ pub use space_setup::{ CancelInvitationError, CurrentInvitation, FactoryResetError, InitializeSpaceError, InitializeSpaceInput, InitializeSpaceResult, IssuePairingInvitationError, - IssuePairingInvitationResult, PairingInvitationAddressCandidate, QuerySetupStateError, - RedeemPairingInvitationError, RedeemPairingInvitationInput, RedeemPairingInvitationResult, - ResetSpaceError, SetupStateView, SpaceAdmissionDeps, SpaceFacade, SpaceFacadeDeps, - SpaceSessionDeps, SpaceTransitionDeps, UnlockSpaceError, UnlockSpaceInput, UnlockSpaceResult, + IssuePairingInvitationResult, PairingInvitationAddressCandidate, PairingInvitationRuntime, + QuerySetupStateError, RedeemPairingInvitationError, RedeemPairingInvitationInput, + RedeemPairingInvitationResult, ResetSpaceError, SetupStateView, SpaceAdmissionDeps, + SpaceFacade, SpaceFacadeDeps, SpaceSessionDeps, SpaceTransitionDeps, UnlockSpaceError, + UnlockSpaceInput, UnlockSpaceResult, }; pub use storage::{ ClearCacheResultView, StorageFacade, StorageFacadeDeps, StorageFacadeError, StorageStatsView, diff --git a/crates/uc-application/src/facade/space_setup/facade.rs b/crates/uc-application/src/facade/space_setup/facade.rs index b98ded5..c242f5b 100644 --- a/crates/uc-application/src/facade/space_setup/facade.rs +++ b/crates/uc-application/src/facade/space_setup/facade.rs @@ -357,6 +357,17 @@ pub struct SpaceFacade { local_device_id: DeviceId, } +/// Process-local invitation state shared by replacement network sessions. +/// +/// A network session can be rebuilt while a previous endpoint registration is +/// still draining. Sharing this runtime lets either session validate an +/// invitation issued by the current facade without persisting invitation +/// credentials across process restarts. +#[derive(Clone, Default)] +pub struct PairingInvitationRuntime { + holder: Arc, +} + impl SpaceFacade { /// Wire all use cases from a single [`SpaceFacadeDeps`] bundle and /// spawn the sponsor-side inbound pairing orchestrator. @@ -366,10 +377,13 @@ impl SpaceFacade { /// [`SpaceConvergenceAssembly::new`] so the application layer stays the /// single construction point (ADR-018). pub fn new(deps: SpaceFacadeDeps) -> Self { - Self::new_internal(deps) + Self::new_with_pairing_runtime(deps, PairingInvitationRuntime::default()) } - fn new_internal(deps: SpaceFacadeDeps) -> Self { + pub fn new_with_pairing_runtime( + deps: SpaceFacadeDeps, + pairing_invitation_runtime: PairingInvitationRuntime, + ) -> Self { let SpaceFacadeDeps { session, admission, @@ -441,9 +455,7 @@ impl SpaceFacade { current_app_version, }; - // Invitation holder is purely an internal flow-state component - // (§11.4) — construct it here so bootstrap never sees the type. - let invitation_holder = Arc::new(InMemoryPairingInvitationHolder::new()); + let invitation_holder = Arc::clone(&pairing_invitation_runtime.holder); // Slice4 P3 T3.2 · facade-local handle for `cancel_invitation` // / `query_setup_state` snapshots; the use case + orchestrator // already own their own `Arc::clone`s below. @@ -1052,7 +1064,7 @@ mod tests { RelationshipStateResetError, RelationshipStateResetPort, RevocationId, SpaceMember, SpaceSecurityStateResetError, SpaceSecurityStateResetPort, }; - use uc_core::pairing::invitation::InvitationCode; + use uc_core::pairing::invitation::{InvitationCode, PairingInvitation}; use uc_core::pairing::PairingSessionMessage; use uc_core::ports::pairing::{ DialError, DialOutcome, PairingEventPort, PairingSessionEvent, PairingSessionId, @@ -2308,6 +2320,32 @@ mod tests { assert_eq!(*inv.calls.lock().unwrap(), 1); } + #[tokio::test] + async fn pairing_invitation_runtime_shares_pending_state_across_sessions() { + let first_session = PairingInvitationRuntime::default(); + let replacement_session = first_session.clone(); + let issued_at = Utc::now(); + let code = InvitationCode::new("SHARED-01"); + let (invitation, _) = PairingInvitation::issue( + code.clone(), + issued_at, + issued_at + chrono::Duration::minutes(5), + DeviceId::new("device-1"), + 0, + ); + + first_session.holder.insert(invitation).await; + + assert_eq!( + replacement_session + .holder + .snapshot_earliest() + .await + .map(|(pending, _)| pending), + Some(code) + ); + } + #[tokio::test] async fn issue_pairing_invitation_forwards_network_not_started() { let (facade, inv, _peer) = make_facade( diff --git a/crates/uc-application/src/facade/space_setup/mod.rs b/crates/uc-application/src/facade/space_setup/mod.rs index c1ecebf..ed40653 100644 --- a/crates/uc-application/src/facade/space_setup/mod.rs +++ b/crates/uc-application/src/facade/space_setup/mod.rs @@ -25,7 +25,7 @@ pub use errors::{ QuerySetupStateError, RedeemPairingInvitationError, ResetSpaceError, TryResumeSessionError, UnlockSpaceError, }; -pub use facade::SpaceFacade; +pub use facade::{PairingInvitationRuntime, SpaceFacade}; pub use uc_observability_contract::analytics::PairingFailureReason; pub(crate) const LEGACY_SPACE_ID: &str = "space"; diff --git a/crates/uc-engine/src/assembly/host.rs b/crates/uc-engine/src/assembly/host.rs index e459a4c..87707fe 100644 --- a/crates/uc-engine/src/assembly/host.rs +++ b/crates/uc-engine/src/assembly/host.rs @@ -823,6 +823,7 @@ mod tests { None, None, None, + uc_application::facade::PairingInvitationRuntime::default(), ) .await .unwrap(); diff --git a/crates/uc-engine/src/assembly/lifecycle.rs b/crates/uc-engine/src/assembly/lifecycle.rs index 03776c9..dd1d0eb 100644 --- a/crates/uc-engine/src/assembly/lifecycle.rs +++ b/crates/uc-engine/src/assembly/lifecycle.rs @@ -50,6 +50,7 @@ pub async fn build_daemon_lifecycle( rendezvous_base_url: Option, relay_fallback_override: Option, iroh_bind_port_override: Option, + pairing_invitation_runtime: uc_application::facade::PairingInvitationRuntime, ) -> anyhow::Result { // 启动期 reconcile:把 peer_addr_repo / trusted_peer_repo 中 // member_repo 已不再持有的孤儿条目清掉,恢复设计意图的不变量 @@ -147,6 +148,7 @@ pub async fn build_daemon_lifecycle( #[cfg(feature = "lan-compat")] mobile_sync_ports, iroh_config, + pairing_invitation_runtime, ) .await .map_err(|e| anyhow::anyhow!("Slice 1+ assembly build failed: {e}"))?; diff --git a/crates/uc-engine/src/assembly/sync_engine.rs b/crates/uc-engine/src/assembly/sync_engine.rs index 1c04f7a..127d8c4 100644 --- a/crates/uc-engine/src/assembly/sync_engine.rs +++ b/crates/uc-engine/src/assembly/sync_engine.rs @@ -594,6 +594,7 @@ pub async fn build_sync_engine_assembly( current_app_version: &str, #[cfg(feature = "lan-compat")] mobile_sync_ports: uc_mobile_lan::MobileSyncPorts, iroh_config: IrohNodeConfig, + pairing_invitation_runtime: uc_application::facade::PairingInvitationRuntime, ) -> Result { let upgrade = UpgradeFacade::new(UpgradeFacadeDeps { app_version_state: Arc::clone(&deps.app_version_state), @@ -920,39 +921,42 @@ pub async fn build_sync_engine_assembly( let local_identity: Arc = identity_store; let convergence_assembly = Arc::new(convergence_assembly); - let facade = Arc::new(SpaceFacade::new(SpaceFacadeDeps { - session: SpaceSessionDeps { - space_access: deps.security.space_access_ports.clone(), - setup_status: Arc::clone(&deps.setup_status), - mobile_consumable_backfill: Arc::clone(&deps.clipboard.mobile_consumable_backfill), - legacy_profile_isolation_required, - app_version_state: Arc::clone(&deps.app_version_state), - current_app_version: current_app_version.to_owned(), - }, - admission: SpaceAdmissionDeps { - local_identity: Arc::clone(&local_identity), - device_identity: Arc::clone(&deps.device.device_identity), - member_repo: Arc::clone(&deps.device.member_repo), - settings: Arc::clone(&deps.settings), - clock: Arc::clone(&deps.system.clock), - pairing_invitation: handlers.invitation, - pairing_invitation_addresses: handlers.invitation_addresses, - pairing_invitation_by_address: handlers.invitation_by_address, - pairing_session: handlers.session, - pairing_events: handlers.events, - proof_port, - trusted_peer_repo: Arc::clone(&shared.trusted_peer_repo), - peer_addr_repo: Arc::clone(&space_setup.peer_addr_repo), - presence: Arc::clone(&presence), - analytics: Arc::clone(&space_setup.analytics_facade), - convergence: Arc::clone(&convergence_assembly), - }, - transition: SpaceTransitionDeps { - device_management_reset_data: Arc::clone(&space_setup.device_management_reset_data), - relationship_reset: Arc::clone(&space_setup.relationship_reset), - space_security_reset: Arc::clone(&space_setup.space_security_reset), + let facade = Arc::new(SpaceFacade::new_with_pairing_runtime( + SpaceFacadeDeps { + session: SpaceSessionDeps { + space_access: deps.security.space_access_ports.clone(), + setup_status: Arc::clone(&deps.setup_status), + mobile_consumable_backfill: Arc::clone(&deps.clipboard.mobile_consumable_backfill), + legacy_profile_isolation_required, + app_version_state: Arc::clone(&deps.app_version_state), + current_app_version: current_app_version.to_owned(), + }, + admission: SpaceAdmissionDeps { + local_identity: Arc::clone(&local_identity), + device_identity: Arc::clone(&deps.device.device_identity), + member_repo: Arc::clone(&deps.device.member_repo), + settings: Arc::clone(&deps.settings), + clock: Arc::clone(&deps.system.clock), + pairing_invitation: handlers.invitation, + pairing_invitation_addresses: handlers.invitation_addresses, + pairing_invitation_by_address: handlers.invitation_by_address, + pairing_session: handlers.session, + pairing_events: handlers.events, + proof_port, + trusted_peer_repo: Arc::clone(&shared.trusted_peer_repo), + peer_addr_repo: Arc::clone(&space_setup.peer_addr_repo), + presence: Arc::clone(&presence), + analytics: Arc::clone(&space_setup.analytics_facade), + convergence: Arc::clone(&convergence_assembly), + }, + transition: SpaceTransitionDeps { + device_management_reset_data: Arc::clone(&space_setup.device_management_reset_data), + relationship_reset: Arc::clone(&space_setup.relationship_reset), + space_security_reset: Arc::clone(&space_setup.space_security_reset), + }, }, - })); + pairing_invitation_runtime, + )); // Slice 2 Phase 1 · T9:roster 门面和 space_setup facade 共享同一组 // 实例(`member_repo` / `local_identity` / `presence`),这样 F1 hook diff --git a/crates/uc-engine/src/runtime/mod.rs b/crates/uc-engine/src/runtime/mod.rs index 571c835..94a6e90 100644 --- a/crates/uc-engine/src/runtime/mod.rs +++ b/crates/uc-engine/src/runtime/mod.rs @@ -74,6 +74,7 @@ struct SessionFactory { network_recovery: Arc, recovery_generation: Arc, profile_convergence: Arc, + pairing_invitation_runtime: uc_application::facade::PairingInvitationRuntime, } struct ProductionSession { @@ -343,6 +344,7 @@ impl ProductionRuntime { network_recovery: Arc::clone(&network_recovery), recovery_generation: Arc::new(AtomicU64::new(0)), profile_convergence: Arc::clone(&profile_convergence), + pairing_invitation_runtime: uc_application::facade::PairingInvitationRuntime::default(), }); session_supervisor.configure_factory(Arc::clone(&session_factory)); session_supervisor.resume().await?; @@ -416,6 +418,7 @@ impl ProductionRuntime { factory.rendezvous_base_url.clone(), factory.relay_fallback_override, factory.iroh_bind_port_override, + factory.pairing_invitation_runtime.clone(), ) .await .map_err(|error| startup_error("p2p session", error))?;