From 1d2a8a2f63f52fc20758edff538e3f70f10421f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sun, 16 Aug 2026 18:25:57 +0800 Subject: [PATCH] feat(desktop): isolate private data by account --- .../contracts/code-channel.json | 4 +- .../contracts/knowledge-workspace.json | 8 +- .../scripts/knowledge-code-channel.test.mjs | 18 +++- .../src-tauri/src/authenticated_storage.rs | 88 +++++++++++++++++++ .../src-tauri/src/code_channel.rs | 8 +- .../src-tauri/src/direct_local_session.rs | 8 +- .../src-tauri/src/knowledge_base.rs | 18 +--- .../src-tauri/src/lib.rs | 1 + .../src-tauri/src/personal_channel.rs | 8 +- .../src-tauri/src/pncc_receipt_projection.rs | 9 +- .../src-tauri/src/pncc_repository_binding.rs | 16 +--- .../hololake-native-desktop/src/main.tsx | 17 ++++ 12 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 product-source/hololake-native-desktop/src-tauri/src/authenticated_storage.rs diff --git a/product-source/hololake-native-desktop/contracts/code-channel.json b/product-source/hololake-native-desktop/contracts/code-channel.json index 33cd103ba..8141364d5 100644 --- a/product-source/hololake-native-desktop/contracts/code-channel.json +++ b/product-source/hololake-native-desktop/contracts/code-channel.json @@ -15,7 +15,9 @@ }, "registry": { "owner": "HOLOLAKE_NATIVE_RUST_CORE", - "location": "TAURI_APP_DATA_CODE_CHANNEL_V1", + "location": "TAURI_APP_DATA_ACCOUNTS_V1_HASHED_ACCOUNT_CODE_CHANNEL_V1", + "authenticated_account_required": true, + "cross_account_projection_allowed": false, "stored_fields_include_credentials": false, "atomic_write": true, "restart_readback": true diff --git a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json index 19cd0551a..388b1042a 100644 --- a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json +++ b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json @@ -4,7 +4,9 @@ "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", "native_storage": { "owner": "HOLOLAKE_NATIVE_RUST_CORE", - "location": "TAURI_APP_DATA_KNOWLEDGE_V1", + "location": "TAURI_APP_DATA_ACCOUNTS_V1_HASHED_ACCOUNT_KNOWLEDGE_V1", + "authenticated_account_required": true, + "cross_account_projection_allowed": false, "engine": "LOCAL_GIT_WITH_DOCUMENT_TREE", "webview_direct_filesystem_access": false, "automatic_server_upload": false @@ -39,7 +41,7 @@ }, "legacy_compatibility": { "source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA", - "mode": "READ_ONLY_SEPARATE_ROOT", + "mode": "NOT_AUTO_PROJECTED_EXPLICIT_OWNER_MIGRATION_ONLY", "in_place_migration": false, "source_modification_allowed": false, "tolaria_surface_used": false @@ -51,7 +53,7 @@ "folder_import_search_and_restart_readback": true, "public_signed_runtime_acceptance": false, "legacy_data_migrated": false, - "legacy_data_available_read_only": true, + "legacy_data_available_read_only": false, "deduplication_runtime_tested": true, "idempotent_import_runtime_tested": true, "native_edit_runtime_tested": true, diff --git a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs index 720c5a410..dbdfb6951 100644 --- a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs +++ b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs @@ -8,19 +8,31 @@ const codeContract = JSON.parse(read('contracts/code-channel.json')) const provenance = JSON.parse(read('audit/knowledge-workspace-migration-provenance.json')) const knowledgeRust = read('src-tauri/src/knowledge_base.rs') const codeRust = read('src-tauri/src/code_channel.rs') +const authenticatedStorageRust = read('src-tauri/src/authenticated_storage.rs') const lib = read('src-tauri/src/lib.rs') const ui = read('src/main.tsx') -test('knowledge workspace is HoloLake-owned and keeps the legacy source read-only', () => { +test('knowledge workspace is HoloLake-owned, account-scoped, and never auto-projects legacy data', () => { assert.equal(knowledgeContract.native_storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') - assert.equal(knowledgeContract.legacy_compatibility.mode, 'READ_ONLY_SEPARATE_ROOT') + assert.equal(knowledgeContract.native_storage.authenticated_account_required, true) + assert.equal(knowledgeContract.native_storage.cross_account_projection_allowed, false) + assert.equal(knowledgeContract.legacy_compatibility.mode, 'NOT_AUTO_PROJECTED_EXPLICIT_OWNER_MIGRATION_ONLY') assert.equal(knowledgeContract.legacy_compatibility.in_place_migration, false) assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false) assert.equal(provenance.legacy_data_boundary.modified, false) - assert.match(knowledgeRust, /join\("knowledge-v1"\)/) + assert.match(knowledgeRust, /account_storage_root\(app, "knowledge-v1"\)/) + assert.match(authenticatedStorageRust, /HOLOLAKE_AUTHENTICATED_ACCOUNT_REQUIRED/) + assert.match(authenticatedStorageRust, /accounts-v1/) + assert.doesNotMatch(authenticatedStorageRust, /join\(&session\.username\)/) assert.match(knowledgeRust, /source_docs_root/) }) +test('code channel uses the same authenticated account boundary', () => { + assert.equal(codeContract.registry.authenticated_account_required, true) + assert.equal(codeContract.registry.cross_account_projection_allowed, false) + assert.match(codeRust, /account_storage_root\(app, "code-channel-v1"\)/) +}) + test('folder import is bounded, idempotent, conflict-safe and creates a local Git receipt', () => { assert.equal(knowledgeContract.folder_import.maximum_files, 1000) assert.equal(knowledgeContract.folder_import.symlinks_followed, false) diff --git a/product-source/hololake-native-desktop/src-tauri/src/authenticated_storage.rs b/product-source/hololake-native-desktop/src-tauri/src/authenticated_storage.rs new file mode 100644 index 000000000..ef0a0cce3 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/authenticated_storage.rs @@ -0,0 +1,88 @@ +//! 登录后的账号隔离存储根。 +//! +//! 私人频道、知识库和代码频道不得在编号与仓库账号验证完成前读取, +//! 也不得让两个仓库账号共享同一份本机数据目录。 + +use ring::digest::{digest, SHA256}; +use std::fs; +use std::path::PathBuf; +use tauri::{AppHandle, Manager}; + +use crate::code_repo_login::{self, LoginSession}; + +pub(crate) fn account_storage_root(app: &AppHandle, namespace: &str) -> Result { + let session = code_repo_login::current_login_session(app)? + .ok_or_else(|| "HOLOLAKE_AUTHENTICATED_ACCOUNT_REQUIRED".to_string())?; + let account_id = account_scope_id(&session)?; + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? + .join("accounts-v1") + .join(account_id) + .join(validate_namespace(namespace)?); + fs::create_dir_all(&root) + .map_err(|error| format!("HOLOLAKE_ACCOUNT_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_ACCOUNT_STORAGE_PERMISSION_FAILED: {error}"))?; + } + Ok(root) +} + +fn validate_namespace(namespace: &str) -> Result<&str, String> { + if !namespace.is_empty() + && namespace.len() <= 48 + && namespace + .chars() + .all(|item| item.is_ascii_lowercase() || item.is_ascii_digit() || item == '-') + { + Ok(namespace) + } else { + Err("HOLOLAKE_ACCOUNT_STORAGE_NAMESPACE_INVALID".into()) + } +} + +fn account_scope_id(session: &LoginSession) -> Result { + if session.username.is_empty() || session.host.is_empty() || session.domain.is_empty() { + return Err("HOLOLAKE_LOGIN_SESSION_SCOPE_INVALID".into()); + } + let material = format!("{}\0{}\0{}", session.domain, session.host, session.username); + let value = digest(&SHA256, material.as_bytes()); + Ok(format!("account-{}", hex(value.as_ref()))) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session(username: &str) -> LoginSession { + LoginSession { + username: username.into(), + host: "guanghulab.com".into(), + domain: "FIFTH_DOMAIN".into(), + signed_in_at_unix_ms: 1, + } + } + + #[test] + fn different_accounts_never_share_a_storage_scope() { + assert_ne!( + account_scope_id(&session("bingshuo")).unwrap(), + account_scope_id(&session("another-human")).unwrap() + ); + } + + #[test] + fn account_scope_is_stable_and_does_not_expose_the_username() { + let first = account_scope_id(&session("bingshuo")).unwrap(); + assert_eq!(first, account_scope_id(&session("bingshuo")).unwrap()); + assert!(!first.contains("bingshuo")); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs index f4c182654..451ec1b9b 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs @@ -8,7 +8,7 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; use url::Url; use uuid::Uuid; @@ -175,11 +175,7 @@ pub async fn read_code_channel_file( } fn code_channel_root(app: &AppHandle) -> Result { - let root = app - .path() - .app_data_dir() - .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? - .join("code-channel-v1"); + let root = crate::authenticated_storage::account_storage_root(app, "code-channel-v1")?; ensure_root(&root)?; Ok(root) } diff --git a/product-source/hololake-native-desktop/src-tauri/src/direct_local_session.rs b/product-source/hololake-native-desktop/src-tauri/src/direct_local_session.rs index 869fac5ac..dc26156fd 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/direct_local_session.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/direct_local_session.rs @@ -5,7 +5,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use uuid::Uuid; const SESSION_SCHEMA: &str = "hololake.direct-local-session/v1"; @@ -192,11 +192,7 @@ pub async fn append_direct_local_session_event( } pub(crate) fn direct_session_root(app: &AppHandle) -> Result { - let app_data = app - .path() - .app_data_dir() - .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?; - let root = app_data.join("direct-local-session-v1"); + let root = crate::authenticated_storage::account_storage_root(app, "direct-local-session-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() diff --git a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs index 9d65460b3..81763f401 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -452,22 +452,10 @@ pub async fn select_and_import_knowledge_folder( } fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option), String> { - let native = app - .path() - .app_data_dir() - .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? - .join("knowledge-v1"); + let native = crate::authenticated_storage::account_storage_root(app, "knowledge-v1")?; ensure_native_knowledge_root(&native)?; - let legacy = dirs::home_dir() - .map(|home| { - home.join("Library") - .join("Application Support") - .join("hololake-desktop") - .join("data") - .join("knowledge-base") - }) - .filter(|path| path.join("docs").is_dir()); - Ok((native, legacy)) + // 旧全局知识库不得自动投影给任一新登录账号;后续只允许用户显式迁移。 + Ok((native, None)) } /// 写私有文件的跨平台选项:unix 上只许本人读写,Windows 上不做额外权限设置。 diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index 36fd774fd..ffe0efc74 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod circular_lake_membrane; +mod authenticated_storage; mod code_channel; mod code_repo_login; mod direct_local_broker; diff --git a/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs index 3a1dbc379..1cea28c34 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use uuid::Uuid; const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1"; @@ -148,11 +148,7 @@ pub async fn transition_personal_channel_task( } fn personal_channel_database(app: &AppHandle) -> Result { - let app_data = app - .path() - .app_data_dir() - .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?; - let root = app_data.join("personal-channel-v1"); + let root = crate::authenticated_storage::account_storage_root(app, "personal-channel-v1")?; create_private_directory(&root)?; Ok(root.join("personal-channel.sqlite3")) } diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs index 1972d437e..c4fc1f877 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_receipt_projection.rs @@ -9,7 +9,7 @@ use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use uuid::Uuid; const JOURNAL_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-journal/v1"; @@ -90,12 +90,7 @@ pub async fn query_pncc_receipt_projection( } pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result { - let root = app - .path() - .app_data_dir() - .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? - .join("pncc-stage-one-v1") - .join("receipt-projection"); + let root = crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() diff --git a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs index 9162ebb23..919e91771 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/pncc_repository_binding.rs @@ -12,7 +12,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Output}; use std::time::{SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; use uuid::Uuid; @@ -153,12 +153,7 @@ pub async fn confirm_pncc_repository_mount( } pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result { - let root = app - .path() - .app_data_dir() - .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? - .join("pncc-stage-one-v1") - .join("repository-mounts"); + let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() @@ -166,12 +161,7 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result Result { - let root = app - .path() - .app_data_dir() - .map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))? - .join("pncc-stage-one-v1") - .join("repository-candidates"); + let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?; fs::create_dir_all(&root) .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?; root.canonicalize() diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 142fe0759..ac65859d9 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -718,6 +718,15 @@ function HoloLakeApp() { // 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。 setLoginRising(true) await new Promise((resolve) => setTimeout(resolve, 1300)) + // 账号切换时先清空上一账号的内存投影,绝不让旧页面在新会话首帧闪现。 + setPersonal(previewPersonal) + setKnowledge(previewKnowledge) + setCodeChannels(previewCode) + setActiveDocument(null) + setActiveChannel(null) + setCodeTree(null) + setActiveCodeFile(null) + setReceipts([]) setRepoLogin({ username: receipt.username, host: receipt.host, domain: receipt.domain, signedInAtUnixMs: Date.now() }) setLoginRising(false) setLoginPassword('') @@ -726,6 +735,14 @@ function HoloLakeApp() { } const signOutRepo = async () => { try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ } + setPersonal(previewPersonal) + setKnowledge(previewKnowledge) + setCodeChannels(previewCode) + setActiveDocument(null) + setActiveChannel(null) + setCodeTree(null) + setActiveCodeFile(null) + setReceipts([]) setRepoLogin(null) } const initializeIdentity = async (event: React.FormEvent) => {