Skip to main content

nookdb_core/
backup.rs

1//! Portable `.nbkp` backup and restore.
2//!
3//! Online logical export of all entries via a redb read-txn snapshot;
4//! restore replays into a single redb write transaction. The Rust core
5//! is schema-agnostic — the schema hash, when known to the caller, is
6//! recorded in the backup header.
7//!
8
9// Public surface filled in by subsequent tasks.
10
11const MAGIC: &[u8; 8] = b"NOOKBKUP";
12const FORMAT_VER: u16 = 1;
13
14/// The redb major.minor marker recorded in the header. Informational; the
15/// logical backup format does not depend on redb internals.
16const REDB_MARKER: u32 = 0x0200_0000;
17
18/// Backup header. All multi-byte integers are big-endian.
19#[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    /// Total on-disk size of the header, in bytes.
30    #[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
109/// Writes one entry: `key_len u32 BE | key | value_len u32 BE | value`.
110/// `key.len()` must be > 0 — a zero-length key is reserved for the sentinel.
111pub(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
132/// Writes the end-of-entries sentinel: a single `u32 BE = 0` key length.
133pub(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/// One streamed read result: either an entry, or the sentinel marking EOF.
139#[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
164/// Wrapper that mirrors bytes through to `inner` while updating a
165/// running CRC32 hash.
166pub(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
196/// Wrapper that mirrors bytes from `inner` while updating a running
197/// CRC32 hash. Caller is responsible for reading exactly the bytes that
198/// were CRC'd at write time, then calling `finish_and_verify`.
199pub(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        // Read the footer DIRECTLY from inner (do NOT update CRC with the footer itself).
214        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/// Statistics returned by a successful [`write_backup`] call.
248#[derive(Debug, Clone, Copy, Default)]
249pub struct BackupStats {
250    pub entry_count: u64,
251    pub bytes_written: u64,
252}
253
254/// Statistics returned by a successful restore call.
255#[derive(Debug, Clone, Copy, Default)]
256pub struct RestoreStats {
257    pub entry_count: u64,
258    pub bytes_read: u64,
259}
260
261/// Options controlling restore behaviour.
262#[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
269/// Streams every (`composite_key`, value) entry in `db` to `w` in the `.nbkp` v1 format.
270///
271/// The DB is read under a single redb read transaction (consistent MVCC snapshot —
272/// concurrent writers are not blocked). `schema_hash` is recorded in the header when
273/// `Some`; pass `None` if no schema is registered with this Database.
274///
275/// # Errors
276///
277/// Returns `NookError::Storage` on I/O failures and `NookError::Corruption`
278/// on internal invariants violated (e.g. composite key with empty prefix —
279/// not expected on a well-formed redb).
280pub 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    // trait method lookup requires explicit closure
291    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
331/// Reads a `.nbkp` v1 stream from `r` into `db` according to `opts`.
332///
333/// Validates magic, format version, and CRC32 footer. Schema-hash and
334/// overwrite checks are applied before any data is written; the redb
335/// write transaction is atomic (rolled back on any failure).
336///
337/// # Errors
338///
339/// `NookError::Corruption` for format problems (magic, version, CRC,
340/// truncation). `NookError::Schema` for schema-hash mismatch.
341/// `NookError::Conflict` for non-empty target without `allow_overwrite`.
342/// `NookError::Storage` for I/O failures.
343pub 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
408/// Writes a backup to `path` atomically: first to `<path>.tmp`, then
409/// fsync, then rename.
410///
411/// On success the original `path` contains the complete backup and no
412/// leftover `.tmp` file remains.
413///
414/// # Errors
415///
416/// Same as [`write_backup`], plus filesystem errors for tmp creation,
417/// fsync, or rename.
418pub 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
442/// Reads a backup file at `path` and restores it into `db` per `opts`.
443///
444/// # Errors
445///
446/// Same as [`read_backup`], plus filesystem errors for opening `path`.
447pub 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; // high byte
513        buf[9] = 2; // low byte → format_ver=2 (BE)
514        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        // 4 bytes of key_len = 5, then EOF before the key body.
565        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        // Flip one byte of the entry value (after the header).
621        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        // The corrupted value may still parse as an entry; we expect the
625        // CRC verify at the end to detect the corruption.
626        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}