feat(desktop): isolate private data by account

This commit is contained in:
冰朔 2026-08-16 18:25:57 +08:00
commit 1d2a8a2f63
12 changed files with 143 additions and 60 deletions

View file

@ -15,7 +15,9 @@
}, },
"registry": { "registry": {
"owner": "HOLOLAKE_NATIVE_RUST_CORE", "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, "stored_fields_include_credentials": false,
"atomic_write": true, "atomic_write": true,
"restart_readback": true "restart_readback": true

View file

@ -4,7 +4,9 @@
"state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING",
"native_storage": { "native_storage": {
"owner": "HOLOLAKE_NATIVE_RUST_CORE", "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", "engine": "LOCAL_GIT_WITH_DOCUMENT_TREE",
"webview_direct_filesystem_access": false, "webview_direct_filesystem_access": false,
"automatic_server_upload": false "automatic_server_upload": false
@ -39,7 +41,7 @@
}, },
"legacy_compatibility": { "legacy_compatibility": {
"source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA", "source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA",
"mode": "READ_ONLY_SEPARATE_ROOT", "mode": "NOT_AUTO_PROJECTED_EXPLICIT_OWNER_MIGRATION_ONLY",
"in_place_migration": false, "in_place_migration": false,
"source_modification_allowed": false, "source_modification_allowed": false,
"tolaria_surface_used": false "tolaria_surface_used": false
@ -51,7 +53,7 @@
"folder_import_search_and_restart_readback": true, "folder_import_search_and_restart_readback": true,
"public_signed_runtime_acceptance": false, "public_signed_runtime_acceptance": false,
"legacy_data_migrated": false, "legacy_data_migrated": false,
"legacy_data_available_read_only": true, "legacy_data_available_read_only": false,
"deduplication_runtime_tested": true, "deduplication_runtime_tested": true,
"idempotent_import_runtime_tested": true, "idempotent_import_runtime_tested": true,
"native_edit_runtime_tested": true, "native_edit_runtime_tested": true,

View file

@ -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 provenance = JSON.parse(read('audit/knowledge-workspace-migration-provenance.json'))
const knowledgeRust = read('src-tauri/src/knowledge_base.rs') const knowledgeRust = read('src-tauri/src/knowledge_base.rs')
const codeRust = read('src-tauri/src/code_channel.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 lib = read('src-tauri/src/lib.rs')
const ui = read('src/main.tsx') 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.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.in_place_migration, false)
assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false) assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false)
assert.equal(provenance.legacy_data_boundary.modified, 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/) 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', () => { 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.maximum_files, 1000)
assert.equal(knowledgeContract.folder_import.symlinks_followed, false) assert.equal(knowledgeContract.folder_import.symlinks_followed, false)

View file

@ -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<PathBuf, String> {
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<String, String> {
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"));
}
}

View file

@ -8,7 +8,7 @@ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use url::Url; use url::Url;
use uuid::Uuid; use uuid::Uuid;
@ -175,11 +175,7 @@ pub async fn read_code_channel_file(
} }
fn code_channel_root(app: &AppHandle) -> Result<PathBuf, String> { fn code_channel_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app let root = crate::authenticated_storage::account_storage_root(app, "code-channel-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
.join("code-channel-v1");
ensure_root(&root)?; ensure_root(&root)?;
Ok(root) Ok(root)
} }

View file

@ -5,7 +5,7 @@ use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::AppHandle;
use uuid::Uuid; use uuid::Uuid;
const SESSION_SCHEMA: &str = "hololake.direct-local-session/v1"; 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<PathBuf, String> { pub(crate) fn direct_session_root(app: &AppHandle) -> Result<PathBuf, String> {
let app_data = app let root = crate::authenticated_storage::account_storage_root(app, "direct-local-session-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?;
let root = app_data.join("direct-local-session-v1");
fs::create_dir_all(&root) fs::create_dir_all(&root)
.map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?; .map_err(|error| format!("HOLOLAKE_DIRECT_SESSION_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize() root.canonicalize()

View file

@ -452,22 +452,10 @@ pub async fn select_and_import_knowledge_folder(
} }
fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), String> { fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), String> {
let native = app let native = crate::authenticated_storage::account_storage_root(app, "knowledge-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?
.join("knowledge-v1");
ensure_native_knowledge_root(&native)?; ensure_native_knowledge_root(&native)?;
let legacy = dirs::home_dir() // 旧全局知识库不得自动投影给任一新登录账号;后续只允许用户显式迁移。
.map(|home| { Ok((native, None))
home.join("Library")
.join("Application Support")
.join("hololake-desktop")
.join("data")
.join("knowledge-base")
})
.filter(|path| path.join("docs").is_dir());
Ok((native, legacy))
} }
/// 写私有文件的跨平台选项unix 上只许本人读写Windows 上不做额外权限设置。 /// 写私有文件的跨平台选项unix 上只许本人读写Windows 上不做额外权限设置。

View file

@ -1,4 +1,5 @@
mod circular_lake_membrane; mod circular_lake_membrane;
mod authenticated_storage;
mod code_channel; mod code_channel;
mod code_repo_login; mod code_repo_login;
mod direct_local_broker; mod direct_local_broker;

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::AppHandle;
use uuid::Uuid; use uuid::Uuid;
const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1"; 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<PathBuf, String> { fn personal_channel_database(app: &AppHandle) -> Result<PathBuf, String> {
let app_data = app let root = crate::authenticated_storage::account_storage_root(app, "personal-channel-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?;
let root = app_data.join("personal-channel-v1");
create_private_directory(&root)?; create_private_directory(&root)?;
Ok(root.join("personal-channel.sqlite3")) Ok(root.join("personal-channel.sqlite3"))
} }

View file

@ -9,7 +9,7 @@ use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::AppHandle;
use uuid::Uuid; use uuid::Uuid;
const JOURNAL_SCHEMA: &str = "hololake.pncc-stage-one-receipt-projection-journal/v1"; 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<PathBuf, String> { pub(crate) fn pncc_projection_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app let root = crate::authenticated_storage::account_storage_root(app, "pncc-receipt-projection-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))?
.join("pncc-stage-one-v1")
.join("receipt-projection");
fs::create_dir_all(&root) fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?; .map_err(|error| format!("PNCC_PROJECTION_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize() root.canonicalize()

View file

@ -12,7 +12,7 @@ use std::os::unix::fs::OpenOptionsExt;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use tauri::{AppHandle, Manager}; use tauri::AppHandle;
use tauri_plugin_dialog::DialogExt; use tauri_plugin_dialog::DialogExt;
use uuid::Uuid; use uuid::Uuid;
@ -153,12 +153,7 @@ pub async fn confirm_pncc_repository_mount(
} }
pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> { pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-mounts-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))?
.join("pncc-stage-one-v1")
.join("repository-mounts");
fs::create_dir_all(&root) fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?; .map_err(|error| format!("PNCC_REPOSITORY_MOUNT_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize() root.canonicalize()
@ -166,12 +161,7 @@ pub(crate) fn pncc_repository_mount_root(app: &AppHandle) -> Result<PathBuf, Str
} }
fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> { fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> {
let root = app let root = crate::authenticated_storage::account_storage_root(app, "pncc-repository-candidates-v1")?;
.path()
.app_data_dir()
.map_err(|error| format!("PNCC_APP_DATA_UNAVAILABLE: {error}"))?
.join("pncc-stage-one-v1")
.join("repository-candidates");
fs::create_dir_all(&root) fs::create_dir_all(&root)
.map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?; .map_err(|error| format!("PNCC_REPOSITORY_CANDIDATE_STORAGE_UNAVAILABLE: {error}"))?;
root.canonicalize() root.canonicalize()

View file

@ -718,6 +718,15 @@ function HoloLakeApp() {
// 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。 // 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。
setLoginRising(true) setLoginRising(true)
await new Promise((resolve) => setTimeout(resolve, 1300)) 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() }) setRepoLogin({ username: receipt.username, host: receipt.host, domain: receipt.domain, signedInAtUnixMs: Date.now() })
setLoginRising(false) setLoginRising(false)
setLoginPassword('') setLoginPassword('')
@ -726,6 +735,14 @@ function HoloLakeApp() {
} }
const signOutRepo = async () => { const signOutRepo = async () => {
try { await invoke('sign_out_code_repo_login') } catch { /* 登出以本机清场为准 */ } 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) setRepoLogin(null)
} }
const initializeIdentity = async (event: React.FormEvent) => { const initializeIdentity = async (event: React.FormEvent) => {