Skip to main content

reddb_server/storage/engine/pager/
impl.rs

1use super::*;
2
3pub(super) const BTRFS_SUPER_MAGIC: i64 = 0x9123_683e;
4pub(super) const ZFS_SUPER_MAGIC: i64 = 0x2fc1_2fc1;
5pub(super) const FS_NOCOW_FL: u64 = 0x0080_0000;
6
7#[cfg(test)]
8pub(super) const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
9#[cfg(test)]
10pub(super) const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(super) enum CowFilesystemKind {
14    Zfs,
15    BtrfsDataCow,
16    TestOverride,
17}
18
19pub(super) fn classify_cow_filesystem(
20    fs_type: i64,
21    mount_options: Option<&str>,
22    inode_flags: Option<u64>,
23) -> Option<CowFilesystemKind> {
24    match fs_type {
25        ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
26        BTRFS_SUPER_MAGIC => {
27            let mount_options = mount_options?;
28            if mount_options.split(',').any(|option| option == "nodatacow") {
29                return None;
30            }
31
32            let inode_flags = inode_flags?;
33            if inode_flags & FS_NOCOW_FL != 0 {
34                return None;
35            }
36
37            Some(CowFilesystemKind::BtrfsDataCow)
38        }
39        _ => None,
40    }
41}
42
43#[cfg(target_os = "linux")]
44fn linux_fstatfs_type(file: &File) -> Option<i64> {
45    use std::mem::MaybeUninit;
46    use std::os::fd::AsRawFd;
47
48    let mut stat = MaybeUninit::<libc::statfs>::uninit();
49    let rc = unsafe { libc::fstatfs(file.as_raw_fd(), stat.as_mut_ptr()) };
50    if rc != 0 {
51        return None;
52    }
53    let stat = unsafe { stat.assume_init() };
54    // `statfs.f_type` is i32 on 32-bit glibc targets (armv7) and i64 on
55    // 64-bit ones — widen explicitly so both compile.
56    #[allow(clippy::unnecessary_cast)]
57    Some(stat.f_type as i64)
58}
59
60#[cfg(target_os = "linux")]
61fn linux_inode_flags(file: &File) -> Option<u64> {
62    use std::os::fd::AsRawFd;
63
64    let mut flags: libc::c_long = 0;
65    let rc = unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut flags) };
66    if rc != 0 {
67        return None;
68    }
69    Some(flags as u64)
70}
71
72#[cfg(target_os = "linux")]
73fn linux_mount_options_for_path(path: &Path) -> Option<String> {
74    let path = path.canonicalize().ok()?;
75    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
76    parse_mountinfo_options_for_path(&mountinfo, &path)
77}
78
79#[cfg(target_os = "linux")]
80pub(super) fn parse_mountinfo_options_for_path(mountinfo: &str, path: &Path) -> Option<String> {
81    let mut best: Option<(usize, String)> = None;
82
83    for line in mountinfo.lines() {
84        let fields: Vec<&str> = line.split(' ').collect();
85        if fields.len() < 10 {
86            continue;
87        }
88
89        let Some(separator) = fields.iter().position(|field| *field == "-") else {
90            continue;
91        };
92        if separator + 3 >= fields.len() || separator < 6 {
93            continue;
94        }
95
96        let mount_point = mountinfo_unescape_path(fields[4]);
97        if !path.starts_with(&mount_point) {
98            continue;
99        }
100
101        let fs_type = fields[separator + 1];
102        if fs_type != "btrfs" && fs_type != "zfs" {
103            continue;
104        }
105
106        let mount_options = fields[5];
107        let super_options = fields[separator + 3];
108        let options = format!("{mount_options},{super_options}");
109        let depth = mount_point.components().count();
110        if best
111            .as_ref()
112            .map(|(best_depth, _)| depth > *best_depth)
113            .unwrap_or(true)
114        {
115            best = Some((depth, options));
116        }
117    }
118
119    best.map(|(_, options)| options)
120}
121
122#[cfg(target_os = "linux")]
123fn mountinfo_unescape_path(value: &str) -> PathBuf {
124    let bytes = value.as_bytes();
125    let mut out = Vec::with_capacity(bytes.len());
126    let mut i = 0;
127
128    while i < bytes.len() {
129        if bytes[i] == b'\\' && i + 3 < bytes.len() {
130            let octal = &value[i + 1..i + 4];
131            if let Ok(byte) = u8::from_str_radix(octal, 8) {
132                out.push(byte);
133                i += 4;
134                continue;
135            }
136        }
137        out.push(bytes[i]);
138        i += 1;
139    }
140
141    PathBuf::from(String::from_utf8_lossy(&out).into_owned())
142}
143
144impl Pager {
145    /// Open or create a database file
146    pub fn open<P: AsRef<Path>>(path: P, mut config: PagerConfig) -> Result<Self, PagerError> {
147        let path = path.as_ref().to_path_buf();
148        let exists = path.exists();
149
150        if !exists && !config.create {
151            return Err(PagerError::InvalidDatabase(
152                "Database does not exist".into(),
153            ));
154        }
155
156        // ADR 0038 §4 phase 1 is a clean break: a store still carrying the
157        // retired `rdb-hdr`/`rdb-meta` sidecars is never read by the engine.
158        // The offline migration tool is the only path forward.
159        if exists {
160            if let Some(sidecar) = reddb_file::layout::retired::first_present_phase1_sidecar(&path)
161            {
162                return Err(PagerError::LegacySidecarStore { sidecar });
163            }
164        }
165
166        if !exists && config.read_only {
167            return Err(PagerError::InvalidDatabase(
168                "Cannot create read-only database".into(),
169            ));
170        }
171
172        // Open file
173        // Note: create requires write access, so disable it for read-only mode
174        let file = OpenOptions::new()
175            .read(true)
176            .write(!config.read_only)
177            .create(config.create && !config.read_only)
178            .open(&path)?;
179
180        // gh-892: diagnostic probe of the filesystem block size. If the
181        // compile-time 16 KiB PAGE_SIZE is not a multiple of the FS block
182        // size, page writes straddle FS blocks and incur read-modify-write
183        // amplification. Pure diagnostic — emitted once per open, never
184        // mutates the page size. `blksize()` is the fstat `st_blksize`.
185        #[cfg(unix)]
186        {
187            use std::os::unix::fs::MetadataExt;
188            if let Ok(meta) = file.metadata() {
189                let fs_block_size = meta.blksize();
190                if Self::page_size_misaligned_with_block(PAGE_SIZE, fs_block_size) {
191                    tracing::warn!(
192                        page_size = PAGE_SIZE,
193                        fs_block_size,
194                        path = %path.display(),
195                        "database page size is not a multiple of the filesystem \
196                         block size; page writes will straddle FS blocks \
197                         (read-modify-write amplification). Diagnostic only — \
198                         the page size is unchanged."
199                    );
200                }
201            }
202        }
203
204        // Lock the same handle that performs page I/O. Windows byte-range
205        // locks deny reads/writes through a separately opened handle even in
206        // this process. The owned VFS handle holds the fence until it closes.
207        if !config.read_only {
208            file.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
209        } else {
210            FileExt::try_lock_shared(&file).map_err(|_| PagerError::Locked)?;
211        }
212        // Own lock release across both ordinary close and later open failures.
213        let file = NativePagerFile(file);
214
215        // ADR 0038 phase 3: the DWB sidecar is retired. When DWB is active,
216        // the recovery frame is written to a reserved zone inside the data
217        // file and fsynced before in-place page writes. A pre-existing sidecar
218        // marks a legacy store that must go through the offline migration tool.
219        //
220        // gh-895: an explicit `double_write = false` request is honored only
221        // when the already-open data file is proven to live on a filesystem
222        // with atomic CoW page writes. Unknown and non-CoW filesystems fail
223        // closed by keeping the in-file DWB zone enabled.
224        let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
225        if exists {
226            if let Some(sidecar) =
227                reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
228            {
229                return Err(PagerError::LegacySidecarStore { sidecar });
230            }
231        }
232        if !config.double_write && !config.read_only && !fold_dwb {
233            let skip_dwb_on_cow =
234                Self::cow_filesystem_has_atomic_page_writes(&path, &file.0).is_some();
235            if !skip_dwb_on_cow {
236                tracing::warn!(
237                    path = %path.display(),
238                    "double_write=false requested, but the data file is not proven to be on \
239                     ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
240                );
241                config.double_write = true;
242            }
243        }
244        let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
245
246        let core = SharedPager::open_owned(
247            file,
248            path,
249            SharedPagerConfig {
250                disk_residency: config.disk_residency,
251                cache_size: config.cache_size,
252                read_only: config.read_only,
253                verify_checksums: config.verify_checksums,
254                double_write: dwb_enabled,
255            },
256            NativeWalBarrier::default(),
257            !exists,
258        )?;
259        let mut pager = Self {
260            core,
261            config,
262            encryption: None,
263        };
264
265        if exists {
266            pager.bind_encryption_for_existing()?;
267        } else {
268            // Initialize new database
269            pager.bind_encryption_for_new()?;
270        }
271
272        Ok(pager)
273    }
274
275    /// gh-892 diagnostic predicate: returns `true` when the database
276    /// `page_size` is **not** an integer multiple of the filesystem's
277    /// reported block size (`st_blksize`), i.e. `page_size % fs_block_size
278    /// != 0`. A `fs_block_size` of `0` (probe unavailable / unknown) is
279    /// treated as aligned so a missing probe never produces a warning.
280    /// Pure function with no I/O so the warn decision is unit-testable.
281    pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
282        fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
283    }
284
285    #[cfg(test)]
286    fn cow_filesystem_test_override() -> Option<bool> {
287        match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
288            1 => Some(true),
289            2 => Some(false),
290            _ => None,
291        }
292    }
293
294    #[cfg(not(test))]
295    fn cow_filesystem_test_override() -> Option<bool> {
296        None
297    }
298
299    fn cow_filesystem_has_atomic_page_writes(
300        path: &Path,
301        file: &File,
302    ) -> Option<CowFilesystemKind> {
303        if let Some(allowed) = Self::cow_filesystem_test_override() {
304            return allowed.then_some(CowFilesystemKind::TestOverride);
305        }
306        Self::probe_cow_filesystem(path, file)
307    }
308
309    #[cfg(target_os = "linux")]
310    fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
311        let fs_type = linux_fstatfs_type(file)?;
312        match fs_type {
313            ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
314            BTRFS_SUPER_MAGIC => {
315                let mount_options = linux_mount_options_for_path(path)?;
316                let inode_flags = linux_inode_flags(file)?;
317                classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
318            }
319            _ => None,
320        }
321    }
322
323    #[cfg(not(target_os = "linux"))]
324    fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
325        None
326    }
327
328    /// Inspect page 0 for the `RDBE` encryption marker, then resolve
329    /// the (key, marker) matrix:
330    ///
331    /// | Marker | Key supplied | Result                              |
332    /// |--------|--------------|-------------------------------------|
333    /// | yes    | yes          | Bind encryptor; validate key        |
334    /// | yes    | no           | `EncryptionRequired` (fail closed)  |
335    /// | no     | yes          | `PlainDatabaseRefusesKey`           |
336    /// | no     | no           | Plain pager — no binding needed     |
337    fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
338        if self.page_count().unwrap_or(0) == 0 {
339            return self.bind_encryption_for_new();
340        }
341        let (_, image) = self.newest_superblock()?;
342        let data = &image[..];
343        let has_marker = reddb_file::paged_encryption_marker_present(data);
344
345        let key = self.config.encryption.clone();
346        match (has_marker, key) {
347            (true, Some(key)) => {
348                let header_bytes =
349                    reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
350                        PagerError::InvalidDatabase("encryption header parse failed".to_string())
351                    })?;
352                let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
353                    .map_err(|e| {
354                        PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
355                    })?;
356                if !header.validate(&key) {
357                    return Err(PagerError::InvalidKey);
358                }
359                let encryptor = crate::storage::encryption::PageEncryptor::new(key);
360                self.encryption = Some((encryptor, header));
361                Ok(())
362            }
363            (true, None) => Err(PagerError::EncryptionRequired),
364            (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
365            (false, None) => Ok(()),
366        }
367    }
368
369    /// New DB: if a key is configured, write the marker + header to
370    /// page 0 so subsequent opens detect encryption.
371    fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
372        let Some(key) = self.config.encryption.clone() else {
373            return Ok(());
374        };
375        let header = crate::storage::encryption::EncryptionHeader::new(&key);
376        let encryptor = crate::storage::encryption::PageEncryptor::new(key);
377
378        // Stamp the marker + header into the superblock zone if `initialize()`
379        // has already published it.
380        if self.page_count().unwrap_or(0) > 0 {
381            let header_bytes = header.to_bytes();
382            self.commit_superblock(|image| {
383                reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
384                    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
385            })?;
386        }
387        self.encryption = Some((encryptor, header));
388        Ok(())
389    }
390
391    /// Open with default configuration
392    pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
393        Self::open(path, PagerConfig::default())
394    }
395
396    /// Read a page through the configured encryptor if any. Page 0
397    /// is always returned plaintext (it carries the encryption marker
398    /// + header). Callers that want raw cipher bytes can use
399    ///   `read_page_no_checksum` directly.
400    pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
401        if page_id == 0 || self.encryption.is_none() {
402            return self.read_page(page_id);
403        }
404        let raw = self.read_page_no_checksum(page_id)?;
405        let (enc, _) = self
406            .encryption
407            .as_ref()
408            .expect("encryption presence checked above");
409        let plaintext = enc
410            .decrypt(page_id, raw.as_bytes())
411            .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
412        let mut buf = [0u8; PAGE_SIZE];
413        let n = plaintext.len().min(PAGE_SIZE);
414        buf[..n].copy_from_slice(&plaintext[..n]);
415        Ok(Page::from_bytes(buf))
416    }
417
418    /// Write a page through the configured encryptor if any. Page 0
419    /// bypasses encryption and goes through the normal checksummed
420    /// path. Encrypted pages skip the checksum update because
421    /// AES-GCM's authentication tag is the integrity guarantee.
422    pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
423        if page_id == 0 || self.encryption.is_none() {
424            return self.write_page(page_id, page);
425        }
426        // Canonical per-page envelope overhead (nonce + GCM tag),
427        // owned by reddb-io-crypto (#1053, ADR 0054).
428        const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
429        let plaintext_len = PAGE_SIZE - OVERHEAD;
430        let plaintext = &page.as_bytes()[..plaintext_len];
431        let (enc, _) = self
432            .encryption
433            .as_ref()
434            .expect("encryption presence checked above");
435        let ciphertext = enc.encrypt(page_id, plaintext);
436        debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
437        let mut buf = [0u8; PAGE_SIZE];
438        buf.copy_from_slice(&ciphertext);
439        let cipher_page = Page::from_bytes(buf);
440        self.write_page_no_checksum(page_id, cipher_page)
441    }
442
443    /// Attach a WAL writer to enforce WAL-first flush ordering.
444    ///
445    /// After this call, [`Pager::flush`] computes the maximum
446    /// `header.lsn` over all dirty pages and calls
447    /// `WalWriter::flush_until(max_lsn)` before any page is written
448    /// to the data file. This is the postgres rule: a page on disk
449    /// implies its WAL record is already durable on disk.
450    ///
451    /// Existing call sites that construct a Pager without a WAL
452    /// keep their previous behaviour (no LSN check) — wiring is
453    /// strictly opt-in.
454    pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
455        let mut slot = self
456            .core
457            .wal_barrier()
458            .0
459            .write()
460            .unwrap_or_else(|p| p.into_inner());
461        *slot = Some(wal);
462    }
463
464    /// Detach the WAL writer (test / shutdown path).
465    pub fn clear_wal_writer(&self) {
466        let mut slot = self
467            .core
468            .wal_barrier()
469            .0
470            .write()
471            .unwrap_or_else(|p| p.into_inner());
472        *slot = None;
473    }
474
475    /// Has a WAL writer been attached?
476    pub fn has_wal_writer(&self) -> bool {
477        self.core
478            .wal_barrier()
479            .0
480            .read()
481            .map(|s| s.is_some())
482            .unwrap_or(false)
483    }
484
485    /// Install an ownership recorder while all other mutators are fenced.
486    #[allow(dead_code)] // Used when ownership recovery is activated.
487    pub(crate) fn install_ownership_recorder(
488        &self,
489        recorder: Arc<dyn super::PageOwnershipRecorder>,
490    ) -> std::io::Result<()> {
491        let mut slot = self
492            .core
493            .wal_barrier()
494            .1
495            .write()
496            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
497        if slot.is_some() {
498            return Err(std::io::Error::other(
499                "ownership recorder already installed",
500            ));
501        }
502        *slot = Some(recorder);
503        Ok(())
504    }
505
506    /// Remove the recorder after allocator copies and journal completion are
507    /// durable, while other mutators remain fenced.
508    pub(crate) fn clear_ownership_recorder(&self) -> std::io::Result<()> {
509        let mut slot = self
510            .core
511            .wal_barrier()
512            .1
513            .write()
514            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
515        if slot.take().is_none() {
516            return Err(std::io::Error::other("ownership recorder is not installed"));
517        }
518        Ok(())
519    }
520
521    /// Issue a best-effort OS read-ahead hint under the page-I/O lock.
522    pub fn prefetch_hint(&self, page_id: u32) {
523        let _ = self.core.with_file(|file| {
524            super::super::prefetch::prefetch_page(&file.0, u64::from(page_id), PAGE_SIZE as u32)
525        });
526    }
527
528    #[cfg(test)]
529    fn dwb_path(path: &Path) -> PathBuf {
530        reddb_file::layout::pager_dwb_shadow_path(path)
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn ownership_scope_serializes_other_allocators_and_fences_errors() {
540        let directory = tempfile::tempdir().expect("temporary directory");
541        let pager = Arc::new(
542            Pager::open(directory.path().join("data.rdb"), Default::default()).expect("pager"),
543        );
544        let scope = pager.lock_ownership_mutations();
545        let (started_tx, started_rx) = std::sync::mpsc::channel();
546        let (done_tx, done_rx) = std::sync::mpsc::channel();
547        let other_pager = Arc::clone(&pager);
548        let worker = std::thread::spawn(move || {
549            started_tx.send(()).expect("signal worker started");
550            let page = other_pager.allocate_page(PageType::BTreeLeaf);
551            done_tx
552                .send(page.map(|page| page.page_id()))
553                .expect("signal allocation");
554        });
555        started_rx.recv().expect("worker started");
556        assert!(done_rx
557            .recv_timeout(std::time::Duration::from_millis(100))
558            .is_err());
559        let own = pager
560            .allocate_page(PageType::BTreeLeaf)
561            .expect("reentrant allocation");
562        drop(scope);
563        let other = done_rx
564            .recv()
565            .expect("worker result")
566            .expect("other allocation");
567        worker.join().expect("worker");
568        assert_ne!(own.page_id(), other);
569        pager.fence_interrupted_ownership();
570        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
571    }
572
573    fn temp_db_path(name: &str) -> PathBuf {
574        std::env::temp_dir().join(format!(
575            "reddb-pager-{}-{}-{}.rdb",
576            name,
577            std::process::id(),
578            crate::utils::now_unix_nanos()
579        ))
580    }
581
582    fn cleanup(path: &Path) {
583        let _ = std::fs::remove_file(path);
584        let _ = std::fs::remove_file(Pager::dwb_path(path));
585    }
586
587    fn read_zone(path: &Path) -> Vec<u8> {
588        let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
589        let mut file = File::open(path).expect("open() should succeed");
590        file.read_exact(&mut zone)
591            .expect("read_exact() should succeed");
592        zone
593    }
594
595    #[test]
596    fn read_only_dwb_recovery_survives_cache_eviction_without_writing() {
597        let directory = tempfile::tempdir().expect("fixture");
598        let path = directory.path().join("data.rdb");
599        let writer = Pager::open_default(&path).expect("writer");
600        let mut pages = Vec::new();
601        for _ in 0..100 {
602            let mut page = writer.allocate_page(PageType::Overflow).expect("allocate");
603            page.update_checksum();
604            writer
605                .write_page(page.page_id(), page.clone())
606                .expect("page");
607            pages.push(page);
608        }
609        writer.sync().expect("checkpoint");
610        drop(writer);
611        let mut staged = pages.last().expect("tail page").clone();
612        staged.content_mut()[0] = 123;
613        staged.update_checksum();
614        let frame = reddb_file::encode_paged_dwb_frame([(staged.page_id(), staged.as_bytes())]);
615        let mut file = OpenOptions::new().write(true).open(&path).expect("fixture");
616        file.seek(SeekFrom::Start(
617            u64::from(reddb_engine::storage::pager_io::DWB_ZONE_START_PAGE) * PAGE_SIZE as u64,
618        ))
619        .expect("DWB offset");
620        file.write_all(&frame).expect("stage durable image");
621        // Simulate interruption before the staged tail page reached its home.
622        file.set_len(u64::from(staged.page_id()) * PAGE_SIZE as u64)
623            .expect("truncate tail");
624        file.sync_all().expect("fixture sync");
625        drop(file);
626        let before = std::fs::read(&path).expect("source bytes");
627        for _ in 0..2 {
628            let reader = Pager::open(
629                &path,
630                PagerConfig {
631                    read_only: true,
632                    create: false,
633                    cache_size: 2,
634                    ..Default::default()
635                },
636            )
637            .expect("read-only DWB recovery");
638            for page in &pages[..pages.len() - 1] {
639                reader
640                    .read_page(page.page_id())
641                    .expect("evict ordinary cache entries");
642            }
643            assert_eq!(
644                reader
645                    .read_page(staged.page_id())
646                    .expect("recovered tail")
647                    .as_bytes(),
648                staged.as_bytes()
649            );
650            assert!(reader.write_page(staged.page_id(), staged.clone()).is_err());
651            drop(reader);
652            assert_eq!(std::fs::read(&path).expect("unchanged source"), before);
653        }
654        let writer = Pager::open_default(&path).expect("ordinary DWB recovery");
655        assert_eq!(
656            writer
657                .read_page(staged.page_id())
658                .expect("durable tail")
659                .as_bytes(),
660            staged.as_bytes()
661        );
662    }
663
664    #[test]
665    fn read_only_dwb_retains_raw_pages_for_host_authentication() {
666        let directory = tempfile::tempdir().expect("fixture");
667        let path = directory.path().join("data.rdb");
668        let writer = Pager::open_default(&path).expect("writer");
669        let id = writer
670            .allocate_page(PageType::Overflow)
671            .expect("page")
672            .page_id();
673        writer.sync().expect("checkpoint");
674        drop(writer);
675        // Ciphertext does not carry a plaintext PageHeader or page checksum.
676        let raw = [91; PAGE_SIZE];
677        let frame = reddb_file::encode_paged_dwb_frame([(id, &raw)]);
678        let mut file = OpenOptions::new().write(true).open(&path).expect("fixture");
679        file.seek(SeekFrom::Start(
680            u64::from(DWB_ZONE_START_PAGE) * PAGE_SIZE as u64,
681        ))
682        .expect("DWB offset");
683        file.write_all(&frame).expect("staged raw frame");
684        file.sync_all().expect("fixture sync");
685        drop(file);
686        let before = std::fs::read(&path).expect("source");
687        let reader = Pager::open(
688            &path,
689            PagerConfig {
690                read_only: true,
691                create: false,
692                ..Default::default()
693            },
694        )
695        .expect("raw DWB recovery");
696        assert_eq!(
697            reader
698                .read_page_no_checksum(id)
699                .expect("raw image")
700                .as_bytes(),
701            &raw
702        );
703        assert!(
704            reader.read_page(id).is_err(),
705            "normal reads still require a page checksum"
706        );
707        drop(reader);
708        assert_eq!(std::fs::read(&path).expect("unchanged source"), before);
709    }
710
711    #[test]
712    fn read_only_recovery_refuses_excess_images_without_writing() {
713        let directory = tempfile::tempdir().expect("fixture");
714        let path = directory.path().join("data.rdb");
715        drop(Pager::open_default(&path).expect("writer"));
716        let before = std::fs::read(&path).expect("source");
717        let reader = Pager::open(
718            &path,
719            PagerConfig {
720                read_only: true,
721                create: false,
722                cache_size: 2,
723                ..Default::default()
724            },
725        )
726        .expect("reader");
727        let limit = reddb_engine::storage::pager_io::DWB_MAX_PAGES_PER_FRAME;
728        for index in 0..=limit {
729            let id = reddb_engine::storage::pager_io::FIRST_ALLOCATABLE_PAGE + index as u32;
730            let mut page = Page::new(PageType::Overflow, id);
731            page.update_checksum();
732            let result = reader.restore_read_only_page(id, page);
733            if index < limit {
734                result.expect("within recovery budget");
735            } else {
736                assert!(result
737                    .expect_err("bounded recovery")
738                    .to_string()
739                    .contains("budget"));
740            }
741        }
742        drop(reader);
743        assert_eq!(std::fs::read(&path).expect("unchanged source"), before);
744    }
745
746    /// Overwrite one superblock slot in place, leaving the sibling untouched.
747    fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
748        let mut file = OpenOptions::new()
749            .write(true)
750            .open(path)
751            .expect("open() should succeed");
752        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
753            copy_index,
754        )))
755        .expect("operation should succeed");
756        file.write_all(bytes).expect("write_all() should succeed");
757        file.sync_all().expect("sync_all() should succeed");
758    }
759
760    fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
761        let zone = read_zone(path);
762        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
763            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
764            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
765            let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
766                .expect("fresh store should publish both slots");
767            reddb_file::set_database_header_version(
768                &mut slot,
769                reddb_file::PAGE_DWB_ZONE_FILE_VERSION - 1,
770            )
771            .expect("set_database_header_version() should succeed");
772            reddb_file::set_database_header_page_count(&mut slot, page_count)
773                .expect("set_database_header_page_count() should succeed");
774            reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
775                .expect("seal_paged_superblock_slot() should succeed");
776            poke_slot(path, copy_index, &slot);
777        }
778    }
779
780    #[test]
781    fn open_refuses_future_database_version() {
782        let path = temp_db_path("future-version");
783        let pager = Pager::open_default(&path).expect("open_default() should succeed");
784        drop(pager);
785
786        // A *valid* superblock slot — correct magic, copy index and CRC — whose
787        // database header names a version we cannot read. The slot is intact;
788        // the header is from the future.
789        let selection = reddb_file::select_paged_superblock(&read_zone(&path))
790            .expect("operation should succeed");
791        let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
792        reddb_file::init_database_header_page(&mut slot, 1)
793            .expect("init_database_header_page() should succeed");
794        reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
795            .expect("operation should succeed");
796        reddb_file::seal_paged_superblock_slot(
797            &mut slot,
798            selection.copy_index,
799            selection.generation + 1,
800        )
801        .expect("operation should succeed");
802        poke_slot(&path, selection.copy_index, &slot);
803
804        let err = match Pager::open_default(&path) {
805            Ok(_) => panic!("future database version should be rejected"),
806            Err(err) => err,
807        };
808        match err {
809            PagerError::InvalidDatabase(msg) => {
810                assert!(msg.contains("newer than supported"), "{msg}");
811            }
812            other => panic!("expected InvalidDatabase, got {other:?}"),
813        }
814
815        cleanup(&path);
816    }
817
818    #[test]
819    fn a_fresh_store_publishes_both_superblock_copies() {
820        // The ping-pong invariant must hold from the very first update, which
821        // means creation cannot leave one copy blank.
822        let path = temp_db_path("fresh-pair");
823        let pager = Pager::open_default(&path).expect("open_default() should succeed");
824        drop(pager);
825
826        let zone = read_zone(&path);
827        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
828            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
829            assert!(
830                reddb_file::paged_superblock_slot_generation(
831                    &zone[start..start + SUPERBLOCK_SLOT_SIZE],
832                    copy_index
833                )
834                .is_some(),
835                "copy {copy_index} must be valid on a fresh store"
836            );
837        }
838        cleanup(&path);
839    }
840
841    #[test]
842    fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
843        let path = temp_db_path("ping-pong");
844        let pager = Pager::open_default(&path).expect("open_default() should succeed");
845        let before = reddb_file::select_paged_superblock(&read_zone(&path))
846            .expect("operation should succeed");
847
848        pager
849            .allocate_page(PageType::BTreeLeaf)
850            .expect("allocate_page() should succeed");
851        pager.sync().expect("sync() should succeed");
852
853        let zone = read_zone(&path);
854        let after = reddb_file::select_paged_superblock(&zone)
855            .expect("select_paged_superblock() should succeed");
856        assert_ne!(
857            after.copy_index, before.copy_index,
858            "an update must target the stale copy"
859        );
860        assert!(after.generation > before.generation);
861
862        // The copy that was newest before the update is still readable — this
863        // is the byte-level statement of "a crash mid-write always leaves one
864        // valid copy".
865        let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
866        assert_eq!(
867            reddb_file::paged_superblock_slot_generation(
868                &zone[start..start + SUPERBLOCK_SLOT_SIZE],
869                before.copy_index
870            ),
871            Some(before.generation)
872        );
873
874        drop(pager);
875        cleanup(&path);
876    }
877
878    #[test]
879    fn failed_trunk_fence_is_completed_before_retrying_allocation() {
880        let path = temp_db_path("retry-trunk-fence");
881        let pager = Pager::open_default(&path).expect("pager");
882        // An empty trunk is itself reusable, so consuming its last page must
883        // still fence both superblocks even outside an ownership epoch.
884        let page = pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id();
885        pager.sync().unwrap();
886        pager.free_page(page).unwrap();
887        pager.sync().unwrap();
888        let trunk = pager.header().unwrap().freelist_head;
889        assert_ne!(trunk, 0);
890
891        // A real write error after the trunk has been read must not turn a
892        // subsequent allocation into an unfenced fast-path reuse.
893        let writable = pager
894            .core
895            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
896            .unwrap();
897        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
898        pager.core.with_file(|file| file.0 = writable).unwrap();
899        pager.allocate_page(PageType::BTreeLeaf).unwrap();
900
901        let zone = read_zone(&path);
902        for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
903            let start = copy * SUPERBLOCK_SLOT_SIZE;
904            let header =
905                reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
906                    .unwrap();
907            assert_ne!(
908                header.freelist_head, trunk,
909                "copy {copy} still owns consumed trunk"
910            );
911        }
912        drop(pager);
913        cleanup(&path);
914    }
915
916    #[test]
917    fn failed_unjournaled_trunk_write_requires_reopen_and_preserves_free_pages() {
918        let path = temp_db_path("failed-trunk-rewrite");
919        let pager = Pager::open_default(&path).expect("pager");
920        let mut pages = std::collections::HashSet::new();
921        for _ in 0..8 {
922            pages.insert(
923                pager
924                    .allocate_page(PageType::BTreeLeaf)
925                    .expect("seed page")
926                    .page_id(),
927            );
928        }
929        pager.sync().expect("persist seed");
930        for &id in &pages {
931            pager.free_page(id).expect("free seed page");
932        }
933        pager.sync().expect("persist free list");
934        let baseline = pager.page_count().expect("page count");
935        let writable = pager
936            .core
937            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).expect("read only")))
938            .expect("inject write failure");
939        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
940        pager
941            .core
942            .with_file(|file| file.0 = writable)
943            .expect("restore file");
944        let error = pager
945            .allocate_page(PageType::BTreeLeaf)
946            .expect_err("writeback must stay fenced");
947        assert!(error.to_string().contains("reopen"), "{error}");
948        drop(pager);
949
950        let recovered = Pager::open_default(&path).expect("recover protected write");
951        let mut reused = std::collections::HashSet::new();
952        for _ in 0..pages.len() {
953            let id = recovered
954                .allocate_page(PageType::BTreeLeaf)
955                .expect("reuse after recovery")
956                .page_id();
957            if id >= baseline {
958                break;
959            }
960            assert!(pages.contains(&id), "unexpected free page {id}");
961            assert!(reused.insert(id), "duplicate reuse {id}");
962        }
963        // Only the single uncertain reservation may have been removed. The
964        // protected write must never detach and lose the rest of the trunk.
965        assert!(reused.len() >= pages.len() - 1, "lost free-page remainder");
966        drop(recovered);
967        cleanup(&path);
968    }
969
970    #[test]
971    fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
972        let path = temp_db_path("reused-allocator-pair");
973        let pager = Pager::open_default(&path).expect("pager");
974        let mut pages = Vec::new();
975        for _ in 0..32 {
976            pages.push(
977                pager
978                    .allocate_page(PageType::BTreeLeaf)
979                    .expect("allocate")
980                    .page_id(),
981            );
982        }
983        pager.sync().expect("seed");
984        for id in pages {
985            pager.free_page(id).expect("free");
986        }
987        pager.sync().expect("publish free list");
988        let mut live = std::collections::HashSet::new();
989        for _ in 0..8 {
990            let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
991            live.insert(page.page_id());
992            pager
993                .write_page(page.page_id(), page)
994                .expect("write valid page");
995        }
996        pager.sync().expect("publish reused ownership");
997        drop(pager);
998        let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
999        poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
1000        let recovered = Pager::open_default(&path).expect("sibling recovery");
1001        for _ in 0..64 {
1002            let id = recovered
1003                .allocate_page(PageType::BTreeLeaf)
1004                .expect("allocate after recovery")
1005                .page_id();
1006            assert!(
1007                !live.contains(&id),
1008                "sibling allocator still considers live page {id} free"
1009            );
1010        }
1011        drop(recovered);
1012        cleanup(&path);
1013    }
1014
1015    #[test]
1016    fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
1017        let path = temp_db_path("torn-newest");
1018        {
1019            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1020            pager
1021                .allocate_page(PageType::BTreeLeaf)
1022                .expect("allocate_page() should succeed");
1023            pager.sync().expect("sync() should succeed");
1024        }
1025
1026        let newest = reddb_file::select_paged_superblock(&read_zone(&path))
1027            .expect("operation should succeed");
1028        // Tear the newest copy: a torn write leaves its CRC broken.
1029        let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
1030        torn.copy_from_slice(
1031            &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
1032                ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
1033        );
1034        torn[64] ^= 0xFF;
1035        poke_slot(&path, newest.copy_index, &torn);
1036
1037        let before = std::fs::read(&path).expect("torn source");
1038        let reader = Pager::open(
1039            &path,
1040            PagerConfig {
1041                read_only: true,
1042                create: false,
1043                ..Default::default()
1044            },
1045        )
1046        .expect("read-only fallback to older superblock");
1047        assert!(reader.page_count().expect("read-only page count") >= 3);
1048        drop(reader);
1049        assert_eq!(std::fs::read(&path).expect("preserved torn source"), before);
1050
1051        let pager = Pager::open_default(&path).expect("older copy must root the store");
1052        assert!(pager.page_count().expect("page_count() should succeed") >= 3);
1053        drop(pager);
1054        cleanup(&path);
1055    }
1056
1057    #[test]
1058    fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
1059        let path = temp_db_path("both-invalid");
1060        {
1061            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1062        }
1063
1064        let zone = read_zone(&path);
1065        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1066            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
1067            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
1068            slot[64] ^= 0xFF;
1069            poke_slot(&path, copy_index, &slot);
1070        }
1071
1072        let err = match Pager::open_default(&path) {
1073            Ok(_) => panic!("a rootless store must not open"),
1074            Err(err) => err,
1075        };
1076        let rendered = err.to_string();
1077        assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
1078        assert!(rendered.contains("superblock zone"), "{rendered}");
1079        assert!(rendered.contains("Preserve the database"), "{rendered}");
1080        assert!(rendered.contains("verified backup"), "{rendered}");
1081        assert!(!rendered.contains("salvage"), "{rendered}");
1082
1083        cleanup(&path);
1084    }
1085
1086    #[test]
1087    fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
1088        let path = temp_db_path("legacy-sidecar");
1089        {
1090            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1091        }
1092
1093        let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
1094        std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
1095
1096        let err = match Pager::open_default(&path) {
1097            Ok(_) => panic!("legacy stores need migration first"),
1098            Err(err) => err,
1099        };
1100        let rendered = err.to_string();
1101        assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
1102        assert!(rendered.contains("migration tool"), "{rendered}");
1103
1104        let _ = std::fs::remove_file(&sidecar);
1105        cleanup(&path);
1106    }
1107
1108    #[test]
1109    fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
1110        let path = temp_db_path("pre-phase3-large");
1111        {
1112            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1113        }
1114        stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
1115
1116        let err = match Pager::open_default(&path) {
1117            Ok(_) => panic!("pre-phase-3 stores need migration first"),
1118            Err(err) => err,
1119        };
1120        let rendered = err.to_string();
1121        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
1122        assert!(rendered.contains("migration tool"), "{rendered}");
1123
1124        cleanup(&path);
1125    }
1126
1127    #[test]
1128    fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
1129        let path = temp_db_path("pre-phase3-small");
1130        {
1131            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1132        }
1133        stamp_pre_phase3_layout(&path, 3);
1134        OpenOptions::new()
1135            .write(true)
1136            .open(&path)
1137            .expect("open() should succeed")
1138            .set_len(3 * PAGE_SIZE as u64)
1139            .expect("set_len() should succeed");
1140
1141        let err = match Pager::open_default(&path) {
1142            Ok(_) => panic!("pre-phase-3 stores need migration first"),
1143            Err(err) => err,
1144        };
1145        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
1146
1147        cleanup(&path);
1148    }
1149
1150    #[test]
1151    fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
1152        let path = temp_db_path("census");
1153        {
1154            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1155            let page = pager
1156                .allocate_page(PageType::BTreeLeaf)
1157                .expect("allocate_page() should succeed");
1158            pager
1159                .write_page(page.page_id(), page)
1160                .expect("operation should succeed");
1161            pager.sync().expect("sync() should succeed");
1162        }
1163        {
1164            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1165            pager.sync().expect("sync() should succeed");
1166        }
1167
1168        assert_eq!(
1169            reddb_file::layout::retired::first_present_phase1_sidecar(&path),
1170            None,
1171            "the pager must never create a retired phase-1 sidecar"
1172        );
1173        cleanup(&path);
1174    }
1175}