// SPDX-License-Identifier: AGPL-3.0-or-later // Clean-room native migration of the HoloLake Era knowledge-workspace contract. use ring::digest::{digest, SHA256}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io::Write; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::{Component, Path, PathBuf}; use std::process::Command; use std::time::UNIX_EPOCH; use tauri::{AppHandle, Manager}; use tauri_plugin_dialog::DialogExt; use uuid::Uuid; const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1"; const MAX_TREE_DOCUMENTS: usize = 5_000; const MAX_IMPORT_FILES: usize = 1_000; const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; const MAX_READ_BYTES: u64 = 2 * 1024 * 1024; const MAX_SEARCH_RESULTS: usize = 100; #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeDocumentSummary { pub source: &'static str, pub path: String, pub title: String, pub updated_at_unix_ms: u128, pub size_bytes: u64, pub content_sha256: String, pub duplicate_count: usize, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeSnapshot { pub schema: &'static str, pub state: &'static str, pub native_root: String, pub legacy_available: bool, pub legacy_root: Option, pub documents: Vec, pub raw_document_count: usize, pub unique_document_count: usize, pub duplicate_document_count: usize, pub truncated: bool, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ReadKnowledgeDocumentInput { pub source: String, pub path: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeDocument { pub source: String, pub path: String, pub title: String, pub body: String, pub updated_at_unix_ms: u128, pub content_sha256: String, pub writable: bool, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SaveKnowledgeDocumentInput { pub path: String, pub body: String, pub expected_content_sha256: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeSaveResult { pub schema: &'static str, pub state: &'static str, pub git_commit: String, pub document: KnowledgeDocument, } #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SearchKnowledgeInput { pub query: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeSearchResult { pub source: &'static str, pub path: String, pub title: String, pub snippet: String, } #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct KnowledgeImportResult { pub schema: &'static str, pub state: &'static str, pub source_name: String, pub destination: String, pub imported_documents: usize, pub imported_assets: usize, pub existing_documents: usize, pub conflicts: usize, pub skipped: usize, pub failed: usize, pub first_document: Option, pub git_commit: String, pub snapshot: KnowledgeSnapshot, } #[derive(Default)] struct ImportCounts { documents: usize, assets: usize, existing: usize, conflicts: usize, skipped: usize, failed: usize, first_document: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ImportDisposition { Added, Existing, Conflict, } #[tauri::command] pub async fn get_knowledge_snapshot(app: AppHandle) -> Result { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || snapshot_at(&roots.0, roots.1.as_deref())) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } #[tauri::command] pub async fn read_knowledge_document( app: AppHandle, input: ReadKnowledgeDocumentInput, ) -> Result { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || { read_document_at(&roots.0, roots.1.as_deref(), input) }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } #[tauri::command] pub async fn search_knowledge( app: AppHandle, input: SearchKnowledgeInput, ) -> Result, String> { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || search_at(&roots.0, roots.1.as_deref(), input)) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } #[tauri::command] pub async fn save_knowledge_document( app: AppHandle, input: SaveKnowledgeDocumentInput, ) -> Result { let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || save_document_at(&roots.0, input)) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? } #[tauri::command] pub async fn select_and_import_knowledge_folder( app: AppHandle, ) -> Result, String> { let picker = app.clone(); let selected = tauri::async_runtime::spawn_blocking(move || { picker .dialog() .file() .set_title("导入到 HoloLake 知识库") .blocking_pick_folder() }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_JOIN_FAILED: {error}"))?; let Some(selected) = selected else { return Ok(None); }; let source = selected .into_path() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?; let roots = knowledge_roots(&app)?; tauri::async_runtime::spawn_blocking(move || { import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some) }) .await .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))? } 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"); 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)) } fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { fs::create_dir_all(root.join("docs").join("导入")) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; fs::set_permissions(root, fs::Permissions::from_mode(0o700)) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?; if !root.join(".git").exists() { git(root, &["init", "--initial-branch=main"], "INIT")?; git(root, &["config", "user.name", "HoloLake"], "CONFIG")?; git( root, &["config", "user.email", "local@hololake.invalid"], "CONFIG", )?; } Ok(()) } fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result { let mut raw_documents = Vec::new(); collect_documents( &native.join("docs"), &native.join("docs"), "native", &mut raw_documents, )?; if let Some(root) = legacy { collect_documents( &root.join("docs"), &root.join("docs"), "legacy", &mut raw_documents, )?; } let raw_document_count = raw_documents.len(); let mut by_hash = HashMap::::new(); let mut documents = Vec::::new(); for document in raw_documents { if let Some(index) = by_hash.get(&document.content_sha256).copied() { documents[index].duplicate_count += 1; } else { by_hash.insert(document.content_sha256.clone(), documents.len()); documents.push(document); } } documents.sort_by(|left, right| { left.source .cmp(right.source) .then_with(|| left.path.cmp(&right.path)) }); let truncated = raw_document_count >= MAX_TREE_DOCUMENTS; documents.truncate(MAX_TREE_DOCUMENTS); let unique_document_count = documents.len(); Ok(KnowledgeSnapshot { schema: SNAPSHOT_SCHEMA, state: "READY", native_root: native.to_string_lossy().into_owned(), legacy_available: legacy.is_some(), legacy_root: legacy.map(|path| path.to_string_lossy().into_owned()), documents, raw_document_count, unique_document_count, duplicate_document_count: raw_document_count.saturating_sub(unique_document_count), truncated, }) } fn collect_documents( root: &Path, current: &Path, source: &'static str, output: &mut Vec, ) -> Result<(), String> { if output.len() >= MAX_TREE_DOCUMENTS { return Ok(()); } let mut entries = fs::read_dir(current) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))? .collect::, _>>() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { if output.len() >= MAX_TREE_DOCUMENTS { break; } let kind = entry .file_type() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; if kind.is_symlink() { continue; } let path = entry.path(); let name = entry.file_name(); if kind.is_dir() { if name != OsStr::new(".git") && name != OsStr::new(".hololake") { collect_documents(root, &path, source, output)?; } continue; } if !kind.is_file() || !is_readable_document(&path) { continue; } let metadata = entry .metadata() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; if metadata.len() > MAX_READ_BYTES { continue; } let relative = relative_posix(root, &path)?; let bytes = fs::read(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; output.push(KnowledgeDocumentSummary { source, path: relative, title: document_title(&path)?, updated_at_unix_ms: modified_unix_ms(&metadata), size_bytes: metadata.len(), content_sha256: sha256_hex(&bytes), duplicate_count: 0, }); } Ok(()) } fn read_document_at( native: &Path, legacy: Option<&Path>, input: ReadKnowledgeDocumentInput, ) -> Result { let root = source_docs_root(native, legacy, &input.source)?; let path = safe_document_path(&root, &input.path)?; let metadata = fs::metadata(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; if !metadata.is_file() || metadata.len() > MAX_READ_BYTES || !is_readable_document(&path) { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); } let body = fs::read_to_string(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; let content_sha256 = sha256_hex(body.as_bytes()); let writable = input.source == "native"; Ok(KnowledgeDocument { source: input.source, path: input.path, title: title_from_text(&body, &path), body, updated_at_unix_ms: modified_unix_ms(&metadata), content_sha256, writable, }) } fn save_document_at( native: &Path, input: SaveKnowledgeDocumentInput, ) -> Result { if input.body.len() as u64 > MAX_READ_BYTES || input.expected_content_sha256.len() != 64 || !input .expected_content_sha256 .chars() .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) { return Err("HOLOLAKE_KNOWLEDGE_SAVE_INPUT_INVALID".into()); } let root = native.join("docs"); let path = safe_document_path(&root, &input.path)?; if !is_readable_document(&path) { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); } let previous = fs::read(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; if sha256_hex(&previous) != input.expected_content_sha256 { return Err("HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT".into()); } if previous != input.body.as_bytes() { let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4())); let mut file = OpenOptions::new() .create_new(true) .write(true) .mode(0o600) .open(&temporary) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; file.write_all(input.body.as_bytes()) .and_then(|_| file.sync_all()) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; fs::rename(&temporary, &path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; git( native, &["add", "--", &format!("docs/{}", input.path)], "ADD", )?; git( native, &["commit", "-m", &format!("knowledge: update {}", input.path)], "COMMIT", )?; } let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? .trim() .to_string(); let document = read_document_at( native, None, ReadKnowledgeDocumentInput { source: "native".into(), path: input.path, }, )?; Ok(KnowledgeSaveResult { schema: "hololake.knowledge-document-save/v1", state: if previous == input.body.as_bytes() { "UNCHANGED" } else { "SAVED" }, git_commit, document, }) } fn search_at( native: &Path, legacy: Option<&Path>, input: SearchKnowledgeInput, ) -> Result, String> { let query = input.query.trim().to_lowercase(); if query.is_empty() || query.chars().count() > 200 { return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into()); } let snapshot = snapshot_at(native, legacy)?; let mut results = Vec::new(); for document in snapshot.documents { if results.len() >= MAX_SEARCH_RESULTS { break; } let root = source_docs_root(native, legacy, document.source)?; let path = safe_document_path(&root, &document.path)?; let body = fs::read_to_string(&path).unwrap_or_default(); let haystack = format!("{}\n{}", document.title, body).to_lowercase(); let Some(index) = haystack.find(&query) else { continue; }; let start = haystack[..index] .char_indices() .rev() .nth(40) .map(|(position, _)| position) .unwrap_or(0); let end = haystack[index..] .char_indices() .nth(120) .map(|(position, _)| index + position) .unwrap_or(haystack.len()); results.push(KnowledgeSearchResult { source: document.source, path: document.path, title: document.title, snippet: haystack[start..end].replace('\n', " "), }); } Ok(results) } fn import_folder_at( native: &Path, legacy: Option<&Path>, source: &Path, ) -> Result { let source = source .canonicalize() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_UNAVAILABLE: {error}"))?; if !source.is_dir() || source.starts_with(native) { return Err("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_INVALID".into()); } let source_name = safe_segment( source .file_name() .and_then(OsStr::to_str) .unwrap_or("未命名文件夹"), ); let import_root = native.join("docs").join("导入"); let destination_name = source_name.clone(); let destination = import_root.join(&destination_name); let destination_exists = destination.exists(); let staging = if destination_exists { destination.clone() } else { let path = native .join(".import-staging") .join(format!("{}-{}", destination_name, Uuid::new_v4())); fs::create_dir_all(&path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; path }; let mut counts = ImportCounts::default(); let import_result = walk_import(&source, &source, &staging, &destination_name, &mut counts); if let Err(error) = import_result { if !destination_exists { let _ = fs::remove_dir_all(&staging); } return Err(error); } if counts.documents == 0 && counts.assets == 0 && counts.existing == 0 && counts.conflicts == 0 { if !destination_exists { let _ = fs::remove_dir_all(&staging); } return Err("HOLOLAKE_KNOWLEDGE_IMPORT_NO_SUPPORTED_FILES".into()); } if !destination_exists { fs::rename(&staging, &destination) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_COMMIT_FAILED: {error}"))?; } let relative_destination = format!("docs/导入/{destination_name}"); if counts.documents + counts.assets > 0 { if let Err(error) = git(native, &["add", "--", &relative_destination], "ADD").and_then(|_| { git( native, &["commit", "-m", &format!("import: {source_name}")], "COMMIT", ) }) { if !destination_exists { let _ = fs::remove_dir_all(&destination); } let _ = git(native, &["reset", "--mixed"], "ROLLBACK_INDEX"); return Err(error); } } let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? .trim() .to_string(); Ok(KnowledgeImportResult { schema: "hololake.knowledge-folder-import/v1", state: if counts.conflicts > 0 { "CONFLICTS_DETECTED" } else if counts.documents + counts.assets == 0 { "ALREADY_PRESENT" } else { "IMPORTED" }, source_name, destination: format!("导入/{destination_name}"), imported_documents: counts.documents, imported_assets: counts.assets, existing_documents: counts.existing, conflicts: counts.conflicts, skipped: counts.skipped, failed: counts.failed, first_document: counts.first_document, git_commit, snapshot: snapshot_at(native, legacy)?, }) } fn walk_import( root: &Path, current: &Path, staging: &Path, destination_name: &str, counts: &mut ImportCounts, ) -> Result<(), String> { if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { return Ok(()); } let mut entries = fs::read_dir(current) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))? .collect::, _>>() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { break; } let name = entry.file_name(); if name == OsStr::new(".DS_Store") || name.to_string_lossy().starts_with("._") { continue; } let kind = entry .file_type() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; if kind.is_symlink() { counts.skipped += 1; continue; } let path = entry.path(); if kind.is_dir() { let lower = name.to_string_lossy().to_lowercase(); if [".git", "node_modules", ".idea", ".vscode", "__macosx"].contains(&lower.as_str()) { counts.skipped += 1; continue; } walk_import(root, &path, staging, destination_name, counts)?; continue; } if !kind.is_file() { counts.skipped += 1; continue; } let metadata = entry .metadata() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; if metadata.len() > MAX_FILE_BYTES { counts.skipped += 1; continue; } let relative = path .strip_prefix(root) .map_err(|_| "HOLOLAKE_KNOWLEDGE_IMPORT_PATH_INVALID".to_string())?; let safe_relative = relative .components() .filter_map(|component| match component { Component::Normal(value) => Some(safe_segment(&value.to_string_lossy())), _ => None, }) .collect::(); let extension = path .extension() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase(); let imported = if ["md", "markdown", "txt", "csv", "json", "yaml", "yml"] .contains(&extension.as_str()) { import_document(&path, &safe_relative, staging).map(|(target, disposition)| { match disposition { ImportDisposition::Added => counts.documents += 1, ImportDisposition::Existing => counts.existing += 1, ImportDisposition::Conflict => counts.conflicts += 1, } if disposition != ImportDisposition::Conflict { counts.first_document.get_or_insert_with(|| { format!( "导入/{}/{}", destination_name, target.to_string_lossy().replace('\\', "/") ) }); } }) } else if ["png", "jpg", "jpeg", "gif", "webp", "svg"].contains(&extension.as_str()) { let target = staging.join(&safe_relative); import_asset(&path, &target).map(|disposition| match disposition { ImportDisposition::Added => counts.assets += 1, ImportDisposition::Existing => counts.existing += 1, ImportDisposition::Conflict => counts.conflicts += 1, }) } else { counts.skipped += 1; continue; }; if imported.is_err() { counts.failed += 1; } } Ok(()) } fn import_document( source: &Path, relative: &Path, staging: &Path, ) -> Result<(PathBuf, ImportDisposition), String> { let extension = source .extension() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase(); let raw = fs::read_to_string(source) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_INVALID: {error}"))?; let mut target_relative = relative.to_path_buf(); if extension != "md" { target_relative.set_extension("md"); } let title = source .file_stem() .and_then(OsStr::to_str) .unwrap_or("未命名页面"); let content = match extension.as_str() { "md" | "markdown" => raw, "txt" => format!("# {title}\n\n{raw}\n"), "csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"), "json" => format!("# {title}\n\n```json\n{raw}\n```\n"), "yaml" | "yml" => format!("# {title}\n\n```yaml\n{raw}\n```\n"), _ => return Err("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_UNSUPPORTED".into()), }; let target = staging.join(&target_relative); fs::create_dir_all(target.parent().unwrap_or(staging)) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; if target.exists() { let existing = fs::read(&target) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; return Ok(( target_relative, if existing == content.as_bytes() { ImportDisposition::Existing } else { ImportDisposition::Conflict }, )); } let mut file = OpenOptions::new() .create_new(true) .write(true) .mode(0o600) .open(&target) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; file.write_all(content.as_bytes()) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; file.sync_all() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; Ok((target_relative, ImportDisposition::Added)) } fn import_asset(source: &Path, target: &Path) -> Result { let bytes = fs::read(source) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_ASSET_INVALID: {error}"))?; if target.exists() { let existing = fs::read(target) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; return Ok(if existing == bytes { ImportDisposition::Existing } else { ImportDisposition::Conflict }); } fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new("."))) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; let mut file = OpenOptions::new() .create_new(true) .write(true) .mode(0o600) .open(target) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; file.write_all(&bytes) .and_then(|_| file.sync_all()) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; Ok(ImportDisposition::Added) } fn sha256_hex(bytes: &[u8]) -> String { digest(&SHA256, bytes) .as_ref() .iter() .map(|byte| format!("{byte:02x}")) .collect() } fn source_docs_root(native: &Path, legacy: Option<&Path>, source: &str) -> Result { match source { "native" => Ok(native.join("docs")), "legacy" => legacy .map(|root| root.join("docs")) .ok_or_else(|| "HOLOLAKE_LEGACY_KNOWLEDGE_UNAVAILABLE".into()), _ => Err("HOLOLAKE_KNOWLEDGE_SOURCE_INVALID".into()), } } fn safe_document_path(root: &Path, relative: &str) -> Result { let relative_path = Path::new(relative); if relative_path.is_absolute() || relative_path .components() .any(|component| !matches!(component, Component::Normal(_))) { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".into()); } let canonical_root = root .canonicalize() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ROOT_UNAVAILABLE: {error}"))?; let candidate = canonical_root.join(relative_path); let canonical = candidate .canonicalize() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; if !canonical.starts_with(&canonical_root) { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_ESCAPE".into()); } let metadata = fs::symlink_metadata(&candidate) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; if metadata.file_type().is_symlink() { return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_SYMLINK_DENIED".into()); } Ok(canonical) } fn is_readable_document(path: &Path) -> bool { matches!( path.extension() .and_then(OsStr::to_str) .unwrap_or("") .to_lowercase() .as_str(), "md" | "markdown" | "txt" ) } fn document_title(path: &Path) -> Result { let body = fs::read_to_string(path) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; Ok(title_from_text(&body, path)) } fn title_from_text(body: &str, path: &Path) -> String { body.lines() .find_map(|line| line.trim().strip_prefix("# ").map(str::trim)) .filter(|title| !title.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| { path.file_stem() .and_then(OsStr::to_str) .unwrap_or("未命名页面") .to_string() }) } fn safe_segment(value: &str) -> String { let cleaned = value .chars() .map(|character| { if character.is_control() || "\\/:*?\"<>|".contains(character) { '-' } else { character } }) .collect::() .trim() .to_string(); if cleaned.is_empty() { "未命名文件夹".into() } else { cleaned } } fn relative_posix(root: &Path, path: &Path) -> Result { Ok(path .strip_prefix(root) .map_err(|_| "HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".to_string())? .to_string_lossy() .replace('\\', "/")) } fn modified_unix_ms(metadata: &fs::Metadata) -> u128 { metadata .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis()) .unwrap_or(0) } fn git(root: &Path, args: &[&str], operation: &str) -> Result { let output = Command::new("/usr/bin/git") .current_dir(root) .env("GIT_TERMINAL_PROMPT", "0") .args(args) .output() .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {error}"))?; if !output.status.success() { return Err(format!( "HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {}", String::from_utf8_lossy(&output.stderr).trim() )); } String::from_utf8(output.stdout) .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_INVALID_UTF8: {error}")) } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; #[test] fn snapshot_reads_native_and_legacy_without_merging_roots() { let native = tempdir().unwrap(); let legacy = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::create_dir_all(legacy.path().join("docs")).unwrap(); fs::write(native.path().join("docs/native.md"), "# 原生页面\n").unwrap(); fs::write(legacy.path().join("docs/legacy.md"), "# 旧版页面\n").unwrap(); let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); assert_eq!(snapshot.documents.len(), 2); assert!(snapshot .documents .iter() .any(|item| item.source == "native")); assert!(snapshot .documents .iter() .any(|item| item.source == "legacy")); } #[test] fn document_path_escape_is_rejected() { let native = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); let error = read_document_at( native.path(), None, ReadKnowledgeDocumentInput { source: "native".into(), path: "../secret.md".into(), }, ) .unwrap_err(); assert!(error.contains("PATH_INVALID")); } #[test] fn folder_import_is_git_backed_and_converts_text() { let native = tempdir().unwrap(); let source = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::write(source.path().join("readme.txt"), "hello").unwrap(); fs::write(source.path().join("skip.bin"), [1, 2, 3]).unwrap(); let result = import_folder_at(native.path(), None, source.path()).unwrap(); assert_eq!(result.state, "IMPORTED"); assert_eq!(result.imported_documents, 1); assert_eq!(result.skipped, 1); assert_eq!(result.git_commit.len(), 40); let imported = native .path() .join("docs") .join(&result.destination) .join("readme.md"); assert!(fs::read_to_string(imported).unwrap().contains("# readme")); } #[test] fn repeated_folder_import_is_idempotent_and_does_not_create_another_tree() { let native = tempdir().unwrap(); let source_parent = tempdir().unwrap(); let source = source_parent.path().join("稳定知识源"); fs::create_dir_all(&source).unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::write(source.join("page.md"), "# 唯一页面\n\n连续内容\n").unwrap(); let first = import_folder_at(native.path(), None, &source).unwrap(); let second = import_folder_at(native.path(), None, &source).unwrap(); assert_eq!(first.state, "IMPORTED"); assert_eq!(second.state, "ALREADY_PRESENT"); assert_eq!(second.imported_documents, 0); assert_eq!(second.existing_documents, 1); assert_eq!(first.destination, second.destination); assert_eq!(second.snapshot.raw_document_count, 1); assert_eq!(second.snapshot.unique_document_count, 1); assert_eq!( fs::read_dir(native.path().join("docs/导入")) .unwrap() .count(), 1 ); } #[test] fn snapshot_coalesces_exact_content_and_prefers_native_source() { let native = tempdir().unwrap(); let legacy = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::create_dir_all(legacy.path().join("docs")).unwrap(); fs::write(native.path().join("docs/native.md"), "# 同一内容\n").unwrap(); fs::write(legacy.path().join("docs/legacy.md"), "# 同一内容\n").unwrap(); let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); assert_eq!(snapshot.raw_document_count, 2); assert_eq!(snapshot.unique_document_count, 1); assert_eq!(snapshot.duplicate_document_count, 1); assert_eq!(snapshot.documents[0].source, "native"); assert_eq!(snapshot.documents[0].duplicate_count, 1); } #[test] fn native_edit_requires_the_exact_current_hash_and_commits_the_update() { let native = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::write(native.path().join("docs/page.md"), "# 旧内容\n").unwrap(); git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap(); git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap(); let current = read_document_at( native.path(), None, ReadKnowledgeDocumentInput { source: "native".into(), path: "page.md".into(), }, ) .unwrap(); let saved = save_document_at( native.path(), SaveKnowledgeDocumentInput { path: "page.md".into(), body: "# 新内容\n".into(), expected_content_sha256: current.content_sha256, }, ) .unwrap(); assert_eq!(saved.state, "SAVED"); assert_eq!(saved.git_commit.len(), 40); assert_eq!(saved.document.body, "# 新内容\n"); assert!(save_document_at( native.path(), SaveKnowledgeDocumentInput { path: "page.md".into(), body: "# 错误覆盖\n".into(), expected_content_sha256: sha256_hex(b"# old stale value\n"), }, ) .unwrap_err() .contains("SAVE_CONFLICT")); } #[test] fn search_returns_bounded_safe_projection() { let native = tempdir().unwrap(); ensure_native_knowledge_root(native.path()).unwrap(); fs::write(native.path().join("docs/page.md"), "# 星湖\n\n连续存在").unwrap(); let results = search_at( native.path(), None, SearchKnowledgeInput { query: "连续".into(), }, ) .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].title, "星湖"); } }