hololake-system-architecture/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs

1065 lines
36 KiB
Rust
Raw Normal View History

// 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<String>,
pub documents: Vec<KnowledgeDocumentSummary>,
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<String>,
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<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ImportDisposition {
Added,
Existing,
Conflict,
}
#[tauri::command]
pub async fn get_knowledge_snapshot(app: AppHandle) -> Result<KnowledgeSnapshot, String> {
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<KnowledgeDocument, String> {
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<Vec<KnowledgeSearchResult>, 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<KnowledgeSaveResult, String> {
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<Option<KnowledgeImportResult>, 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<PathBuf>), 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<KnowledgeSnapshot, String> {
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::<String, usize>::new();
let mut documents = Vec::<KnowledgeDocumentSummary>::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<KnowledgeDocumentSummary>,
) -> 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::<Result<Vec<_>, _>>()
.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<KnowledgeDocument, String> {
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<KnowledgeSaveResult, String> {
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<Vec<KnowledgeSearchResult>, 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<KnowledgeImportResult, String> {
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::<Result<Vec<_>, _>>()
.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::<PathBuf>();
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<ImportDisposition, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<String, String> {
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::<String>()
.trim()
.to_string();
if cleaned.is_empty() {
"未命名文件夹".into()
} else {
cleaned
}
}
fn relative_posix(root: &Path, path: &Path) -> Result<String, String> {
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<String, String> {
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, "星湖");
}
}