694 lines
25 KiB
Rust
694 lines
25 KiB
Rust
|
|
//! 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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|