//! 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, } #[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, rows: Vec, 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, rows: Vec, 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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 { 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 = 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 { 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 { 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 { 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" ); } }