ZY-TAKEOVER-SNAPSHOT 20260815: 铸渊接管前现场冻结(Codex 未提交改动全部入册)· 冰朔面谕接管+可回退
This commit is contained in:
parent
ebe2ecf262
commit
ffed065841
22 changed files with 4293 additions and 567 deletions
|
|
@ -8,6 +8,18 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
|
|
@ -811,6 +823,18 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
|
|
@ -1319,12 +1343,30 @@ version = "0.12.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
|
|
@ -1351,6 +1393,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"fs2",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
|
@ -1360,6 +1403,7 @@ dependencies = [
|
|||
"tauri-runtime",
|
||||
"tauri-runtime-wry",
|
||||
"tempfile",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -1901,6 +1945,17 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
|
|
@ -3027,6 +3082,20 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
|
|
@ -4570,6 +4639,12 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
|
|||
dirs = "6"
|
||||
fs2 = "0.4"
|
||||
ring = "0.17"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
|
@ -27,6 +28,7 @@ tauri-runtime-wry = "=2.10.0"
|
|||
tauri-plugin-updater = "2.10.0"
|
||||
tauri-plugin-dialog = "=2.7.2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,929 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Reverse;
|
||||
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::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1";
|
||||
const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1";
|
||||
const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"];
|
||||
const MAX_TREE_ENTRIES: usize = 1_000;
|
||||
const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CloneCodeChannelInput {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeChannelEntry {
|
||||
pub channel_id: String,
|
||||
pub name: String,
|
||||
pub source_kind: String,
|
||||
pub local_path: String,
|
||||
pub remote_url: Option<String>,
|
||||
pub git_head: String,
|
||||
pub branch: String,
|
||||
pub repository_clean: bool,
|
||||
pub registered_at_unix_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeChannelSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub storage_root: String,
|
||||
pub channels: Vec<CodeChannelEntry>,
|
||||
pub authority: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BrowseCodeChannelInput {
|
||||
pub channel_id: String,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeTreeEntry {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub kind: &'static str,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeTreeSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
pub entries: Vec<CodeTreeEntry>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ReadCodeChannelFileInput {
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CodeFileProjection {
|
||||
pub schema: &'static str,
|
||||
pub channel_id: String,
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
pub source: String,
|
||||
pub human_markdown: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
struct CodeChannelRegistry {
|
||||
schema: String,
|
||||
channels: Vec<CodeChannelEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ValidatedCloneUrl {
|
||||
normalized: String,
|
||||
owner: String,
|
||||
repository: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_code_channel_snapshot(app: AppHandle) -> Result<CodeChannelSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&root))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clone_code_channel(
|
||||
app: AppHandle,
|
||||
input: CloneCodeChannelInput,
|
||||
) -> Result<CodeChannelSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || clone_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_local_code_channel(
|
||||
app: AppHandle,
|
||||
) -> Result<Option<CodeChannelSnapshot>, String> {
|
||||
let picker = app.clone();
|
||||
let selected = tauri::async_runtime::spawn_blocking(move || {
|
||||
picker
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("选择本地 Git 代码频道")
|
||||
.blocking_pick_folder()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_JOIN_FAILED: {error}"))?;
|
||||
let Some(selected) = selected else {
|
||||
return Ok(None);
|
||||
};
|
||||
let selected = selected
|
||||
.into_path()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_PATH_INVALID: {error}"))?;
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || register_local_at(&root, &selected).map(Some))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn browse_code_channel(
|
||||
app: AppHandle,
|
||||
input: BrowseCodeChannelInput,
|
||||
) -> Result<CodeTreeSnapshot, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || browse_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_code_channel_file(
|
||||
app: AppHandle,
|
||||
input: ReadCodeChannelFileInput,
|
||||
) -> Result<CodeFileProjection, String> {
|
||||
let root = code_channel_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || read_code_file_at(&root, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
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");
|
||||
ensure_root(&root)?;
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn ensure_root(root: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(root.join("repositories"))
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PERMISSION_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_at(root: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let mut registry = read_registry(root)?;
|
||||
for channel in &mut registry.channels {
|
||||
if let Ok(inspection) = inspect_repository(Path::new(&channel.local_path)) {
|
||||
channel.git_head = inspection.git_head;
|
||||
channel.branch = inspection.branch;
|
||||
channel.repository_clean = inspection.repository_clean;
|
||||
}
|
||||
}
|
||||
registry
|
||||
.channels
|
||||
.sort_by_key(|entry| Reverse(entry.registered_at_unix_ms));
|
||||
Ok(CodeChannelSnapshot {
|
||||
schema: SNAPSHOT_SCHEMA,
|
||||
state: "READY",
|
||||
storage_root: root.to_string_lossy().into_owned(),
|
||||
channels: registry.channels,
|
||||
authority: "LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY",
|
||||
})
|
||||
}
|
||||
|
||||
fn browse_at(root: &Path, input: BrowseCodeChannelInput) -> Result<CodeTreeSnapshot, String> {
|
||||
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||
let relative = input.path.unwrap_or_default();
|
||||
let directory = safe_repository_path(&repository, &relative)?;
|
||||
if !directory.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_DIRECTORY_INVALID".into());
|
||||
}
|
||||
let mut entries = fs::read_dir(&directory)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||
entries.sort_by_key(|entry| {
|
||||
let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true);
|
||||
(is_file, entry.file_name())
|
||||
});
|
||||
let mut projected = Vec::new();
|
||||
for entry in entries {
|
||||
if projected.len() >= MAX_TREE_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name == ".git" || name == ".DS_Store" || name.starts_with("._") {
|
||||
continue;
|
||||
}
|
||||
let kind = entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?;
|
||||
if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let entry_path = entry.path();
|
||||
let relative_path = relative_posix(&repository, &entry_path)?;
|
||||
let size_bytes = if kind.is_file() {
|
||||
entry
|
||||
.metadata()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?
|
||||
.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
projected.push(CodeTreeEntry {
|
||||
path: relative_path,
|
||||
name,
|
||||
kind: if kind.is_dir() { "directory" } else { "file" },
|
||||
size_bytes,
|
||||
});
|
||||
}
|
||||
Ok(CodeTreeSnapshot {
|
||||
schema: "hololake.code-channel-tree/v1",
|
||||
channel_id: channel.channel_id,
|
||||
path: relative,
|
||||
truncated: projected.len() >= MAX_TREE_ENTRIES,
|
||||
entries: projected,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_code_file_at(
|
||||
root: &Path,
|
||||
input: ReadCodeChannelFileInput,
|
||||
) -> Result<CodeFileProjection, String> {
|
||||
let (channel, repository) = registered_repository(root, &input.channel_id)?;
|
||||
let file = safe_repository_path(&repository, &input.path)?;
|
||||
let metadata = fs::metadata(&file)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_UNAVAILABLE: {error}"))?;
|
||||
if !metadata.is_file() || metadata.len() > MAX_CODE_FILE_BYTES || !is_supported_text_file(&file)
|
||||
{
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_FILE_UNSUPPORTED".into());
|
||||
}
|
||||
let source = fs::read_to_string(&file)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_INVALID_UTF8: {error}"))?;
|
||||
let format = file_format(&file);
|
||||
let human_markdown = human_projection(&input.path, &format, &source);
|
||||
Ok(CodeFileProjection {
|
||||
schema: "hololake.code-channel-file-projection/v1",
|
||||
channel_id: channel.channel_id,
|
||||
path: input.path,
|
||||
format,
|
||||
source,
|
||||
human_markdown,
|
||||
size_bytes: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn registered_repository(
|
||||
root: &Path,
|
||||
channel_id: &str,
|
||||
) -> Result<(CodeChannelEntry, PathBuf), String> {
|
||||
let registry = read_registry(root)?;
|
||||
let channel = registry
|
||||
.channels
|
||||
.into_iter()
|
||||
.find(|entry| entry.channel_id == channel_id)
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_NOT_REGISTERED")?;
|
||||
let repository = PathBuf::from(&channel.local_path)
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE: {error}"))?;
|
||||
if !repository.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE".into());
|
||||
}
|
||||
Ok((channel, repository))
|
||||
}
|
||||
|
||||
fn safe_repository_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(_)))
|
||||
{
|
||||
if relative.is_empty() {
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_PATH_INVALID".into());
|
||||
}
|
||||
if relative.is_empty() {
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
let candidate = root.join(relative_path);
|
||||
let metadata = fs::symlink_metadata(&candidate)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_SYMLINK_DENIED".into());
|
||||
}
|
||||
let canonical = candidate
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?;
|
||||
if !canonical.starts_with(root) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_PATH_ESCAPE".into());
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn relative_posix(root: &Path, path: &Path) -> Result<String, String> {
|
||||
Ok(path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "HOLOLAKE_CODE_CHANNEL_PATH_INVALID".to_string())?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn is_supported_text_file(path: &Path) -> bool {
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("");
|
||||
if ["README", "LICENSE", "Dockerfile", "Makefile", "Procfile"].contains(&name) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"md" | "markdown"
|
||||
| "txt"
|
||||
| "json"
|
||||
| "jsonl"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "toml"
|
||||
| "hdlp"
|
||||
| "rs"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "mjs"
|
||||
| "cjs"
|
||||
| "py"
|
||||
| "sh"
|
||||
| "zsh"
|
||||
| "bash"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "html"
|
||||
| "xml"
|
||||
| "sql"
|
||||
| "graphql"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "swift"
|
||||
| "c"
|
||||
| "h"
|
||||
| "cpp"
|
||||
| "hpp"
|
||||
| "proto"
|
||||
| "ini"
|
||||
| "conf"
|
||||
| "env"
|
||||
)
|
||||
}
|
||||
|
||||
fn file_format(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("text")
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn human_projection(path: &str, format: &str, source: &str) -> String {
|
||||
if matches!(format, "md" | "markdown") {
|
||||
return source.to_string();
|
||||
}
|
||||
let title = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("代码文件");
|
||||
if format == "json" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(source) {
|
||||
return format!(
|
||||
"# {title}\n\n> 来自代码频道的 JSON 结构,已转换为可读层级。\n\n{}",
|
||||
json_as_markdown(&value, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(format, "hdlp" | "yaml" | "yml" | "toml" | "ini" | "conf") {
|
||||
let rows = source
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
.split_once(':')
|
||||
.or_else(|| trimmed.split_once('='))
|
||||
.map(|(key, value)| format!("| {} | {} |", key.trim(), value.trim()))
|
||||
})
|
||||
.take(120)
|
||||
.collect::<Vec<_>>();
|
||||
if !rows.is_empty() {
|
||||
return format!(
|
||||
"# {title}\n\n> 来自代码频道的结构化文件。原始机器字段已转成阅读表格。\n\n| 字段 | 内容 |\n| --- | --- |\n{}",
|
||||
rows.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"# {title}\n\n> 来自代码频道的 `{format}` 文件。下方保留原始内容,便于核验。\n\n```{format}\n{source}\n```"
|
||||
)
|
||||
}
|
||||
|
||||
fn json_as_markdown(value: &serde_json::Value, level: usize) -> String {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => map
|
||||
.iter()
|
||||
.map(|(key, value)| match value {
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => format!(
|
||||
"{} {key}\n\n{}",
|
||||
"#".repeat(level.min(5)),
|
||||
json_as_markdown(value, level + 1)
|
||||
),
|
||||
_ => format!("- **{key}**:{}", scalar_json(value)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n"),
|
||||
serde_json::Value::Array(values) => values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| match value {
|
||||
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
|
||||
format!("{}. {}", index + 1, json_as_markdown(value, level + 1))
|
||||
}
|
||||
_ => format!("- {}", scalar_json(value)),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => scalar_json(value),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_json(value: &serde_json::Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
|
||||
fn clone_at(root: &Path, input: CloneCodeChannelInput) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let validated = validate_clone_url(&input.url)?;
|
||||
let destination = root.join("repositories").join(format!(
|
||||
"{}--{}",
|
||||
safe_segment(&validated.owner),
|
||||
safe_segment(&validated.repository)
|
||||
));
|
||||
if destination.exists() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_DESTINATION_EXISTS".into());
|
||||
}
|
||||
let output = Command::new("/usr/bin/git")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_ASKPASS", "/usr/bin/false")
|
||||
.env("SSH_ASKPASS", "/usr/bin/false")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.args([
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"core.askPass=",
|
||||
"clone",
|
||||
"--origin",
|
||||
"origin",
|
||||
"--no-tags",
|
||||
])
|
||||
.arg(&validated.normalized)
|
||||
.arg(&destination)
|
||||
.output()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {error}"))?;
|
||||
if !output.status.success() {
|
||||
if destination.starts_with(root.join("repositories")) {
|
||||
let _ = fs::remove_dir_all(&destination);
|
||||
}
|
||||
return Err(format!(
|
||||
"HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let entry = entry_for_repository(
|
||||
&destination,
|
||||
"CLONED_HTTPS",
|
||||
Some(validated.normalized),
|
||||
Some(format!("{}/{}", validated.owner, validated.repository)),
|
||||
)?;
|
||||
upsert_entry(root, entry)?;
|
||||
snapshot_at(root)
|
||||
}
|
||||
|
||||
fn register_local_at(root: &Path, selected: &Path) -> Result<CodeChannelSnapshot, String> {
|
||||
ensure_root(root)?;
|
||||
let canonical = selected
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_LOCAL_UNAVAILABLE: {error}"))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_LOCAL_INVALID".into());
|
||||
}
|
||||
let remote = git_optional(&canonical, &["remote", "get-url", "origin"])
|
||||
.and_then(|value| redact_remote_url(value.trim()));
|
||||
let entry = entry_for_repository(&canonical, "LOCAL_GIT", remote, None)?;
|
||||
upsert_entry(root, entry)?;
|
||||
snapshot_at(root)
|
||||
}
|
||||
|
||||
struct RepositoryInspection {
|
||||
git_head: String,
|
||||
branch: String,
|
||||
repository_clean: bool,
|
||||
}
|
||||
|
||||
fn inspect_repository(path: &Path) -> Result<RepositoryInspection, String> {
|
||||
let inside = git(path, &["rev-parse", "--is-inside-work-tree"], "INSPECT")?;
|
||||
if inside.trim() != "true" {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_NOT_GIT".into());
|
||||
}
|
||||
let git_head = git(path, &["rev-parse", "HEAD"], "READ_HEAD")?
|
||||
.trim()
|
||||
.to_string();
|
||||
let branch = git_optional(path, &["symbolic-ref", "--short", "HEAD"])
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "DETACHED".into());
|
||||
let repository_clean = git(path, &["status", "--porcelain"], "READ_STATUS")?
|
||||
.trim()
|
||||
.is_empty();
|
||||
Ok(RepositoryInspection {
|
||||
git_head,
|
||||
branch,
|
||||
repository_clean,
|
||||
})
|
||||
}
|
||||
|
||||
fn entry_for_repository(
|
||||
path: &Path,
|
||||
source_kind: &str,
|
||||
remote_url: Option<String>,
|
||||
explicit_name: Option<String>,
|
||||
) -> Result<CodeChannelEntry, String> {
|
||||
let inspection = inspect_repository(path)?;
|
||||
let name = explicit_name.unwrap_or_else(|| {
|
||||
path.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("代码频道")
|
||||
.to_string()
|
||||
});
|
||||
Ok(CodeChannelEntry {
|
||||
channel_id: format!("channel-{}", Uuid::new_v4()),
|
||||
name,
|
||||
source_kind: source_kind.into(),
|
||||
local_path: path.to_string_lossy().into_owned(),
|
||||
remote_url,
|
||||
git_head: inspection.git_head,
|
||||
branch: inspection.branch,
|
||||
repository_clean: inspection.repository_clean,
|
||||
registered_at_unix_ms: now_unix_ms()?,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_clone_url(raw: &str) -> Result<ValidatedCloneUrl, String> {
|
||||
let raw = raw.trim();
|
||||
if raw.len() > 2_048 {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let mut parsed = Url::parse(raw).map_err(|_| "HOLOLAKE_CODE_CHANNEL_URL_INVALID")?;
|
||||
if parsed.scheme() != "https"
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
|| parsed.port().is_some()
|
||||
{
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||
.to_lowercase();
|
||||
if !ALLOWED_HOSTS.contains(&host.as_str()) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_HOST_NOT_TRUSTED".into());
|
||||
}
|
||||
let segments = parsed
|
||||
.path_segments()
|
||||
.ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")?
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if segments.len() != 3 || segments[0] != "code" {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
let owner = segments[1].to_string();
|
||||
let repository = segments[2]
|
||||
.strip_suffix(".git")
|
||||
.unwrap_or(segments[2])
|
||||
.to_string();
|
||||
if !valid_git_segment(&owner) || !valid_git_segment(&repository) {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into());
|
||||
}
|
||||
parsed.set_path(&format!("/code/{owner}/{repository}.git"));
|
||||
Ok(ValidatedCloneUrl {
|
||||
normalized: parsed.to_string(),
|
||||
owner,
|
||||
repository,
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_git_segment(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 100
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character))
|
||||
}
|
||||
|
||||
fn redact_remote_url(raw: &str) -> Option<String> {
|
||||
let mut parsed = Url::parse(raw).ok()?;
|
||||
if parsed.scheme() != "https" {
|
||||
return None;
|
||||
}
|
||||
parsed.set_username("").ok()?;
|
||||
parsed.set_password(None).ok()?;
|
||||
parsed.set_query(None);
|
||||
parsed.set_fragment(None);
|
||||
Some(parsed.to_string())
|
||||
}
|
||||
|
||||
fn safe_segment(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|character| {
|
||||
if character.is_ascii_alphanumeric() || "-_.".contains(character) {
|
||||
character
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn upsert_entry(root: &Path, entry: CodeChannelEntry) -> Result<(), String> {
|
||||
let mut registry = read_registry(root)?;
|
||||
registry
|
||||
.channels
|
||||
.retain(|existing| existing.local_path != entry.local_path);
|
||||
registry.channels.push(entry);
|
||||
write_registry(root, ®istry)
|
||||
}
|
||||
|
||||
fn read_registry(root: &Path) -> Result<CodeChannelRegistry, String> {
|
||||
let path = root.join("channels.json");
|
||||
if !path.exists() {
|
||||
return Ok(CodeChannelRegistry {
|
||||
schema: REGISTRY_SCHEMA.into(),
|
||||
channels: Vec::new(),
|
||||
});
|
||||
}
|
||||
let bytes = fs::read(&path)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_UNAVAILABLE: {error}"))?;
|
||||
let registry: CodeChannelRegistry = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||
if registry.schema != REGISTRY_SCHEMA {
|
||||
return Err("HOLOLAKE_CODE_CHANNEL_REGISTRY_SCHEMA_INVALID".into());
|
||||
}
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
fn write_registry(root: &Path, registry: &CodeChannelRegistry) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(registry)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?;
|
||||
let temporary = root.join(format!("channels.json.tmp-{}", Uuid::new_v4()));
|
||||
let final_path = root.join("channels.json");
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
file.write_all(&bytes)
|
||||
.and_then(|_| file.sync_all())
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
fs::rename(&temporary, &final_path)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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_CODE_CHANNEL_GIT_{operation}_FAILED: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_INVALID_UTF8: {error}"))
|
||||
}
|
||||
|
||||
fn git_optional(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("/usr/bin/git")
|
||||
.current_dir(root)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<u128, String> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn clone_url_is_normalized_to_official_https_git_endpoint() {
|
||||
let parsed =
|
||||
validate_clone_url("https://guanghulab.com/code/bingshuo/guanghu-ice-heart").unwrap();
|
||||
assert_eq!(
|
||||
parsed.normalized,
|
||||
"https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git"
|
||||
);
|
||||
assert_eq!(parsed.owner, "bingshuo");
|
||||
assert_eq!(parsed.repository, "guanghu-ice-heart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_url_rejects_credentials_queries_and_unknown_hosts() {
|
||||
assert!(
|
||||
validate_clone_url("https://user:secret@guanghulab.com/code/bingshuo/repo").is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_clone_url("https://guanghulab.com/code/bingshuo/repo?token=secret").is_err()
|
||||
);
|
||||
assert!(validate_clone_url("https://example.com/code/bingshuo/repo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_redaction_removes_credentials_and_fragments() {
|
||||
assert_eq!(
|
||||
redact_remote_url("https://user:secret@example.com/a/b.git?x=1#readme"),
|
||||
Some("https://example.com/a/b.git".into())
|
||||
);
|
||||
assert_eq!(redact_remote_url("git@example.com:a/b.git"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_repository_registration_is_persistent_and_deduplicated() {
|
||||
let root = tempdir().unwrap();
|
||||
let repository = tempdir().unwrap();
|
||||
ensure_root(root.path()).unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["init", "--initial-branch=main"],
|
||||
"TEST_INIT",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.name", "Test"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(repository.path().join("README.md"), "# test\n").unwrap();
|
||||
git(repository.path(), &["add", "README.md"], "TEST_ADD").unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["commit", "-m", "initial"],
|
||||
"TEST_COMMIT",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
register_local_at(root.path(), repository.path()).unwrap();
|
||||
register_local_at(root.path(), repository.path()).unwrap();
|
||||
let snapshot = snapshot_at(root.path()).unwrap();
|
||||
assert_eq!(snapshot.channels.len(), 1);
|
||||
assert_eq!(snapshot.channels[0].branch, "main");
|
||||
assert!(snapshot.channels[0].repository_clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_repository_can_be_browsed_and_projected_as_knowledge() {
|
||||
let root = tempdir().unwrap();
|
||||
let repository = tempdir().unwrap();
|
||||
ensure_root(root.path()).unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["init", "--initial-branch=main"],
|
||||
"TEST_INIT",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.name", "Test"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["config", "user.email", "test@example.invalid"],
|
||||
"TEST_CONFIG",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(repository.path().join("config")).unwrap();
|
||||
fs::write(
|
||||
repository.path().join("config/system.json"),
|
||||
r#"{"name":"光湖","state":"ready","routes":["native","mcp"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
git(repository.path(), &["add", "."], "TEST_ADD").unwrap();
|
||||
git(
|
||||
repository.path(),
|
||||
&["commit", "-m", "initial"],
|
||||
"TEST_COMMIT",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let snapshot = register_local_at(root.path(), repository.path()).unwrap();
|
||||
let channel_id = snapshot.channels[0].channel_id.clone();
|
||||
let tree = browse_at(
|
||||
root.path(),
|
||||
BrowseCodeChannelInput {
|
||||
channel_id: channel_id.clone(),
|
||||
path: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tree
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.name == "config" && entry.kind == "directory"));
|
||||
|
||||
let projection = read_code_file_at(
|
||||
root.path(),
|
||||
ReadCodeChannelFileInput {
|
||||
channel_id,
|
||||
path: "config/system.json".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projection.format, "json");
|
||||
assert!(projection
|
||||
.human_markdown
|
||||
.contains("来自代码频道的 JSON 结构"));
|
||||
assert!(projection.human_markdown.contains("**state**:ready"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_tree_denies_parent_escape() {
|
||||
let repository = tempdir().unwrap();
|
||||
assert!(safe_repository_path(repository.path(), "../secret").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,11 @@
|
|||
mod code_channel;
|
||||
mod direct_local_broker;
|
||||
mod direct_local_session;
|
||||
mod dynamic_capability_routing;
|
||||
mod home_status;
|
||||
mod knowledge_base;
|
||||
mod local_development_bridge;
|
||||
mod personal_channel;
|
||||
mod pncc_receipt_projection;
|
||||
mod pncc_remote_git;
|
||||
mod pncc_repository_binding;
|
||||
|
|
@ -33,6 +36,20 @@ pub fn run() {
|
|||
local_development_bridge::acquire_development_write_lane,
|
||||
local_development_bridge::inspect_development_write_lane,
|
||||
local_development_bridge::release_development_write_lane,
|
||||
personal_channel::get_personal_channel_snapshot,
|
||||
personal_channel::initialize_personal_channel,
|
||||
personal_channel::create_personal_channel_task,
|
||||
personal_channel::transition_personal_channel_task,
|
||||
knowledge_base::get_knowledge_snapshot,
|
||||
knowledge_base::read_knowledge_document,
|
||||
knowledge_base::search_knowledge,
|
||||
knowledge_base::save_knowledge_document,
|
||||
knowledge_base::select_and_import_knowledge_folder,
|
||||
code_channel::get_code_channel_snapshot,
|
||||
code_channel::clone_code_channel,
|
||||
code_channel::select_local_code_channel,
|
||||
code_channel::browse_code_channel,
|
||||
code_channel::read_code_channel_file,
|
||||
pncc_repository_binding::inspect_mounted_pncc_repository,
|
||||
pncc_repository_binding::select_pncc_repository_candidate,
|
||||
pncc_repository_binding::confirm_pncc_repository_mount,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,853 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1";
|
||||
const DATABASE_SCHEMA_VERSION: i64 = 1;
|
||||
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InitializePersonalChannelInput {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatePersonalChannelTaskInput {
|
||||
pub title: String,
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransitionPersonalChannelTaskInput {
|
||||
pub task_id: String,
|
||||
pub expected_status: String,
|
||||
pub next_status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelIdentity {
|
||||
pub human_subject_id: String,
|
||||
pub display_name: String,
|
||||
pub channel_id: String,
|
||||
pub created_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelTask {
|
||||
pub task_id: String,
|
||||
pub title: String,
|
||||
pub purpose: String,
|
||||
pub status: String,
|
||||
pub created_at_unix_ms: i64,
|
||||
pub updated_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelEventProjection {
|
||||
pub sequence: i64,
|
||||
pub event_id: String,
|
||||
pub kind: String,
|
||||
pub task_id: Option<String>,
|
||||
pub summary: String,
|
||||
pub occurred_at_unix_ms: i64,
|
||||
pub event_hash: String,
|
||||
pub receipt_id: String,
|
||||
pub receipt_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelIntegrity {
|
||||
pub state: &'static str,
|
||||
pub schema_version: i64,
|
||||
pub event_count: i64,
|
||||
pub receipt_count: i64,
|
||||
pub last_event_hash: String,
|
||||
pub last_receipt_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PersonalChannelSnapshot {
|
||||
pub schema: &'static str,
|
||||
pub state: &'static str,
|
||||
pub identity: Option<PersonalChannelIdentity>,
|
||||
pub current_task: Option<PersonalChannelTask>,
|
||||
pub recent_events: Vec<PersonalChannelEventProjection>,
|
||||
pub integrity: PersonalChannelIntegrity,
|
||||
pub storage: &'static str,
|
||||
pub authority: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EventHashPayload<'a> {
|
||||
schema: &'static str,
|
||||
sequence: i64,
|
||||
event_id: &'a str,
|
||||
human_subject_id: &'a str,
|
||||
kind: &'a str,
|
||||
task_id: Option<&'a str>,
|
||||
summary: &'a str,
|
||||
occurred_at_unix_ms: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_personal_channel_snapshot(
|
||||
app: AppHandle,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn initialize_personal_channel(
|
||||
app: AppHandle,
|
||||
input: InitializePersonalChannelInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || initialize_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_personal_channel_task(
|
||||
app: AppHandle,
|
||||
input: CreatePersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || create_task_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn transition_personal_channel_task(
|
||||
app: AppHandle,
|
||||
input: TransitionPersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let database = personal_channel_database(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || transition_task_at(&database, input))
|
||||
.await
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
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");
|
||||
create_private_directory(&root)?;
|
||||
Ok(root.join("personal-channel.sqlite3"))
|
||||
}
|
||||
|
||||
fn create_private_directory(path: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_database(path: &Path) -> Result<Connection, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_private_directory(parent)?;
|
||||
}
|
||||
let connection = Connection::open(path)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?;
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = DELETE;
|
||||
PRAGMA synchronous = FULL;
|
||||
PRAGMA trusted_schema = OFF;
|
||||
CREATE TABLE IF NOT EXISTS kernel_meta (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO kernel_meta(key, value) VALUES ('schema_version', '1');
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
||||
human_subject_id TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL UNIQUE,
|
||||
created_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY NOT NULL,
|
||||
human_subject_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'COMPLETED')),
|
||||
created_at_unix_ms INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS one_active_personal_task
|
||||
ON tasks(status) WHERE status = 'ACTIVE';
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||
event_id TEXT NOT NULL UNIQUE,
|
||||
human_subject_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
task_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
occurred_at_unix_ms INTEGER NOT NULL,
|
||||
previous_event_hash TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
event_hash TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
sequence INTEGER PRIMARY KEY NOT NULL,
|
||||
receipt_id TEXT NOT NULL UNIQUE,
|
||||
event_sequence INTEGER NOT NULL UNIQUE REFERENCES events(sequence),
|
||||
event_hash TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
previous_receipt_hash TEXT NOT NULL,
|
||||
receipt_hash TEXT NOT NULL UNIQUE,
|
||||
issued_at_unix_ms INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}")
|
||||
})?;
|
||||
}
|
||||
let version: String = connection
|
||||
.query_row(
|
||||
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?;
|
||||
if version != DATABASE_SCHEMA_VERSION.to_string() {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn initialize_at(
|
||||
database: &Path,
|
||||
input: InitializePersonalChannelInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let display_name = validated_text(&input.display_name, 80, "DISPLAY_NAME")?;
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let already_initialized: bool = transaction
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM identities WHERE singleton = 1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
if already_initialized {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED".into());
|
||||
}
|
||||
let human_subject_id = format!("human-local-{}", Uuid::new_v4());
|
||||
let channel_id = format!("channel-local-{}", Uuid::new_v4());
|
||||
let created_at = now_unix_ms()?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO identities(singleton, human_subject_id, display_name, channel_id, created_at_unix_ms)
|
||||
VALUES(1, ?1, ?2, ?3, ?4)",
|
||||
params![human_subject_id, display_name, channel_id, created_at],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"CHANNEL_INITIALIZED",
|
||||
None,
|
||||
&format!("{display_name} 建立了个人频道"),
|
||||
created_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn create_task_at(
|
||||
database: &Path,
|
||||
input: CreatePersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
let title = validated_text(&input.title, 160, "TASK_TITLE")?;
|
||||
let purpose = validated_text(&input.purpose, 1_000, "TASK_PURPOSE")?;
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let human_subject_id = require_identity_id(&transaction)?;
|
||||
let active_exists: bool = transaction
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM tasks WHERE status = 'ACTIVE')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
if active_exists {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS".into());
|
||||
}
|
||||
let task_id = format!("task-local-{}", Uuid::new_v4());
|
||||
let observed_at = now_unix_ms()?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO tasks(task_id, human_subject_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms)
|
||||
VALUES(?1, ?2, ?3, ?4, 'ACTIVE', ?5, ?5)",
|
||||
params![task_id, human_subject_id, title, purpose, observed_at],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"TASK_STARTED",
|
||||
Some(&task_id),
|
||||
&format!("开始:{title}"),
|
||||
observed_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn transition_task_at(
|
||||
database: &Path,
|
||||
input: TransitionPersonalChannelTaskInput,
|
||||
) -> Result<PersonalChannelSnapshot, String> {
|
||||
validate_identifier(&input.task_id, "TASK")?;
|
||||
if input.expected_status != "ACTIVE" || input.next_status != "COMPLETED" {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_TRANSITION_INVALID".into());
|
||||
}
|
||||
let mut connection = open_database(database)?;
|
||||
verify_integrity(&connection)?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(database_write_error)?;
|
||||
let human_subject_id = require_identity_id(&transaction)?;
|
||||
let task = transaction
|
||||
.query_row(
|
||||
"SELECT title, status FROM tasks WHERE task_id = ?1",
|
||||
params![input.task_id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.ok_or("HOLOLAKE_PERSONAL_CHANNEL_TASK_NOT_FOUND")?;
|
||||
if task.1 != input.expected_status {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||
}
|
||||
let observed_at = now_unix_ms()?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE tasks SET status = 'COMPLETED', updated_at_unix_ms = ?1
|
||||
WHERE task_id = ?2 AND status = 'ACTIVE'",
|
||||
params![observed_at, input.task_id],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
if changed != 1 {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into());
|
||||
}
|
||||
append_event(
|
||||
&transaction,
|
||||
&human_subject_id,
|
||||
"TASK_COMPLETED",
|
||||
Some(&input.task_id),
|
||||
&format!("完成:{}", task.0),
|
||||
observed_at,
|
||||
)?;
|
||||
transaction.commit().map_err(database_write_error)?;
|
||||
snapshot_at(database)
|
||||
}
|
||||
|
||||
fn require_identity_id(transaction: &Transaction<'_>) -> Result<String, String> {
|
||||
transaction
|
||||
.query_row(
|
||||
"SELECT human_subject_id FROM identities WHERE singleton = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.ok_or_else(|| "HOLOLAKE_PERSONAL_CHANNEL_NOT_INITIALIZED".into())
|
||||
}
|
||||
|
||||
fn append_event(
|
||||
transaction: &Transaction<'_>,
|
||||
human_subject_id: &str,
|
||||
kind: &str,
|
||||
task_id: Option<&str>,
|
||||
summary: &str,
|
||||
occurred_at_unix_ms: i64,
|
||||
) -> Result<(), String> {
|
||||
let (last_sequence, previous_event_hash, previous_receipt_hash) = transaction
|
||||
.query_row(
|
||||
"SELECT e.sequence, e.event_hash, r.receipt_hash
|
||||
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence DESC LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?
|
||||
.unwrap_or((0, ZERO_HASH.into(), ZERO_HASH.into()));
|
||||
let sequence = last_sequence + 1;
|
||||
let event_id = format!("event-local-{}", Uuid::new_v4());
|
||||
let payload = EventHashPayload {
|
||||
schema: KERNEL_SCHEMA,
|
||||
sequence,
|
||||
event_id: &event_id,
|
||||
human_subject_id,
|
||||
kind,
|
||||
task_id,
|
||||
summary,
|
||||
occurred_at_unix_ms,
|
||||
};
|
||||
let payload_bytes = serde_json::to_vec(&payload)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?;
|
||||
let payload_sha256 = sha256_hex(&payload_bytes);
|
||||
let event_hash =
|
||||
sha256_hex(format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes());
|
||||
let receipt_seed = sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes());
|
||||
let receipt_id = format!("HLR-{}", &receipt_seed[..24]);
|
||||
let receipt_hash = sha256_hex(
|
||||
format!(
|
||||
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO events(sequence, event_id, human_subject_id, kind, task_id, summary,
|
||||
occurred_at_unix_ms, previous_event_hash, payload_sha256, event_hash)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
sequence,
|
||||
event_id,
|
||||
human_subject_id,
|
||||
kind,
|
||||
task_id,
|
||||
summary,
|
||||
occurred_at_unix_ms,
|
||||
previous_event_hash,
|
||||
payload_sha256,
|
||||
event_hash
|
||||
],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO receipts(sequence, receipt_id, event_sequence, event_hash, payload_sha256,
|
||||
previous_receipt_hash, receipt_hash, issued_at_unix_ms)
|
||||
VALUES(?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
sequence,
|
||||
receipt_id,
|
||||
event_hash,
|
||||
payload_sha256,
|
||||
previous_receipt_hash,
|
||||
receipt_hash,
|
||||
occurred_at_unix_ms
|
||||
],
|
||||
)
|
||||
.map_err(database_write_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn snapshot_at(database: &Path) -> Result<PersonalChannelSnapshot, String> {
|
||||
let connection = open_database(database)?;
|
||||
let integrity = verify_integrity(&connection)?;
|
||||
let identity = connection
|
||||
.query_row(
|
||||
"SELECT human_subject_id, display_name, channel_id, created_at_unix_ms
|
||||
FROM identities WHERE singleton = 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PersonalChannelIdentity {
|
||||
human_subject_id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
channel_id: row.get(2)?,
|
||||
created_at_unix_ms: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?;
|
||||
let current_task = connection
|
||||
.query_row(
|
||||
"SELECT task_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms
|
||||
FROM tasks WHERE status = 'ACTIVE' LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(PersonalChannelTask {
|
||||
task_id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
purpose: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at_unix_ms: row.get(4)?,
|
||||
updated_at_unix_ms: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(database_read_error)?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT e.sequence, e.event_id, e.kind, e.task_id, e.summary, e.occurred_at_unix_ms,
|
||||
e.event_hash, r.receipt_id, r.receipt_hash
|
||||
FROM events e JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence DESC LIMIT 12",
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
let recent_events = statement
|
||||
.query_map([], |row| {
|
||||
Ok(PersonalChannelEventProjection {
|
||||
sequence: row.get(0)?,
|
||||
event_id: row.get(1)?,
|
||||
kind: row.get(2)?,
|
||||
task_id: row.get(3)?,
|
||||
summary: row.get(4)?,
|
||||
occurred_at_unix_ms: row.get(5)?,
|
||||
event_hash: row.get(6)?,
|
||||
receipt_id: row.get(7)?,
|
||||
receipt_hash: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(database_read_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(database_read_error)?;
|
||||
Ok(PersonalChannelSnapshot {
|
||||
schema: KERNEL_SCHEMA,
|
||||
state: if identity.is_some() {
|
||||
"READY"
|
||||
} else {
|
||||
"UNINITIALIZED"
|
||||
},
|
||||
identity,
|
||||
current_task,
|
||||
recent_events,
|
||||
integrity,
|
||||
storage: "LOCAL_PRIVATE_SQLITE_SINGLE_HOLOLAKE_OWNER",
|
||||
authority: "LOCAL_HUMAN_CONFIRMED_IDENTITY_NOT_SERVER_AUTHORITY",
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_integrity(connection: &Connection) -> Result<PersonalChannelIntegrity, String> {
|
||||
let schema_version: i64 = connection
|
||||
.query_row(
|
||||
"SELECT value FROM kernel_meta WHERE key = 'schema_version'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(database_read_error)?
|
||||
.parse()
|
||||
.map_err(|_| "HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID".to_string())?;
|
||||
if schema_version != DATABASE_SCHEMA_VERSION {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into());
|
||||
}
|
||||
let receipt_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM receipts", [], |row| row.get(0))
|
||||
.map_err(database_read_error)?;
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT e.sequence, e.event_id, e.human_subject_id, e.kind, e.task_id, e.summary,
|
||||
e.occurred_at_unix_ms, e.previous_event_hash, e.payload_sha256, e.event_hash,
|
||||
r.receipt_id, r.event_hash, r.payload_sha256, r.previous_receipt_hash, r.receipt_hash
|
||||
FROM events e LEFT JOIN receipts r ON r.event_sequence = e.sequence
|
||||
ORDER BY e.sequence ASC",
|
||||
)
|
||||
.map_err(database_read_error)?;
|
||||
let mut rows = statement.query([]).map_err(database_read_error)?;
|
||||
let mut expected_sequence = 1_i64;
|
||||
let mut previous_event_hash = ZERO_HASH.to_string();
|
||||
let mut previous_receipt_hash = ZERO_HASH.to_string();
|
||||
let mut event_count = 0_i64;
|
||||
while let Some(row) = rows.next().map_err(database_read_error)? {
|
||||
let sequence: i64 = row.get(0).map_err(database_read_error)?;
|
||||
let event_id: String = row.get(1).map_err(database_read_error)?;
|
||||
let human_subject_id: String = row.get(2).map_err(database_read_error)?;
|
||||
let kind: String = row.get(3).map_err(database_read_error)?;
|
||||
let task_id: Option<String> = row.get(4).map_err(database_read_error)?;
|
||||
let summary: String = row.get(5).map_err(database_read_error)?;
|
||||
let occurred_at: i64 = row.get(6).map_err(database_read_error)?;
|
||||
let stored_previous_event: String = row.get(7).map_err(database_read_error)?;
|
||||
let stored_payload: String = row.get(8).map_err(database_read_error)?;
|
||||
let stored_event_hash: String = row.get(9).map_err(database_read_error)?;
|
||||
let receipt_id: Option<String> = row.get(10).map_err(database_read_error)?;
|
||||
let receipt_event_hash: Option<String> = row.get(11).map_err(database_read_error)?;
|
||||
let receipt_payload: Option<String> = row.get(12).map_err(database_read_error)?;
|
||||
let stored_previous_receipt: Option<String> = row.get(13).map_err(database_read_error)?;
|
||||
let stored_receipt_hash: Option<String> = row.get(14).map_err(database_read_error)?;
|
||||
if sequence != expected_sequence || stored_previous_event != previous_event_hash {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
let payload = EventHashPayload {
|
||||
schema: KERNEL_SCHEMA,
|
||||
sequence,
|
||||
event_id: &event_id,
|
||||
human_subject_id: &human_subject_id,
|
||||
kind: &kind,
|
||||
task_id: task_id.as_deref(),
|
||||
summary: &summary,
|
||||
occurred_at_unix_ms: occurred_at,
|
||||
};
|
||||
let payload_sha256 = sha256_hex(
|
||||
&serde_json::to_vec(&payload)
|
||||
.map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?,
|
||||
);
|
||||
let event_hash = sha256_hex(
|
||||
format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes(),
|
||||
);
|
||||
let receipt_id = receipt_id.ok_or("HOLOLAKE_PERSONAL_CHANNEL_RECEIPT_MISSING")?;
|
||||
let expected_receipt_id = format!(
|
||||
"HLR-{}",
|
||||
&sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes())[..24]
|
||||
);
|
||||
let receipt_hash = sha256_hex(
|
||||
format!(
|
||||
"receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
if stored_payload != payload_sha256
|
||||
|| stored_event_hash != event_hash
|
||||
|| receipt_id != expected_receipt_id
|
||||
|| receipt_event_hash.as_deref() != Some(event_hash.as_str())
|
||||
|| receipt_payload.as_deref() != Some(payload_sha256.as_str())
|
||||
|| stored_previous_receipt.as_deref() != Some(previous_receipt_hash.as_str())
|
||||
|| stored_receipt_hash.as_deref() != Some(receipt_hash.as_str())
|
||||
{
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
previous_event_hash = event_hash;
|
||||
previous_receipt_hash = receipt_hash;
|
||||
expected_sequence += 1;
|
||||
event_count += 1;
|
||||
}
|
||||
if receipt_count != event_count {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
let identity_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0))
|
||||
.map_err(database_read_error)?;
|
||||
if identity_count > 1 || (identity_count == 1 && event_count == 0) {
|
||||
return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into());
|
||||
}
|
||||
Ok(PersonalChannelIntegrity {
|
||||
state: "PASS_100",
|
||||
schema_version,
|
||||
event_count,
|
||||
receipt_count,
|
||||
last_event_hash: previous_event_hash,
|
||||
last_receipt_hash: previous_receipt_hash,
|
||||
})
|
||||
}
|
||||
|
||||
fn validated_text(value: &str, maximum_chars: usize, kind: &str) -> Result<String, String> {
|
||||
let trimmed = value.trim();
|
||||
let count = trimmed.chars().count();
|
||||
if count == 0
|
||||
|| count > maximum_chars
|
||||
|| trimmed.chars().any(|character| character.is_control())
|
||||
{
|
||||
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_INVALID"));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_identifier(value: &str, kind: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
|
||||
{
|
||||
return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_ID_INVALID"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> Result<i64, String> {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))?
|
||||
.as_millis();
|
||||
i64::try_from(millis).map_err(|_| "HOLOLAKE_SYSTEM_CLOCK_INVALID".into())
|
||||
}
|
||||
|
||||
fn sha256_hex(value: &[u8]) -> String {
|
||||
digest(&SHA256, value)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn database_read_error(error: rusqlite::Error) -> String {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNREADABLE: {error}")
|
||||
}
|
||||
|
||||
fn database_write_error(error: rusqlite::Error) -> String {
|
||||
format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_WRITE_FAILED: {error}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn database(temp: &TempDir) -> PathBuf {
|
||||
temp.path().join("personal-channel.sqlite3")
|
||||
}
|
||||
|
||||
fn initialize(database: &Path) -> PersonalChannelSnapshot {
|
||||
initialize_at(
|
||||
database,
|
||||
InitializePersonalChannelInput {
|
||||
display_name: "冰朔".into(),
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialization_creates_identity_event_and_receipt_atomically() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let snapshot = initialize(&database(&temp));
|
||||
assert_eq!(snapshot.state, "READY");
|
||||
assert_eq!(snapshot.identity.unwrap().display_name, "冰朔");
|
||||
assert_eq!(snapshot.integrity.event_count, 1);
|
||||
assert_eq!(snapshot.integrity.receipt_count, 1);
|
||||
assert_eq!(snapshot.recent_events[0].kind, "CHANNEL_INITIALIZED");
|
||||
assert!(snapshot.recent_events[0].receipt_id.starts_with("HLR-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_reads_the_same_identity_task_event_and_receipt_chain() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let created = create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "完成第一阶段闭环".into(),
|
||||
purpose: "让身份、任务、事件与回执在重启后仍然可见".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let before_hash = created.integrity.last_receipt_hash.clone();
|
||||
drop(created);
|
||||
let after_restart = snapshot_at(&database).unwrap();
|
||||
assert_eq!(
|
||||
after_restart.current_task.unwrap().title,
|
||||
"完成第一阶段闭环"
|
||||
);
|
||||
assert_eq!(after_restart.integrity.event_count, 2);
|
||||
assert_eq!(after_restart.integrity.last_receipt_hash, before_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_active_task_is_enforced_and_completion_is_receipted() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let created = create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "当前任务".into(),
|
||||
purpose: "验证单一当前焦点".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
create_task_at(
|
||||
&database,
|
||||
CreatePersonalChannelTaskInput {
|
||||
title: "冲突任务".into(),
|
||||
purpose: "不应被创建".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS"
|
||||
);
|
||||
let completed = transition_task_at(
|
||||
&database,
|
||||
TransitionPersonalChannelTaskInput {
|
||||
task_id: created.current_task.unwrap().task_id,
|
||||
expected_status: "ACTIVE".into(),
|
||||
next_status: "COMPLETED".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(completed.current_task.is_none());
|
||||
assert_eq!(completed.recent_events[0].kind, "TASK_COMPLETED");
|
||||
assert_eq!(completed.integrity.event_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_event_bytes_fail_closed_on_readback() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
let connection = open_database(&database).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE events SET summary = '被篡改' WHERE sequence = 1",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
snapshot_at(&database).unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_identity_is_singleton_and_not_recreated() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let database = database(&temp);
|
||||
initialize(&database);
|
||||
assert_eq!(
|
||||
initialize_at(
|
||||
&database,
|
||||
InitializePersonalChannelInput {
|
||||
display_name: "另一个人".into(),
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
],
|
||||
"createUpdaterArtifacts": false,
|
||||
"category": "Productivity"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue