feat: admit signed channel workbench module
This commit is contained in:
parent
593d5e5884
commit
bafaf464c2
25 changed files with 1886 additions and 44 deletions
|
|
@ -0,0 +1,497 @@
|
|||
//! Signed channel document and spreadsheet workbench adapter.
|
||||
//!
|
||||
//! The signed package only selects this host-owned adapter. User content stays in
|
||||
//! the authenticated account root and every accepted save is committed together
|
||||
//! with a hash-chained receipt. Unmounting the package never removes that data.
|
||||
|
||||
use ring::digest::{digest, SHA256};
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::AppHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const MODULE_NUMBER: &str = "HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001";
|
||||
const ADAPTER: &str = "channel-workbench-v1";
|
||||
const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const MAX_BODY_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_COLUMNS: usize = 64;
|
||||
const MAX_ROWS: usize = 5_000;
|
||||
const MAX_CELL_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WorkbenchColumn {
|
||||
pub column_id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WorkbenchRow {
|
||||
pub row_id: String,
|
||||
pub cells: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelDocument {
|
||||
document_id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
revision: u64,
|
||||
updated_at_unix_ms: u64,
|
||||
content_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelSpreadsheet {
|
||||
table_id: String,
|
||||
title: String,
|
||||
columns: Vec<WorkbenchColumn>,
|
||||
rows: Vec<WorkbenchRow>,
|
||||
revision: u64,
|
||||
updated_at_unix_ms: u64,
|
||||
content_sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChannelWorkbenchSnapshot {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
module_number: &'static str,
|
||||
document: ChannelDocument,
|
||||
spreadsheet: ChannelSpreadsheet,
|
||||
receipt_count: u64,
|
||||
latest_receipt_hash: String,
|
||||
user_data_survives_unmount: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SaveChannelDocumentInput {
|
||||
document_id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SaveChannelSpreadsheetInput {
|
||||
table_id: String,
|
||||
title: String,
|
||||
columns: Vec<WorkbenchColumn>,
|
||||
rows: Vec<WorkbenchRow>,
|
||||
expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorkbenchSaveResult {
|
||||
schema: &'static str,
|
||||
state: &'static str,
|
||||
entity_kind: &'static str,
|
||||
entity_id: String,
|
||||
revision: u64,
|
||||
content_sha256: String,
|
||||
receipt_sha256: String,
|
||||
observed_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
fn now_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
digest(&SHA256, bytes)
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn storage_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
crate::authenticated_storage::account_storage_root(app, "channel-workbench-v1")
|
||||
}
|
||||
|
||||
fn require_active(app: &AppHandle) -> Result<(), String> {
|
||||
crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER)
|
||||
}
|
||||
|
||||
fn open_db(root: &Path) -> Result<Connection, String> {
|
||||
fs::create_dir_all(root)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_ROOT_FAILED: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(root, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_PERMISSION_FAILED: {error}"))?;
|
||||
}
|
||||
let connection = Connection::open(root.join("channel-workbench.sqlite3"))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_DB_OPEN_FAILED: {error}"))?;
|
||||
connection
|
||||
.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_DB_TIMEOUT_FAILED: {error}"))?;
|
||||
connection
|
||||
.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS documents(
|
||||
document_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL,
|
||||
content_sha256 TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS spreadsheets(
|
||||
table_id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
columns_json TEXT NOT NULL,
|
||||
rows_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
updated_at_unix_ms INTEGER NOT NULL,
|
||||
content_sha256 TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS workbench_receipts(
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
receipt_id TEXT NOT NULL UNIQUE,
|
||||
event TEXT NOT NULL,
|
||||
entity_kind TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
content_sha256 TEXT NOT NULL,
|
||||
previous_receipt_hash TEXT NOT NULL,
|
||||
receipt_hash TEXT NOT NULL UNIQUE,
|
||||
observed_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS workbench_receipts_no_update
|
||||
BEFORE UPDATE ON workbench_receipts BEGIN SELECT RAISE(ABORT,'HOLOLAKE_WORKBENCH_RECEIPT_APPEND_ONLY'); END;
|
||||
CREATE TRIGGER IF NOT EXISTS workbench_receipts_no_delete
|
||||
BEFORE DELETE ON workbench_receipts BEGIN SELECT RAISE(ABORT,'HOLOLAKE_WORKBENCH_RECEIPT_APPEND_ONLY'); END;",
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_DB_INIT_FAILED: {error}"))?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
fn valid_object_id(value: &str, prefix: &str) -> bool {
|
||||
value.starts_with(prefix)
|
||||
&& value.len() <= 96
|
||||
&& value
|
||||
.chars()
|
||||
.all(|item| item.is_ascii_uppercase() || item.is_ascii_digit() || item == '-')
|
||||
}
|
||||
|
||||
fn valid_title(value: &str) -> bool {
|
||||
!value.trim().is_empty() && value.len() <= 120 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn receipt_hash(
|
||||
event: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
revision: u64,
|
||||
content_sha256: &str,
|
||||
previous: &str,
|
||||
observed: u64,
|
||||
) -> String {
|
||||
sha256_hex(
|
||||
format!(
|
||||
"{event}\n{entity_kind}\n{entity_id}\n{revision}\n{content_sha256}\n{previous}\n{observed}"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
fn verify_receipts(connection: &Connection) -> Result<(u64, String), String> {
|
||||
let mut statement = connection
|
||||
.prepare("SELECT sequence,event,entity_kind,entity_id,revision,content_sha256,previous_receipt_hash,receipt_hash,observed_at_unix_ms FROM workbench_receipts ORDER BY sequence")
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, u64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, u64>(4)?,
|
||||
row.get::<_, String>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
row.get::<_, String>(7)?,
|
||||
row.get::<_, u64>(8)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
let mut count = 0;
|
||||
let mut previous = ZERO_HASH.to_string();
|
||||
for row in rows {
|
||||
let (sequence, event, kind, id, revision, content, stored_previous, stored_hash, observed) =
|
||||
row.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
if sequence != count + 1
|
||||
|| stored_previous != previous
|
||||
|| stored_hash != receipt_hash(&event, &kind, &id, revision, &content, &previous, observed)
|
||||
{
|
||||
return Err("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_CHAIN_INVALID".into());
|
||||
}
|
||||
count = sequence;
|
||||
previous = stored_hash;
|
||||
}
|
||||
Ok((count, previous))
|
||||
}
|
||||
|
||||
fn append_receipt(
|
||||
tx: &Transaction<'_>,
|
||||
event: &str,
|
||||
kind: &str,
|
||||
id: &str,
|
||||
revision: u64,
|
||||
content: &str,
|
||||
observed: u64,
|
||||
) -> Result<String, String> {
|
||||
let previous: String = tx
|
||||
.query_row(
|
||||
"SELECT COALESCE((SELECT receipt_hash FROM workbench_receipts ORDER BY sequence DESC LIMIT 1),?1)",
|
||||
params![ZERO_HASH],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_HEAD_FAILED: {error}"))?;
|
||||
let hash = receipt_hash(event, kind, id, revision, content, &previous, observed);
|
||||
tx.execute(
|
||||
"INSERT INTO workbench_receipts(receipt_id,event,entity_kind,entity_id,revision,content_sha256,previous_receipt_hash,receipt_hash,observed_at_unix_ms) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![format!("HLP-WB-RCP-{}", Uuid::new_v4().simple()), event, kind, id, revision, content, previous, hash, observed],
|
||||
)
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_WRITE_FAILED: {error}"))?;
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
fn empty_document() -> ChannelDocument {
|
||||
ChannelDocument {
|
||||
document_id: "HLP-WB-DOC-0001".into(),
|
||||
title: "频道笔记".into(),
|
||||
body: "".into(),
|
||||
revision: 0,
|
||||
updated_at_unix_ms: 0,
|
||||
content_sha256: sha256_hex(b""),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_spreadsheet() -> ChannelSpreadsheet {
|
||||
ChannelSpreadsheet {
|
||||
table_id: "HLP-WB-TBL-0001".into(),
|
||||
title: "频道工作表".into(),
|
||||
columns: vec![
|
||||
WorkbenchColumn { column_id: "HLP-WB-COL-0001".into(), title: "事项".into() },
|
||||
WorkbenchColumn { column_id: "HLP-WB-COL-0002".into(), title: "状态".into() },
|
||||
],
|
||||
rows: Vec::new(),
|
||||
revision: 0,
|
||||
updated_at_unix_ms: 0,
|
||||
content_sha256: sha256_hex(b""),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_at(root: &Path) -> Result<ChannelWorkbenchSnapshot, String> {
|
||||
let connection = open_db(root)?;
|
||||
let (receipt_count, latest_receipt_hash) = verify_receipts(&connection)?;
|
||||
let document = connection
|
||||
.query_row(
|
||||
"SELECT document_id,title,body,revision,updated_at_unix_ms,content_sha256 FROM documents ORDER BY updated_at_unix_ms DESC LIMIT 1",
|
||||
[],
|
||||
|row| Ok(ChannelDocument { document_id: row.get(0)?, title: row.get(1)?, body: row.get(2)?, revision: row.get(3)?, updated_at_unix_ms: row.get(4)?, content_sha256: row.get(5)? }),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_READ_FAILED: {error}"))?
|
||||
.unwrap_or_else(empty_document);
|
||||
let spreadsheet = connection
|
||||
.query_row(
|
||||
"SELECT table_id,title,columns_json,rows_json,revision,updated_at_unix_ms,content_sha256 FROM spreadsheets ORDER BY updated_at_unix_ms DESC LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, u64>(4)?, row.get::<_, u64>(5)?, row.get::<_, String>(6)?)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_READ_FAILED: {error}"))?
|
||||
.map(|(table_id, title, columns, rows, revision, updated_at_unix_ms, content_sha256)| {
|
||||
Ok::<ChannelSpreadsheet, String>(ChannelSpreadsheet {
|
||||
table_id,
|
||||
title,
|
||||
columns: serde_json::from_str(&columns).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_COLUMNS_INVALID".to_string())?,
|
||||
rows: serde_json::from_str(&rows).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_ROWS_INVALID".to_string())?,
|
||||
revision,
|
||||
updated_at_unix_ms,
|
||||
content_sha256,
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_else(empty_spreadsheet);
|
||||
if document.revision > 0 {
|
||||
let actual = sha256_hex(
|
||||
serde_json::to_string(&(document.title.trim(), &document.body))
|
||||
.map_err(|_| "HOLOLAKE_CHANNEL_DOCUMENT_SERIALIZE_FAILED")?
|
||||
.as_bytes(),
|
||||
);
|
||||
if actual != document.content_sha256 {
|
||||
return Err("HOLOLAKE_CHANNEL_DOCUMENT_CONTENT_INTEGRITY_INVALID".into());
|
||||
}
|
||||
}
|
||||
if spreadsheet.revision > 0 {
|
||||
let actual = sha256_hex(
|
||||
serde_json::to_string(&(
|
||||
spreadsheet.title.trim(),
|
||||
&spreadsheet.columns,
|
||||
&spreadsheet.rows,
|
||||
))
|
||||
.map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?
|
||||
.as_bytes(),
|
||||
);
|
||||
if actual != spreadsheet.content_sha256 {
|
||||
return Err("HOLOLAKE_CHANNEL_SPREADSHEET_CONTENT_INTEGRITY_INVALID".into());
|
||||
}
|
||||
}
|
||||
let mut latest = connection
|
||||
.prepare("SELECT entity_kind,entity_id,revision,content_sha256 FROM workbench_receipts WHERE sequence IN (SELECT MAX(sequence) FROM workbench_receipts GROUP BY entity_kind,entity_id)")
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
let heads = latest
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, u64>(2)?, row.get::<_, String>(3)?)))
|
||||
.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
for head in heads {
|
||||
let (kind, id, revision, content) = head.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_READ_FAILED: {error}"))?;
|
||||
let matches = match kind.as_str() {
|
||||
"DOCUMENT" => document.document_id == id && document.revision == revision && document.content_sha256 == content,
|
||||
"SPREADSHEET" => spreadsheet.table_id == id && spreadsheet.revision == revision && spreadsheet.content_sha256 == content,
|
||||
_ => false,
|
||||
};
|
||||
if !matches { return Err("HOLOLAKE_CHANNEL_WORKBENCH_CURRENT_STATE_INVALID".into()); }
|
||||
}
|
||||
Ok(ChannelWorkbenchSnapshot {
|
||||
schema: "hololake.channel-workbench-snapshot/v1",
|
||||
state: "ACTIVE_LOCAL_ACCOUNT_DATA",
|
||||
module_number: MODULE_NUMBER,
|
||||
document,
|
||||
spreadsheet,
|
||||
receipt_count,
|
||||
latest_receipt_hash,
|
||||
user_data_survives_unmount: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn save_document_at(root: &Path, input: SaveChannelDocumentInput, observed: u64) -> Result<WorkbenchSaveResult, String> {
|
||||
if !valid_object_id(&input.document_id, "HLP-WB-DOC-")
|
||||
|| !valid_title(&input.title)
|
||||
|| input.body.len() > MAX_BODY_BYTES
|
||||
{
|
||||
return Err("HOLOLAKE_CHANNEL_DOCUMENT_INPUT_INVALID".into());
|
||||
}
|
||||
let mut connection = open_db(root)?;
|
||||
verify_receipts(&connection)?;
|
||||
let transaction = connection.transaction().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_TRANSACTION_FAILED: {error}"))?;
|
||||
let current: Option<u64> = transaction.query_row("SELECT revision FROM documents WHERE document_id=?1", params![input.document_id], |row| row.get(0)).optional().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_READ_FAILED: {error}"))?;
|
||||
if current.unwrap_or(0) != input.expected_revision { return Err("HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT".into()); }
|
||||
let revision = input.expected_revision + 1;
|
||||
let content = sha256_hex(serde_json::to_string(&(input.title.trim(), &input.body)).map_err(|_| "HOLOLAKE_CHANNEL_DOCUMENT_SERIALIZE_FAILED")?.as_bytes());
|
||||
transaction.execute(
|
||||
"INSERT INTO documents VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(document_id) DO UPDATE SET title=excluded.title,body=excluded.body,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms,content_sha256=excluded.content_sha256",
|
||||
params![input.document_id, input.title.trim(), input.body, revision, observed, content],
|
||||
).map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_WRITE_FAILED: {error}"))?;
|
||||
let receipt = append_receipt(&transaction, if current.is_some() { "UPDATE" } else { "CREATE" }, "DOCUMENT", &input.document_id, revision, &content, observed)?;
|
||||
transaction.commit().map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_COMMIT_FAILED: {error}"))?;
|
||||
Ok(WorkbenchSaveResult { schema: "hololake.channel-workbench-save/v1", state: "COMMITTED", entity_kind: "DOCUMENT", entity_id: input.document_id, revision, content_sha256: content, receipt_sha256: receipt, observed_at_unix_ms: observed })
|
||||
}
|
||||
|
||||
fn validate_spreadsheet(input: &SaveChannelSpreadsheetInput) -> Result<(), String> {
|
||||
if !valid_object_id(&input.table_id, "HLP-WB-TBL-")
|
||||
|| !valid_title(&input.title)
|
||||
|| input.columns.is_empty()
|
||||
|| input.columns.len() > MAX_COLUMNS
|
||||
|| input.rows.len() > MAX_ROWS
|
||||
|| input.columns.iter().any(|column| !valid_object_id(&column.column_id, "HLP-WB-COL-") || !valid_title(&column.title))
|
||||
|| input.rows.iter().any(|row| !valid_object_id(&row.row_id, "HLP-WB-ROW-") || row.cells.len() > input.columns.len() || row.cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES))
|
||||
{
|
||||
return Err("HOLOLAKE_CHANNEL_SPREADSHEET_INPUT_INVALID".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_spreadsheet_at(root: &Path, input: SaveChannelSpreadsheetInput, observed: u64) -> Result<WorkbenchSaveResult, String> {
|
||||
validate_spreadsheet(&input)?;
|
||||
let mut connection = open_db(root)?;
|
||||
verify_receipts(&connection)?;
|
||||
let transaction = connection.transaction().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_TRANSACTION_FAILED: {error}"))?;
|
||||
let current: Option<u64> = transaction.query_row("SELECT revision FROM spreadsheets WHERE table_id=?1", params![input.table_id], |row| row.get(0)).optional().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_READ_FAILED: {error}"))?;
|
||||
if current.unwrap_or(0) != input.expected_revision { return Err("HOLOLAKE_CHANNEL_SPREADSHEET_REVISION_CONFLICT".into()); }
|
||||
let revision = input.expected_revision + 1;
|
||||
let columns = serde_json::to_string(&input.columns).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?;
|
||||
let rows = serde_json::to_string(&input.rows).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?;
|
||||
let content = sha256_hex(serde_json::to_string(&(input.title.trim(), &input.columns, &input.rows)).map_err(|_| "HOLOLAKE_CHANNEL_SPREADSHEET_SERIALIZE_FAILED")?.as_bytes());
|
||||
transaction.execute(
|
||||
"INSERT INTO spreadsheets VALUES (?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(table_id) DO UPDATE SET title=excluded.title,columns_json=excluded.columns_json,rows_json=excluded.rows_json,revision=excluded.revision,updated_at_unix_ms=excluded.updated_at_unix_ms,content_sha256=excluded.content_sha256",
|
||||
params![input.table_id, input.title.trim(), columns, rows, revision, observed, content],
|
||||
).map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_WRITE_FAILED: {error}"))?;
|
||||
let receipt = append_receipt(&transaction, if current.is_some() { "UPDATE" } else { "CREATE" }, "SPREADSHEET", &input.table_id, revision, &content, observed)?;
|
||||
transaction.commit().map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_COMMIT_FAILED: {error}"))?;
|
||||
Ok(WorkbenchSaveResult { schema: "hololake.channel-workbench-save/v1", state: "COMMITTED", entity_kind: "SPREADSHEET", entity_id: input.table_id, revision, content_sha256: content, receipt_sha256: receipt, observed_at_unix_ms: observed })
|
||||
}
|
||||
|
||||
pub async fn get_channel_workbench_snapshot(app: AppHandle) -> Result<ChannelWorkbenchSnapshot, String> {
|
||||
require_active(&app)?;
|
||||
let root = storage_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || snapshot_at(&root)).await.map_err(|error| format!("HOLOLAKE_CHANNEL_WORKBENCH_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub async fn save_channel_document(app: AppHandle, input: SaveChannelDocumentInput) -> Result<WorkbenchSaveResult, String> {
|
||||
require_active(&app)?;
|
||||
let root = storage_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || save_document_at(&root, input, now_unix_ms())).await.map_err(|error| format!("HOLOLAKE_CHANNEL_DOCUMENT_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
pub async fn save_channel_spreadsheet(app: AppHandle, input: SaveChannelSpreadsheetInput) -> Result<WorkbenchSaveResult, String> {
|
||||
require_active(&app)?;
|
||||
let root = storage_root(&app)?;
|
||||
tauri::async_runtime::spawn_blocking(move || save_spreadsheet_at(&root, input, now_unix_ms())).await.map_err(|error| format!("HOLOLAKE_CHANNEL_SPREADSHEET_JOIN_FAILED: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn document_and_spreadsheet_survive_reopen_with_receipt_chain() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let document = save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "真实笔记".into(), body: "第一段真实内容".into(), expected_revision: 0 }, 101).unwrap();
|
||||
let sheet = save_spreadsheet_at(root.path(), SaveChannelSpreadsheetInput { table_id: "HLP-WB-TBL-0001".into(), title: "真实工作表".into(), columns: vec![WorkbenchColumn { column_id: "HLP-WB-COL-0001".into(), title: "数量".into() }], rows: vec![WorkbenchRow { row_id: "HLP-WB-ROW-0001".into(), cells: vec!["=1+2".into()] }], expected_revision: 0 }, 102).unwrap();
|
||||
let snapshot = snapshot_at(root.path()).unwrap();
|
||||
assert_eq!(document.revision, 1);
|
||||
assert_eq!(sheet.revision, 1);
|
||||
assert_eq!(snapshot.document.body, "第一段真实内容");
|
||||
assert_eq!(snapshot.spreadsheet.rows[0].cells[0], "=1+2");
|
||||
assert_eq!(snapshot.receipt_count, 2);
|
||||
assert_eq!(snapshot.latest_receipt_hash, sheet.receipt_sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_revision_and_tampered_receipt_fail_closed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let input = SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "正文".into(), expected_revision: 0 };
|
||||
save_document_at(root.path(), input, 101).unwrap();
|
||||
assert_eq!(save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "过期写入".into(), expected_revision: 0 }, 102).unwrap_err(), "HOLOLAKE_CHANNEL_DOCUMENT_REVISION_CONFLICT");
|
||||
let connection = open_db(root.path()).unwrap();
|
||||
connection.execute_batch("DROP TRIGGER workbench_receipts_no_update; UPDATE workbench_receipts SET receipt_hash='bad'").unwrap();
|
||||
assert_eq!(snapshot_at(root.path()).unwrap_err(), "HOLOLAKE_CHANNEL_WORKBENCH_RECEIPT_CHAIN_INVALID");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_mutation_without_a_receipt_fails_closed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
save_document_at(root.path(), SaveChannelDocumentInput { document_id: "HLP-WB-DOC-0001".into(), title: "笔记".into(), body: "可信正文".into(), expected_revision: 0 }, 101).unwrap();
|
||||
let connection = open_db(root.path()).unwrap();
|
||||
connection.execute("UPDATE documents SET body='被改写' WHERE document_id='HLP-WB-DOC-0001'", []).unwrap();
|
||||
assert_eq!(snapshot_at(root.path()).unwrap_err(), "HOLOLAKE_CHANNEL_DOCUMENT_CONTENT_INTEGRITY_INVALID");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
mod authenticated_storage;
|
||||
mod circular_lake_membrane;
|
||||
mod channel_workbench;
|
||||
mod code_channel;
|
||||
mod code_repo_login;
|
||||
mod direct_local_broker;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,32 @@ const NATIVE_COMPOSITION_PACKAGE: &[u8] = include_bytes!(
|
|||
const NATIVE_COMPOSITION_SIGNATURE: &str = include_str!(
|
||||
"../../fixtures/module-packages/HLP-MOD-LOCAL-NATIVE-COMPOSITION-0001-0.1.0.ghmod.sig"
|
||||
);
|
||||
const CHANNEL_WORKBENCH_NUMBER: &str = "HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001";
|
||||
const CHANNEL_WORKBENCH_PACKAGE: &[u8] = include_bytes!(
|
||||
"../../fixtures/module-packages/HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001-0.1.0.ghmod"
|
||||
);
|
||||
const CHANNEL_WORKBENCH_SIGNATURE: &str = include_str!(
|
||||
"../../fixtures/module-packages/HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001-0.1.0.ghmod.sig"
|
||||
);
|
||||
|
||||
struct BundledModuleSource {
|
||||
module_number: &'static str,
|
||||
package: &'static [u8],
|
||||
signature: &'static str,
|
||||
}
|
||||
|
||||
const BUNDLED_MODULES: &[BundledModuleSource] = &[
|
||||
BundledModuleSource {
|
||||
module_number: NATIVE_COMPOSITION_NUMBER,
|
||||
package: NATIVE_COMPOSITION_PACKAGE,
|
||||
signature: NATIVE_COMPOSITION_SIGNATURE,
|
||||
},
|
||||
BundledModuleSource {
|
||||
module_number: CHANNEL_WORKBENCH_NUMBER,
|
||||
package: CHANNEL_WORKBENCH_PACKAGE,
|
||||
signature: CHANNEL_WORKBENCH_SIGNATURE,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RuntimeContract {
|
||||
|
|
@ -379,10 +405,11 @@ fn read_verified_package_bytes(
|
|||
}
|
||||
|
||||
fn bundled_package(module_number: &str) -> Result<(&'static [u8], &'static str), String> {
|
||||
match module_number {
|
||||
NATIVE_COMPOSITION_NUMBER => Ok((NATIVE_COMPOSITION_PACKAGE, NATIVE_COMPOSITION_SIGNATURE)),
|
||||
_ => Err("HOLOLAKE_BUNDLED_MODULE_UNKNOWN".into()),
|
||||
}
|
||||
BUNDLED_MODULES
|
||||
.iter()
|
||||
.find(|source| source.module_number == module_number)
|
||||
.map(|source| (source.package, source.signature))
|
||||
.ok_or_else(|| "HOLOLAKE_BUNDLED_MODULE_UNKNOWN".into())
|
||||
}
|
||||
|
||||
fn runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
|
|
@ -673,25 +700,35 @@ pub async fn get_bundled_module_catalog(
|
|||
) -> Result<Vec<BundledModuleDescriptor>, String> {
|
||||
let root = runtime_root(&app)?;
|
||||
let snapshot = snapshot_at(&root)?;
|
||||
let (bytes, signature) = bundled_package(NATIVE_COMPOSITION_NUMBER)?;
|
||||
let (_, package, package_sha256) =
|
||||
read_verified_package_bytes(bytes, signature, RELEASE_TRUST_RAW)?;
|
||||
let installed_state = snapshot
|
||||
.modules
|
||||
BUNDLED_MODULES
|
||||
.iter()
|
||||
.find(|module| module.module_number == NATIVE_COMPOSITION_NUMBER)
|
||||
.map(|module| module.state.clone())
|
||||
.unwrap_or_else(|| "NOT_INSTALLED".into());
|
||||
Ok(vec![BundledModuleDescriptor {
|
||||
module_number: package.manifest.module_number,
|
||||
display_name: package.manifest.display_name,
|
||||
version: package.manifest.version,
|
||||
adapter: package.manifest.adapter,
|
||||
permissions: package.manifest.permissions,
|
||||
package_sha256,
|
||||
installed_state,
|
||||
signature_verified: true,
|
||||
}])
|
||||
.map(|source| {
|
||||
let (_, package, package_sha256) = read_verified_package_bytes(
|
||||
source.package,
|
||||
source.signature,
|
||||
RELEASE_TRUST_RAW,
|
||||
)?;
|
||||
if package.manifest.module_number != source.module_number {
|
||||
return Err("HOLOLAKE_BUNDLED_MODULE_IDENTITY_INVALID".into());
|
||||
}
|
||||
let installed_state = snapshot
|
||||
.modules
|
||||
.iter()
|
||||
.find(|module| module.module_number == source.module_number)
|
||||
.map(|module| module.state.clone())
|
||||
.unwrap_or_else(|| "NOT_INSTALLED".into());
|
||||
Ok(BundledModuleDescriptor {
|
||||
module_number: package.manifest.module_number,
|
||||
display_name: package.manifest.display_name,
|
||||
version: package.manifest.version,
|
||||
adapter: package.manifest.adapter,
|
||||
permissions: package.manifest.permissions,
|
||||
package_sha256,
|
||||
installed_state,
|
||||
signature_verified: true,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn activate_bundled_module(
|
||||
|
|
@ -1455,6 +1492,21 @@ mod tests {
|
|||
assert_eq!(snapshot.receipt_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_channel_workbench_has_an_independent_verified_identity() {
|
||||
let (_, package, digest) = read_verified_package_bytes(
|
||||
CHANNEL_WORKBENCH_PACKAGE,
|
||||
CHANNEL_WORKBENCH_SIGNATURE,
|
||||
RELEASE_TRUST_RAW,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(package.manifest.module_number, CHANNEL_WORKBENCH_NUMBER);
|
||||
assert_eq!(package.manifest.adapter, "channel-workbench-v1");
|
||||
assert_eq!(package.manifest.permissions.len(), 4);
|
||||
assert!(is_sha256(&digest));
|
||||
assert_eq!(BUNDLED_MODULES.len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_package_input_is_rejected_before_signature_processing() {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ fn validate_tree() -> Result<(), String> {
|
|||
|| tree.record_id != "HLP-UNIFIED-NUMBER-TREE-001"
|
||||
|| tree.state != "MACHINE_COMPILED_STARTUP_ENFORCED"
|
||||
|| tree.root_number != "HLP-NUMBER-WORLD-ROOT-001"
|
||||
|| tree.route_count != 95
|
||||
|| tree.route_count != 98
|
||||
|| tree.routes.len() != tree.route_count
|
||||
|| !tree.invariants.number_is_stable_coordinate_not_authority
|
||||
|| !tree.invariants.path_is_unique_navigation
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ mod tests {
|
|||
#[test]
|
||||
fn registry_is_closed_and_contains_every_migrated_command() {
|
||||
let registry = load_registry().unwrap();
|
||||
assert_eq!(registry.operations.len(), 73);
|
||||
assert_eq!(registry.operations.len(), 76);
|
||||
assert!(!registry.runtime.legacy_direct_commands_allowed);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,15 @@ pub(crate) async fn dispatch(
|
|||
crate::native_composition::execute_knowledge_native_composition(app, input(&payload)?)
|
||||
.await?,
|
||||
),
|
||||
"channel_workbench::get_channel_workbench_snapshot" => {
|
||||
json(crate::channel_workbench::get_channel_workbench_snapshot(app).await?)
|
||||
}
|
||||
"channel_workbench::save_channel_document" => json(
|
||||
crate::channel_workbench::save_channel_document(app, input(&payload)?).await?,
|
||||
),
|
||||
"channel_workbench::save_channel_spreadsheet" => json(
|
||||
crate::channel_workbench::save_channel_spreadsheet(app, input(&payload)?).await?,
|
||||
),
|
||||
"local_development_bridge::acquire_development_write_lane" => json(
|
||||
crate::local_development_bridge::acquire_development_write_lane(app, input(&payload)?)
|
||||
.await?,
|
||||
|
|
|
|||
Loading…
Reference in a new issue