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

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

View file

@ -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<PathBuf, String> {
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()

View file

@ -452,22 +452,10 @@ pub async fn select_and_import_knowledge_folder(
}
fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option<PathBuf>), 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 上不做额外权限设置。

View file

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

View file

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

View file

@ -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<PathBuf, String> {
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()

View file

@ -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<PathBuf, String> {
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<PathBuf, Str
}
fn pncc_repository_candidate_root(app: &AppHandle) -> Result<PathBuf, String> {
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()