1//! Edit rows. An edit is stored as its serialized ops with picture and attachment bytes
2//! moved to the `payloads` table, named by SHA-256 in the order `slots` visits them.
3//!
4//! A password-protected section's queue keeps none of it in the clear: each edit and
5//! payload is sealed with AES-256-GCM (a random nonce, the row's kind as associated data)
6//! and payloads are named by HMAC-SHA256, both under keys HMAC-SHA256 derives from the
7//! section's own key. Only the author's name and the payload names stay readable.
8
9use crate::{PendingEdit, Result, signed, unsigned};
10use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
11use hmac::{Hmac, Mac};
12use onestore::{
13 op::{Edit, Op, PageOp, SectionOp, TableEdit},
14 page::{PageObject, PageParagraph, ParagraphContent, TableCell},
15 protected::Key,
16};
17use rusqlite::{Connection, OptionalExtension, params};
18use sha2::{Digest, Sha256};
19use std::{io, sync::Arc};
20use zeroize::Zeroizing;
21
22type Payload = Option<Arc<[u8]>>;
23
24fn paragraphs(list: &mut [PageParagraph], visit: &mut impl FnMut(&mut Payload)) {
25 for paragraph in list {
26 match &mut paragraph.content {
27 ParagraphContent::Image(image) => visit(&mut image.bytes),
28 ParagraphContent::Attachment(attachment) => {
29 visit(&mut attachment.bytes);
30 visit(&mut attachment.preview);
31 }
32 ParagraphContent::Table(table) => {
33 for row in &mut table.rows {
34 cells(&mut row.cells, visit);
35 }
36 }
37 ParagraphContent::Text(_)
38 | ParagraphContent::Ink(_)
39 | ParagraphContent::Unsupported(_) => {}
40 }
41 }
42}
43
44fn cells(list: &mut [TableCell], visit: &mut impl FnMut(&mut Payload)) {
45 for cell in list {
46 paragraphs(&mut cell.paragraphs, visit);
47 }
48}
49
50fn object(object: &mut PageObject, visit: &mut impl FnMut(&mut Payload)) {
51 match object {
52 PageObject::Outline(outline) => paragraphs(&mut outline.paragraphs, visit),
53 PageObject::Title(title) => {
54 for outline in &mut title.outlines {
55 paragraphs(&mut outline.paragraphs, visit);
56 }
57 }
58 PageObject::Image(image) => visit(&mut image.bytes),
59 PageObject::Attachment(attachment) => {
60 visit(&mut attachment.bytes);
61 visit(&mut attachment.preview);
62 }
63 PageObject::Ink(_) | PageObject::Unsupported(_) => {}
64 }
65}
66
67/// Visits every payload an edit carries, in a fixed order.
68fn slots(edit: &mut Edit, mut visit: impl FnMut(&mut Payload)) {
69 for op in &mut edit.ops {
70 match op {
71 Op::Page { op, .. } => match op {
72 PageOp::Insert {
73 paragraphs: list, ..
74 } => paragraphs(list, &mut visit),
75 PageOp::Add { object: added, .. } => object(added, &mut visit),
76 PageOp::Table {
77 edit: TableEdit::Rows { rows, .. },
78 ..
79 } => {
80 for row in rows {
81 cells(&mut row.cells, &mut visit);
82 }
83 }
84 PageOp::Table {
85 edit: TableEdit::Column { cells: list, .. },
86 ..
87 } => cells(list, &mut visit),
88 _ => {}
89 },
90 Op::Section(SectionOp::Import { page, .. } | SectionOp::Conflict { page, .. }) => {
91 for added in &mut page.objects {
92 object(added, &mut visit);
93 }
94 }
95 Op::Section(_) => {}
96 }
97 }
98}
99
100fn damaged(message: &'static str) -> crate::Error {
101 io::Error::new(io::ErrorKind::InvalidData, message).into()
102}
103
104/// A key `key` derives for `purpose`.
105fn derived(key: &Key, purpose: &[u8]) -> Zeroizing<[u8; 32]> {
106 let mut mac =
107 <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(key.secret()).expect("any key length");
108 mac.update(purpose);
109 Zeroizing::new(mac.finalize().into_bytes().into())
110}
111
112fn cipher(key: &Key) -> Aes256Gcm {
113 Aes256Gcm::new_from_slice(&*derived(key, b"Snowbound queue v1")).expect("a 32-byte key")
114}
115
116/// A payload's name: its SHA-256, or in a protected section's queue an HMAC.
117fn name(key: Option<&Key>, bytes: &[u8]) -> String {
118 match key {
119 Some(key) => {
120 let mut mac = <Hmac<Sha256> as hmac::KeyInit>::new_from_slice(&*derived(
121 key,
122 b"Snowbound payload names v1",
123 ))
124 .expect("any key length");
125 mac.update(bytes);
126 hex(&mac.finalize().into_bytes())
127 }
128 None => hex(&Sha256::digest(bytes)),
129 }
130}
131
132/// `clear` sealed under `key` as `kind`: a random nonce, then the ciphertext and tag.
133fn seal(key: &Key, kind: &[u8], clear: &[u8]) -> Result<Vec<u8>> {
134 let mut nonce = [0; 12];
135 getrandom::fill(&mut nonce).map_err(|_| io::Error::other("System random source failed"))?;
136 let sealed = cipher(key)
137 .encrypt(
138 &nonce.into(),
139 aes_gcm::aead::Payload {
140 msg: clear,
141 aad: kind,
142 },
143 )
144 .map_err(|_| io::Error::other("A queued edit could not be sealed"))?;
145 Ok([&nonce[..], &sealed].concat())
146}
147
148/// The inverse of `seal`.
149fn open(key: &Key, kind: &[u8], sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
150 let (nonce, sealed) = sealed
151 .split_first_chunk::<12>()
152 .ok_or_else(|| damaged("A sealed queue row is truncated"))?;
153 Ok(Zeroizing::new(
154 cipher(key)
155 .decrypt(
156 &(*nonce).into(),
157 aes_gcm::aead::Payload {
158 msg: sealed,
159 aad: kind,
160 },
161 )
162 .map_err(|_| damaged("A sealed queue row does not open under the section's key"))?,
163 ))
164}
165
166/// Stores `edit` in `batch` under `id`, or the next id, its payloads once each by name;
167/// returns the edit's id.
168pub(crate) fn insert(
169 connection: &Connection,
170 key: Option<&Key>,
171 id: Option<u64>,
172 batch: i64,
173 author: &str,
174 edit: &Edit,
175) -> Result<u64> {
176 let (text, names) = encode(connection, key, edit)?;
177 connection.execute(
178 "INSERT INTO edits(id, batch, author, edit, payloads) VALUES (?1, ?2, ?3, ?4, ?5)",
179 params![id.map(signed).transpose()?, batch, author, text, names],
180 )?;
181 unsigned(connection.last_insert_rowid())
182}
183
184fn hex(digest: &[u8]) -> String {
185 digest.iter().map(|byte| format!("{byte:02x}")).collect()
186}
187
188/// Replaces a stored edit's ops.
189pub(crate) fn rewrite(
190 connection: &Connection,
191 key: Option<&Key>,
192 id: u64,
193 edit: &Edit,
194) -> Result<()> {
195 let (text, names) = encode(connection, key, edit)?;
196 connection.execute(
197 "UPDATE edits SET edit=?1, payloads=?2 WHERE id=?3",
198 params![text, names, signed(id)?],
199 )?;
200 Ok(())
201}
202
203/// The serialized edit without payload bytes and the payload names, storing the payloads;
204/// sealed under `key` for a protected section.
205fn encode(
206 connection: &Connection,
207 key: Option<&Key>,
208 edit: &Edit,
209) -> Result<(String, Option<String>)> {
210 let mut edit = edit.clone();
211 let mut names: Vec<Option<String>> = Vec::new();
212 let mut failure = None;
213 slots(&mut edit, |payload| {
214 let name = payload.take().map(|bytes| {
215 let name = name(key, &bytes);
216 let stored = match key {
217 Some(key) => seal(key, b"payload", &bytes),
218 None => Ok(bytes.to_vec()),
219 };
220 let stored = stored.and_then(|stored| {
221 Ok(connection.execute(
222 "INSERT INTO payloads(sha256, bytes) VALUES (unhex(?1), ?2) ON CONFLICT DO NOTHING",
223 params![&name, &stored[..]],
224 )?)
225 });
226 if let Err(error) = stored {
227 failure.get_or_insert(error);
228 }
229 name
230 });
231 names.push(name);
232 });
233 if let Some(error) = failure {
234 return Err(error);
235 }
236 let names = names
237 .iter()
238 .any(Option::is_some)
239 .then(|| serde_json::to_string(&names))
240 .transpose()
241 .map_err(io::Error::other)?;
242 let text = Zeroizing::new(serde_json::to_string(&edit).map_err(io::Error::other)?);
243 let text = match key {
244 Some(key) => {
245 use base64::Engine;
246 base64::engine::general_purpose::STANDARD.encode(seal(key, b"edit", text.as_bytes())?)
247 }
248 None => text.to_string(),
249 };
250 Ok((text, names))
251}
252
253/// A stored edit's ops, without its payloads.
254pub(crate) fn parse(key: Option<&Key>, text: &str) -> Result<Edit> {
255 Ok(match key {
256 Some(key) => {
257 use base64::Engine;
258 let sealed = base64::engine::general_purpose::STANDARD
259 .decode(text)
260 .map_err(|_| damaged("A sealed queued edit is damaged"))?;
261 serde_json::from_slice(&open(key, b"edit", &sealed)?)
262 }
263 None => serde_json::from_str(text),
264 }
265 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?)
266}
267
268fn decode(
269 connection: &Connection,
270 key: Option<&Key>,
271 text: &str,
272 names: Option<String>,
273) -> Result<Edit> {
274 let mut edit = parse(key, text)?;
275 let Some(names) = names else {
276 return Ok(edit);
277 };
278 let names: Vec<Option<String>> = serde_json::from_str(&names)
279 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
280 let mut names = names.into_iter();
281 let mut failure: Option<crate::Error> = None;
282 let mut query =
283 connection.prepare_cached("SELECT bytes FROM payloads WHERE sha256=unhex(?1)")?;
284 slots(&mut edit, |payload| {
285 let Some(name) = names.next() else {
286 failure.get_or_insert(damaged("A queued edit names fewer payloads than it holds"));
287 return;
288 };
289 let Some(name) = name else {
290 return;
291 };
292 let bytes = query
293 .query_row([&name], |row| row.get::<_, Vec<u8>>(0))
294 .optional();
295 let bytes = match (key, bytes) {
296 (Some(key), Ok(Some(sealed))) => {
297 open(key, b"payload", &sealed).map(|bytes| Some(bytes.to_vec()))
298 }
299 (_, bytes) => bytes.map_err(Into::into),
300 };
301 match bytes {
302 Ok(Some(bytes)) if self::name(key, &bytes) == name => {
303 *payload = Some(Arc::from(bytes));
304 }
305 Ok(_) => {
306 failure.get_or_insert(damaged("A queued payload is missing or damaged"));
307 }
308 Err(error) => {
309 failure.get_or_insert(error);
310 }
311 }
312 });
313 if names.next().is_some() {
314 failure.get_or_insert(damaged("A queued edit names more payloads than it holds"));
315 }
316 match failure {
317 Some(error) => Err(error),
318 None => Ok(edit),
319 }
320}
321
322/// Queued edits, oldest first; of one batch when `batch` is given.
323pub(crate) fn load(
324 connection: &Connection,
325 key: Option<&Key>,
326 batch: Option<i64>,
327) -> Result<Vec<PendingEdit>> {
328 let mut query = connection.prepare_cached(
329 "SELECT id, author, edit, payloads FROM edits WHERE ?1 IS NULL OR batch=?1 ORDER BY id",
330 )?;
331 let mut rows = query.query([batch])?;
332 let mut edits = Vec::new();
333 while let Some(row) = rows.next()? {
334 edits.push(PendingEdit {
335 id: unsigned(row.get(0)?)?,
336 author: row.get(1)?,
337 edit: decode(connection, key, &row.get::<_, String>(2)?, row.get(3)?)?,
338 });
339 }
340 Ok(edits)
341}
342
343/// Drops payloads no queued edit names.
344pub(crate) fn collect(connection: &Connection) -> Result<()> {
345 connection.execute(
346 "DELETE FROM payloads WHERE NOT EXISTS (SELECT 1 FROM edits, json_each(edits.payloads) AS name
347 WHERE edits.payloads IS NOT NULL AND name.value=lower(hex(payloads.sha256)))",
348 [],
349 )?;
350 Ok(())
351}
352
353/// The object spaces an edit changes; section ops change the root space as well.
354pub(crate) fn spaces(edit: &Edit, root: onestore::ExGuid) -> Vec<onestore::ExGuid> {
355 let mut spaces = Vec::new();
356 for op in &edit.ops {
357 match op {
358 Op::Page { space, .. } => spaces.push(*space),
359 Op::Section(op) => {
360 spaces.push(root);
361 match op {
362 SectionOp::Create(creation) | SectionOp::Import { creation, .. } => {
363 spaces.push(creation.space());
364 }
365 SectionOp::Conflict { of, creation, .. } => {
366 spaces.extend([*of, creation.space()]);
367 }
368 SectionOp::Pages(edits) => spaces.extend(edits.iter().map(|edit| edit.space())),
369 SectionOp::Delete(pages) => spaces.extend(pages),
370 SectionOp::RestoreVersion { page, .. }
371 | SectionOp::DeleteVersions { page, .. } => spaces.push(*page),
372 SectionOp::Color(_) => {}
373 }
374 }
375 }
376 }
377 spaces.sort();
378 spaces.dedup();
379 spaces
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use onestore::{ExGuid, page::Image};
386
387 #[test]
388 fn payloads_are_stored_once_by_hash_and_restored_in_place() {
389 let connection = Connection::open_in_memory().unwrap();
390 connection.execute_batch(crate::schema::QUEUE).unwrap();
391 connection
392 .execute("INSERT INTO batches DEFAULT VALUES", [])
393 .unwrap();
394 let bytes: Arc<[u8]> = Arc::from(vec![7_u8; 100_000]);
395 let image = |id: u32| {
396 PageObject::Image(Image {
397 id: ExGuid {
398 guid: [1; 16],
399 n: id,
400 },
401 layout: Default::default(),
402 size: None,
403 bytes: Some(Arc::clone(&bytes)),
404 display: None,
405 alt: None,
406 background: false,
407 printout: None,
408 tags: Vec::new(),
409 link: None,
410 text: None,
411 })
412 };
413 let space = ExGuid {
414 guid: [2; 16],
415 n: 1,
416 };
417 let edit = Edit {
418 at: 1,
419 ops: [image(1), image(2)]
420 .into_iter()
421 .map(|object| Op::Page {
422 space,
423 op: PageOp::Add {
424 object,
425 before: None,
426 },
427 })
428 .collect(),
429 };
430 let id = insert(&connection, None, None, 1, "Author", &edit).unwrap();
431 let stored: String = connection
432 .query_row("SELECT edit FROM edits WHERE id=?1", [id as i64], |row| {
433 row.get(0)
434 })
435 .unwrap();
436 assert!(stored.len() < 1000, "{}", stored.len());
437 let payloads: i64 = connection
438 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
439 .unwrap();
440 assert_eq!(payloads, 1);
441 let loaded = load(&connection, None, None).unwrap();
442 assert_eq!(loaded[0].edit, edit);
443 let Op::Page {
444 op:
445 PageOp::Add {
446 object: PageObject::Image(image),
447 ..
448 },
449 ..
450 } = &loaded[0].edit.ops[1]
451 else {
452 panic!()
453 };
454 assert_eq!(image.bytes.as_deref(), Some(&bytes[..]));
455 connection.execute("DELETE FROM edits", []).unwrap();
456 collect(&connection).unwrap();
457 let payloads: i64 = connection
458 .query_row("SELECT count(*) FROM payloads", [], |row| row.get(0))
459 .unwrap();
460 assert_eq!(payloads, 0);
461 }
462}