20260826 两宿主全线正本/回执/状态 + 协议正本地图v1.2 + 模板/镜面/坑清单(元审计收敛commit;前人未提交改动未碰)

This commit is contained in:
冰朔 2026-08-26 16:23:44 +08:00
commit 59fc9bfd7b
670 changed files with 133422 additions and 129 deletions

View file

@ -0,0 +1,694 @@
//! 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"
);
}
}