1const MAGIC: &[u8; 8] = b"NOOKBKUP";
12const FORMAT_VER: u16 = 1;
13
14const REDB_MARKER: u32 = 0x0200_0000;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub(crate) struct BackupHeader {
21 pub format_ver: u16,
22 pub created_ms: u64,
23 pub schema_hash: Option<[u8; 32]>,
24 pub redb_marker: u32,
25 pub entry_count_hint: u64,
26}
27
28impl BackupHeader {
29 #[cfg(test)]
31 pub(crate) const SIZE: usize = 8 + 2 + 8 + 1 + 32 + 4 + 8;
32
33 pub(crate) fn write_to<W: std::io::Write>(&self, w: &mut W) -> std::io::Result<()> {
34 w.write_all(MAGIC)?;
35 w.write_all(&self.format_ver.to_be_bytes())?;
36 w.write_all(&self.created_ms.to_be_bytes())?;
37 if let Some(h) = self.schema_hash {
38 w.write_all(&[1u8])?;
39 w.write_all(&h)?;
40 } else {
41 w.write_all(&[0u8])?;
42 w.write_all(&[0u8; 32])?;
43 }
44 w.write_all(&self.redb_marker.to_be_bytes())?;
45 w.write_all(&self.entry_count_hint.to_be_bytes())?;
46 Ok(())
47 }
48
49 pub(crate) fn read_from<R: std::io::Read>(r: &mut R) -> Result<Self, crate::error::NookError> {
50 let mut magic = [0u8; 8];
51 read_exact_or_truncated(r, &mut magic)?;
52 if &magic != MAGIC {
53 return Err(crate::error::NookError::Corruption {
54 msg: "invalid backup magic".into(),
55 });
56 }
57 let mut fv = [0u8; 2];
58 read_exact_or_truncated(r, &mut fv)?;
59 let format_ver = u16::from_be_bytes(fv);
60 if format_ver != FORMAT_VER {
61 return Err(crate::error::NookError::Corruption {
62 msg: format!("unsupported backup format version {format_ver}"),
63 });
64 }
65 let mut cm = [0u8; 8];
66 read_exact_or_truncated(r, &mut cm)?;
67 let created_ms = u64::from_be_bytes(cm);
68 let mut sp = [0u8; 1];
69 read_exact_or_truncated(r, &mut sp)?;
70 let mut sh = [0u8; 32];
71 read_exact_or_truncated(r, &mut sh)?;
72 let schema_hash = match sp[0] {
73 0 => None,
74 1 => Some(sh),
75 other => {
76 return Err(crate::error::NookError::Corruption {
77 msg: format!("invalid schema_present byte {other}"),
78 });
79 }
80 };
81 let mut rm = [0u8; 4];
82 read_exact_or_truncated(r, &mut rm)?;
83 let redb_marker = u32::from_be_bytes(rm);
84 let mut ec = [0u8; 8];
85 read_exact_or_truncated(r, &mut ec)?;
86 let entry_count_hint = u64::from_be_bytes(ec);
87 Ok(Self {
88 format_ver,
89 created_ms,
90 schema_hash,
91 redb_marker,
92 entry_count_hint,
93 })
94 }
95}
96
97fn read_exact_or_truncated<R: std::io::Read>(
98 r: &mut R,
99 buf: &mut [u8],
100) -> Result<(), crate::error::NookError> {
101 r.read_exact(buf).map_err(|e| match e.kind() {
102 std::io::ErrorKind::UnexpectedEof => crate::error::NookError::Corruption {
103 msg: "truncated backup stream".into(),
104 },
105 _ => crate::error::NookError::Storage(e),
106 })
107}
108
109pub(crate) fn write_entry<W: std::io::Write>(
112 w: &mut W,
113 key: &[u8],
114 value: &[u8],
115) -> std::io::Result<()> {
116 debug_assert!(!key.is_empty(), "entry key must be non-empty");
117 w.write_all(
118 &u32::try_from(key.len())
119 .expect("key too large for backup frame")
120 .to_be_bytes(),
121 )?;
122 w.write_all(key)?;
123 w.write_all(
124 &u32::try_from(value.len())
125 .expect("value too large for backup frame")
126 .to_be_bytes(),
127 )?;
128 w.write_all(value)?;
129 Ok(())
130}
131
132pub(crate) fn write_sentinel<W: std::io::Write>(w: &mut W) -> std::io::Result<()> {
134 w.write_all(&0u32.to_be_bytes())?;
135 Ok(())
136}
137
138#[derive(Debug)]
140pub(crate) enum ReadEntry {
141 Entry { key: Vec<u8>, value: Vec<u8> },
142 Sentinel,
143}
144
145pub(crate) fn read_entry<R: std::io::Read>(
146 r: &mut R,
147) -> Result<ReadEntry, crate::error::NookError> {
148 let mut kl = [0u8; 4];
149 read_exact_or_truncated(r, &mut kl)?;
150 let key_len = u32::from_be_bytes(kl) as usize;
151 if key_len == 0 {
152 return Ok(ReadEntry::Sentinel);
153 }
154 let mut key = vec![0u8; key_len];
155 read_exact_or_truncated(r, &mut key)?;
156 let mut vl = [0u8; 4];
157 read_exact_or_truncated(r, &mut vl)?;
158 let value_len = u32::from_be_bytes(vl) as usize;
159 let mut value = vec![0u8; value_len];
160 read_exact_or_truncated(r, &mut value)?;
161 Ok(ReadEntry::Entry { key, value })
162}
163
164pub(crate) struct CrcWriter<W: std::io::Write> {
167 inner: W,
168 crc: crc32fast::Hasher,
169}
170
171impl<W: std::io::Write> CrcWriter<W> {
172 pub(crate) fn new(inner: W) -> Self {
173 Self {
174 inner,
175 crc: crc32fast::Hasher::new(),
176 }
177 }
178 pub(crate) fn finish(mut self) -> std::io::Result<(W, u32)> {
179 let sum = self.crc.finalize();
180 self.inner.write_all(&sum.to_be_bytes())?;
181 Ok((self.inner, sum))
182 }
183}
184
185impl<W: std::io::Write> std::io::Write for CrcWriter<W> {
186 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
187 let n = self.inner.write(buf)?;
188 self.crc.update(&buf[..n]);
189 Ok(n)
190 }
191 fn flush(&mut self) -> std::io::Result<()> {
192 self.inner.flush()
193 }
194}
195
196pub(crate) struct CrcReader<R: std::io::Read> {
200 inner: R,
201 crc: crc32fast::Hasher,
202}
203
204impl<R: std::io::Read> CrcReader<R> {
205 pub(crate) fn new(inner: R) -> Self {
206 Self {
207 inner,
208 crc: crc32fast::Hasher::new(),
209 }
210 }
211 pub(crate) fn finish_and_verify(mut self) -> Result<(), crate::error::NookError> {
212 let mut footer = [0u8; 4];
213 self.inner
215 .read_exact(&mut footer)
216 .map_err(|e| match e.kind() {
217 std::io::ErrorKind::UnexpectedEof => crate::error::NookError::Corruption {
218 msg: "truncated backup stream".into(),
219 },
220 _ => crate::error::NookError::from(e),
221 })?;
222 let expected = u32::from_be_bytes(footer);
223 let actual = self.crc.finalize();
224 if expected != actual {
225 return Err(crate::error::NookError::Corruption {
226 msg: "backup checksum mismatch".into(),
227 });
228 }
229 Ok(())
230 }
231}
232
233impl<R: std::io::Read> std::io::Read for CrcReader<R> {
234 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
235 let n = self.inner.read(buf)?;
236 self.crc.update(&buf[..n]);
237 Ok(n)
238 }
239}
240
241use std::path::Path;
242use std::time::{SystemTime, UNIX_EPOCH};
243
244use crate::database::Database;
245use crate::error::NookError;
246
247#[derive(Debug, Clone, Copy, Default)]
249pub struct BackupStats {
250 pub entry_count: u64,
251 pub bytes_written: u64,
252}
253
254#[derive(Debug, Clone, Copy, Default)]
256pub struct RestoreStats {
257 pub entry_count: u64,
258 pub bytes_read: u64,
259}
260
261#[derive(Debug, Clone, Copy, Default)]
263pub struct RestoreOptions {
264 pub allow_overwrite: bool,
265 pub skip_schema_check: bool,
266 pub current_schema_hash: Option<[u8; 32]>,
267}
268
269pub fn write_backup<W: std::io::Write>(
281 db: &Database,
282 w: &mut W,
283 schema_hash: Option<[u8; 32]>,
284) -> Result<BackupStats, NookError> {
285 let created_ms = SystemTime::now()
286 .duration_since(UNIX_EPOCH)
287 .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
288
289 #[allow(clippy::redundant_closure_for_method_calls)]
290 let entries = db.read(|tx| tx.list_entries_raw())?;
292
293 let mut counting = CountingWriter { inner: w, count: 0 };
294 let mut crc = CrcWriter::new(&mut counting);
295 let header = BackupHeader {
296 format_ver: FORMAT_VER,
297 created_ms,
298 schema_hash,
299 redb_marker: REDB_MARKER,
300 entry_count_hint: entries.len() as u64,
301 };
302 header.write_to(&mut crc).map_err(NookError::from)?;
303 for (k, v) in &entries {
304 write_entry(&mut crc, k, v).map_err(NookError::from)?;
305 }
306 write_sentinel(&mut crc).map_err(NookError::from)?;
307 crc.finish().map_err(NookError::from)?;
308
309 Ok(BackupStats {
310 entry_count: entries.len() as u64,
311 bytes_written: counting.count as u64,
312 })
313}
314
315struct CountingWriter<'a, W: std::io::Write> {
316 inner: &'a mut W,
317 count: usize,
318}
319
320impl<W: std::io::Write> std::io::Write for CountingWriter<'_, W> {
321 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
322 let n = self.inner.write(buf)?;
323 self.count += n;
324 Ok(n)
325 }
326 fn flush(&mut self) -> std::io::Result<()> {
327 self.inner.flush()
328 }
329}
330
331pub fn read_backup<R: std::io::Read>(
344 db: &Database,
345 r: &mut R,
346 opts: RestoreOptions,
347) -> Result<RestoreStats, NookError> {
348 let mut counting = CountingReader { inner: r, count: 0 };
349 let mut crc = CrcReader::new(&mut counting);
350 let header = BackupHeader::read_from(&mut crc)?;
351 if !opts.skip_schema_check {
352 if let (Some(bh), Some(ch)) = (header.schema_hash, opts.current_schema_hash) {
353 if bh != ch {
354 return Err(NookError::Schema {
355 msg: "backup schema hash mismatch".into(),
356 });
357 }
358 }
359 }
360
361 let mut entries: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
362 loop {
363 match read_entry(&mut crc)? {
364 ReadEntry::Sentinel => break,
365 ReadEntry::Entry { key, value } => entries.push((key, value)),
366 }
367 }
368 crc.finish_and_verify()?;
369
370 db.write(|tx| {
371 if opts.allow_overwrite {
372 tx.clear_entries()?;
373 } else if tx.has_any_entry()? {
374 return Err(NookError::Conflict {
375 msg: "restore target not empty".into(),
376 });
377 }
378 for (k, v) in &entries {
379 tx.put_raw(k, v)?;
380 }
381 Ok(())
382 })?;
383
384 let bytes_read = u64::try_from(counting.count).unwrap_or(u64::MAX);
385 let entry_count = u64::try_from(entries.len()).unwrap_or(u64::MAX);
386 Ok(RestoreStats {
387 entry_count,
388 bytes_read,
389 })
390}
391
392struct CountingReader<'a, R: std::io::Read> {
393 inner: &'a mut R,
394 count: usize,
395}
396
397impl<R: std::io::Read> std::io::Read for CountingReader<'_, R> {
398 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
399 let n = self.inner.read(buf)?;
400 self.count += n;
401 Ok(n)
402 }
403}
404
405use std::fs::{self, File};
406use std::io::{BufReader, BufWriter, Write as _};
407
408pub fn backup_to_path(
419 db: &Database,
420 path: &Path,
421 schema_hash: Option<[u8; 32]>,
422) -> Result<BackupStats, NookError> {
423 let tmp_path = path.with_extension(path.extension().map_or_else(
424 || "tmp".to_string(),
425 |e| format!("{}.tmp", e.to_string_lossy()),
426 ));
427 let stats = {
428 let file = File::create(&tmp_path).map_err(NookError::from)?;
429 let mut bw = BufWriter::new(file);
430 let stats = write_backup(db, &mut bw, schema_hash)?;
431 let mut file = bw
432 .into_inner()
433 .map_err(|e| NookError::from(std::io::Error::other(format!("flush tmp: {e}"))))?;
434 file.flush().map_err(NookError::from)?;
435 file.sync_all().map_err(NookError::from)?;
436 stats
437 };
438 fs::rename(&tmp_path, path).map_err(NookError::from)?;
439 Ok(stats)
440}
441
442pub fn restore_from_path(
448 db: &Database,
449 path: &Path,
450 opts: RestoreOptions,
451) -> Result<RestoreStats, NookError> {
452 let file = File::open(path).map_err(NookError::from)?;
453 let mut br = BufReader::new(file);
454 read_backup(db, &mut br, opts)
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 fn sample() -> BackupHeader {
462 BackupHeader {
463 format_ver: FORMAT_VER,
464 created_ms: 1_700_000_000_000,
465 schema_hash: Some([7u8; 32]),
466 redb_marker: REDB_MARKER,
467 entry_count_hint: 42,
468 }
469 }
470
471 #[test]
472 fn header_roundtrip_with_schema_hash() {
473 let h = sample();
474 let mut buf = Vec::new();
475 h.write_to(&mut buf).unwrap();
476 assert_eq!(buf.len(), BackupHeader::SIZE);
477 let read = BackupHeader::read_from(&mut buf.as_slice()).unwrap();
478 assert_eq!(read, h);
479 }
480
481 #[test]
482 fn header_roundtrip_without_schema_hash() {
483 let h = BackupHeader {
484 schema_hash: None,
485 ..sample()
486 };
487 let mut buf = Vec::new();
488 h.write_to(&mut buf).unwrap();
489 let read = BackupHeader::read_from(&mut buf.as_slice()).unwrap();
490 assert_eq!(read, h);
491 assert!(read.schema_hash.is_none());
492 }
493
494 #[test]
495 fn header_rejects_bad_magic() {
496 let mut buf = Vec::new();
497 sample().write_to(&mut buf).unwrap();
498 buf[0] = b'X';
499 let err = BackupHeader::read_from(&mut buf.as_slice()).unwrap_err();
500 match err {
501 crate::error::NookError::Corruption { msg } => {
502 assert!(msg.contains("invalid backup magic"), "msg={msg}");
503 }
504 other => panic!("expected Corruption, got {other:?}"),
505 }
506 }
507
508 #[test]
509 fn header_rejects_unknown_format_version() {
510 let mut buf = Vec::new();
511 sample().write_to(&mut buf).unwrap();
512 buf[8] = 0; buf[9] = 2; let err = BackupHeader::read_from(&mut buf.as_slice()).unwrap_err();
515 match err {
516 crate::error::NookError::Corruption { msg } => assert!(
517 msg.contains("unsupported backup format version 2"),
518 "msg={msg}"
519 ),
520 other => panic!("expected Corruption, got {other:?}"),
521 }
522 }
523
524 #[test]
525 fn header_truncated_yields_corruption() {
526 let mut buf = Vec::new();
527 sample().write_to(&mut buf).unwrap();
528 buf.truncate(BackupHeader::SIZE - 1);
529 let err = BackupHeader::read_from(&mut buf.as_slice()).unwrap_err();
530 match err {
531 crate::error::NookError::Corruption { msg } => {
532 assert!(msg.contains("truncated"), "msg={msg}");
533 }
534 other => panic!("expected Corruption, got {other:?}"),
535 }
536 }
537
538 #[test]
539 fn entry_roundtrip() {
540 let mut buf = Vec::new();
541 write_entry(&mut buf, b"users\0alice", b"value-a").unwrap();
542 write_entry(&mut buf, b"posts\0p1", b"hello").unwrap();
543 write_sentinel(&mut buf).unwrap();
544 let mut r = buf.as_slice();
545 match read_entry(&mut r).unwrap() {
546 ReadEntry::Entry { key, value } => {
547 assert_eq!(key, b"users\0alice");
548 assert_eq!(value, b"value-a");
549 }
550 ReadEntry::Sentinel => panic!("expected entry"),
551 }
552 match read_entry(&mut r).unwrap() {
553 ReadEntry::Entry { key, value } => {
554 assert_eq!(key, b"posts\0p1");
555 assert_eq!(value, b"hello");
556 }
557 ReadEntry::Sentinel => panic!("expected entry"),
558 }
559 assert!(matches!(read_entry(&mut r).unwrap(), ReadEntry::Sentinel));
560 }
561
562 #[test]
563 fn entry_truncated_after_key_len_is_corruption() {
564 let buf = [0u8, 0u8, 0u8, 5u8];
566 let err = read_entry(&mut buf.as_slice()).unwrap_err();
567 match err {
568 crate::error::NookError::Corruption { msg } => {
569 assert!(msg.contains("truncated"), "msg={msg}");
570 }
571 other => panic!("expected Corruption, got {other:?}"),
572 }
573 }
574
575 #[test]
576 fn entry_empty_value_roundtrip() {
577 let mut buf = Vec::new();
578 write_entry(&mut buf, b"k", b"").unwrap();
579 write_sentinel(&mut buf).unwrap();
580 let mut r = buf.as_slice();
581 match read_entry(&mut r).unwrap() {
582 ReadEntry::Entry { key, value } => {
583 assert_eq!(key, b"k");
584 assert!(value.is_empty());
585 }
586 ReadEntry::Sentinel => panic!("expected entry"),
587 }
588 }
589
590 #[test]
591 fn crc_roundtrip_clean() {
592 let mut buf: Vec<u8> = Vec::new();
593 {
594 let mut w = CrcWriter::new(&mut buf);
595 sample().write_to(&mut w).unwrap();
596 write_entry(&mut w, b"k", b"v").unwrap();
597 write_sentinel(&mut w).unwrap();
598 let (_inner, _sum) = w.finish().unwrap();
599 }
600 let mut r = CrcReader::new(buf.as_slice());
601 let _hdr = BackupHeader::read_from(&mut r).unwrap();
602 match read_entry(&mut r).unwrap() {
603 ReadEntry::Entry { .. } => {}
604 ReadEntry::Sentinel => panic!("expected entry"),
605 }
606 assert!(matches!(read_entry(&mut r).unwrap(), ReadEntry::Sentinel));
607 r.finish_and_verify().unwrap();
608 }
609
610 #[test]
611 fn crc_byte_flip_in_payload_detected() {
612 let mut buf: Vec<u8> = Vec::new();
613 {
614 let mut w = CrcWriter::new(&mut buf);
615 sample().write_to(&mut w).unwrap();
616 write_entry(&mut w, b"k", b"v").unwrap();
617 write_sentinel(&mut w).unwrap();
618 w.finish().unwrap();
619 }
620 buf[BackupHeader::SIZE + 4 + 1 + 4] ^= 0x55;
622 let mut r = CrcReader::new(buf.as_slice());
623 let _hdr = BackupHeader::read_from(&mut r).unwrap();
624 let _ = read_entry(&mut r);
627 let _ = read_entry(&mut r);
628 let err = r.finish_and_verify().unwrap_err();
629 match err {
630 crate::error::NookError::Corruption { msg } => {
631 assert!(msg.contains("checksum"), "msg={msg}");
632 }
633 other => panic!("expected Corruption, got {other:?}"),
634 }
635 }
636
637 use proptest::prelude::*;
638
639 proptest! {
640 #[test]
641 fn write_then_read_roundtrips_random_entries(
642 entries in proptest::collection::vec(
643 (proptest::collection::vec(any::<u8>(), 1..32),
644 proptest::collection::vec(any::<u8>(), 0..128)),
645 0..50,
646 )
647 ) {
648 let mut buf: Vec<u8> = Vec::new();
649 {
650 let mut w = CrcWriter::new(&mut buf);
651 BackupHeader {
652 format_ver: FORMAT_VER,
653 created_ms: 1,
654 schema_hash: None,
655 redb_marker: REDB_MARKER,
656 entry_count_hint: entries.len() as u64,
657 }.write_to(&mut w).unwrap();
658 for (k, v) in &entries {
659 write_entry(&mut w, k, v).unwrap();
660 }
661 write_sentinel(&mut w).unwrap();
662 w.finish().unwrap();
663 }
664 let mut r = CrcReader::new(buf.as_slice());
665 let _hdr = BackupHeader::read_from(&mut r).unwrap();
666 let mut read = Vec::new();
667 loop {
668 match read_entry(&mut r).unwrap() {
669 ReadEntry::Sentinel => break,
670 ReadEntry::Entry { key, value } => read.push((key, value)),
671 }
672 }
673 r.finish_and_verify().unwrap();
674 prop_assert_eq!(read, entries);
675 }
676 }
677}