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)]
8const PHASE3_DWB_ZONE_FILE_VERSION: u32 = reddb_file::PAGE_FILE_VERSION;
9
10#[cfg(test)]
11pub(super) const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
12#[cfg(test)]
13pub(super) const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(super) enum CowFilesystemKind {
17    Zfs,
18    BtrfsDataCow,
19    TestOverride,
20}
21
22pub(super) fn classify_cow_filesystem(
23    fs_type: i64,
24    mount_options: Option<&str>,
25    inode_flags: Option<u64>,
26) -> Option<CowFilesystemKind> {
27    match fs_type {
28        ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
29        BTRFS_SUPER_MAGIC => {
30            let mount_options = mount_options?;
31            if mount_options.split(',').any(|option| option == "nodatacow") {
32                return None;
33            }
34
35            let inode_flags = inode_flags?;
36            if inode_flags & FS_NOCOW_FL != 0 {
37                return None;
38            }
39
40            Some(CowFilesystemKind::BtrfsDataCow)
41        }
42        _ => None,
43    }
44}
45
46#[cfg(target_os = "linux")]
47fn linux_fstatfs_type(file: &File) -> Option<i64> {
48    use std::mem::MaybeUninit;
49    use std::os::fd::AsRawFd;
50
51    let mut stat = MaybeUninit::<libc::statfs>::uninit();
52    let rc = unsafe { libc::fstatfs(file.as_raw_fd(), stat.as_mut_ptr()) };
53    if rc != 0 {
54        return None;
55    }
56    let stat = unsafe { stat.assume_init() };
57    // `statfs.f_type` is i32 on 32-bit glibc targets (armv7) and i64 on
58    // 64-bit ones — widen explicitly so both compile.
59    #[allow(clippy::unnecessary_cast)]
60    Some(stat.f_type as i64)
61}
62
63#[cfg(target_os = "linux")]
64fn linux_inode_flags(file: &File) -> Option<u64> {
65    use std::os::fd::AsRawFd;
66
67    let mut flags: libc::c_long = 0;
68    let rc = unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut flags) };
69    if rc != 0 {
70        return None;
71    }
72    Some(flags as u64)
73}
74
75#[cfg(target_os = "linux")]
76fn linux_mount_options_for_path(path: &Path) -> Option<String> {
77    let path = path.canonicalize().ok()?;
78    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
79    parse_mountinfo_options_for_path(&mountinfo, &path)
80}
81
82#[cfg(target_os = "linux")]
83pub(super) fn parse_mountinfo_options_for_path(mountinfo: &str, path: &Path) -> Option<String> {
84    let mut best: Option<(usize, String)> = None;
85
86    for line in mountinfo.lines() {
87        let fields: Vec<&str> = line.split(' ').collect();
88        if fields.len() < 10 {
89            continue;
90        }
91
92        let Some(separator) = fields.iter().position(|field| *field == "-") else {
93            continue;
94        };
95        if separator + 3 >= fields.len() || separator < 6 {
96            continue;
97        }
98
99        let mount_point = mountinfo_unescape_path(fields[4]);
100        if !path.starts_with(&mount_point) {
101            continue;
102        }
103
104        let fs_type = fields[separator + 1];
105        if fs_type != "btrfs" && fs_type != "zfs" {
106            continue;
107        }
108
109        let mount_options = fields[5];
110        let super_options = fields[separator + 3];
111        let options = format!("{mount_options},{super_options}");
112        let depth = mount_point.components().count();
113        if best
114            .as_ref()
115            .map(|(best_depth, _)| depth > *best_depth)
116            .unwrap_or(true)
117        {
118            best = Some((depth, options));
119        }
120    }
121
122    best.map(|(_, options)| options)
123}
124
125#[cfg(target_os = "linux")]
126fn mountinfo_unescape_path(value: &str) -> PathBuf {
127    let bytes = value.as_bytes();
128    let mut out = Vec::with_capacity(bytes.len());
129    let mut i = 0;
130
131    while i < bytes.len() {
132        if bytes[i] == b'\\' && i + 3 < bytes.len() {
133            let octal = &value[i + 1..i + 4];
134            if let Ok(byte) = u8::from_str_radix(octal, 8) {
135                out.push(byte);
136                i += 4;
137                continue;
138            }
139        }
140        out.push(bytes[i]);
141        i += 1;
142    }
143
144    PathBuf::from(String::from_utf8_lossy(&out).into_owned())
145}
146
147impl Pager {
148    /// Open or create a database file
149    pub fn open<P: AsRef<Path>>(path: P, mut config: PagerConfig) -> Result<Self, PagerError> {
150        let path = path.as_ref().to_path_buf();
151        let exists = path.exists();
152
153        if !exists && !config.create {
154            return Err(PagerError::InvalidDatabase(
155                "Database does not exist".into(),
156            ));
157        }
158
159        // ADR 0038 §4 phase 1 is a clean break: a store still carrying the
160        // retired `rdb-hdr`/`rdb-meta` sidecars is never read by the engine.
161        // The offline migration tool is the only path forward.
162        if exists {
163            if let Some(sidecar) = reddb_file::layout::retired::first_present_phase1_sidecar(&path)
164            {
165                return Err(PagerError::LegacySidecarStore { sidecar });
166            }
167        }
168
169        if !exists && config.read_only {
170            return Err(PagerError::InvalidDatabase(
171                "Cannot create read-only database".into(),
172            ));
173        }
174
175        // Open file
176        // Note: create requires write access, so disable it for read-only mode
177        let file = OpenOptions::new()
178            .read(true)
179            .write(!config.read_only)
180            .create(config.create && !config.read_only)
181            .open(&path)?;
182
183        // gh-892: diagnostic probe of the filesystem block size. If the
184        // compile-time 16 KiB PAGE_SIZE is not a multiple of the FS block
185        // size, page writes straddle FS blocks and incur read-modify-write
186        // amplification. Pure diagnostic — emitted once per open, never
187        // mutates the page size. `blksize()` is the fstat `st_blksize`.
188        #[cfg(unix)]
189        {
190            use std::os::unix::fs::MetadataExt;
191            if let Ok(meta) = file.metadata() {
192                let fs_block_size = meta.blksize();
193                if Self::page_size_misaligned_with_block(PAGE_SIZE, fs_block_size) {
194                    tracing::warn!(
195                        page_size = PAGE_SIZE,
196                        fs_block_size,
197                        path = %path.display(),
198                        "database page size is not a multiple of the filesystem \
199                         block size; page writes will straddle FS blocks \
200                         (read-modify-write amplification). Diagnostic only — \
201                         the page size is unchanged."
202                    );
203                }
204            }
205        }
206
207        // Acquire file lock (exclusive for writes, shared for read-only)
208        let lock_file = if !config.read_only {
209            let lf = OpenOptions::new().read(true).write(true).open(&path)?;
210            lf.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
211            Some(lf)
212        } else {
213            let lf = OpenOptions::new().read(true).open(&path)?;
214            match lf.try_lock_shared() {
215                Ok(_) => Some(lf),
216                Err(_) => None,
217            }
218        };
219
220        // ADR 0038 phase 3: the DWB sidecar is retired. When DWB is active,
221        // the recovery frame is written to a reserved zone inside the data
222        // file and fsynced before in-place page writes. A pre-existing sidecar
223        // marks a legacy store that must go through the offline migration tool.
224        //
225        // gh-895: an explicit `double_write = false` request is honored only
226        // when the already-open data file is proven to live on a filesystem
227        // with atomic CoW page writes. Unknown and non-CoW filesystems fail
228        // closed by keeping the in-file DWB zone enabled.
229        let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
230        if exists {
231            if let Some(sidecar) =
232                reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
233            {
234                return Err(PagerError::LegacySidecarStore { sidecar });
235            }
236        }
237        if !config.double_write && !config.read_only && !fold_dwb {
238            let skip_dwb_on_cow =
239                Self::cow_filesystem_has_atomic_page_writes(&path, &file).is_some();
240            if !skip_dwb_on_cow {
241                tracing::warn!(
242                    path = %path.display(),
243                    "double_write=false requested, but the data file is not proven to be on \
244                     ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
245                );
246                config.double_write = true;
247            }
248        }
249        let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
250
251        let core = SharedPager::open_owned(
252            NativePagerFile(file),
253            path,
254            SharedPagerConfig {
255                cache_size: config.cache_size,
256                read_only: config.read_only,
257                verify_checksums: config.verify_checksums,
258                double_write: dwb_enabled,
259            },
260            NativeWalBarrier::default(),
261            !exists,
262        )?;
263        let mut pager = Self {
264            core,
265            _lock_file: lock_file,
266            config,
267            encryption: None,
268        };
269
270        if exists {
271            pager.bind_encryption_for_existing()?;
272        } else {
273            // Initialize new database
274            pager.bind_encryption_for_new()?;
275        }
276
277        Ok(pager)
278    }
279
280    /// gh-892 diagnostic predicate: returns `true` when the database
281    /// `page_size` is **not** an integer multiple of the filesystem's
282    /// reported block size (`st_blksize`), i.e. `page_size % fs_block_size
283    /// != 0`. A `fs_block_size` of `0` (probe unavailable / unknown) is
284    /// treated as aligned so a missing probe never produces a warning.
285    /// Pure function with no I/O so the warn decision is unit-testable.
286    pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
287        fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
288    }
289
290    #[cfg(test)]
291    fn cow_filesystem_test_override() -> Option<bool> {
292        match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
293            1 => Some(true),
294            2 => Some(false),
295            _ => None,
296        }
297    }
298
299    #[cfg(not(test))]
300    fn cow_filesystem_test_override() -> Option<bool> {
301        None
302    }
303
304    fn cow_filesystem_has_atomic_page_writes(
305        path: &Path,
306        file: &File,
307    ) -> Option<CowFilesystemKind> {
308        if let Some(allowed) = Self::cow_filesystem_test_override() {
309            return allowed.then_some(CowFilesystemKind::TestOverride);
310        }
311        Self::probe_cow_filesystem(path, file)
312    }
313
314    #[cfg(target_os = "linux")]
315    fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
316        let fs_type = linux_fstatfs_type(file)?;
317        match fs_type {
318            ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
319            BTRFS_SUPER_MAGIC => {
320                let mount_options = linux_mount_options_for_path(path)?;
321                let inode_flags = linux_inode_flags(file)?;
322                classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
323            }
324            _ => None,
325        }
326    }
327
328    #[cfg(not(target_os = "linux"))]
329    fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
330        None
331    }
332
333    /// Inspect page 0 for the `RDBE` encryption marker, then resolve
334    /// the (key, marker) matrix:
335    ///
336    /// | Marker | Key supplied | Result                              |
337    /// |--------|--------------|-------------------------------------|
338    /// | yes    | yes          | Bind encryptor; validate key        |
339    /// | yes    | no           | `EncryptionRequired` (fail closed)  |
340    /// | no     | yes          | `PlainDatabaseRefusesKey`           |
341    /// | no     | no           | Plain pager — no binding needed     |
342    fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
343        if self.page_count().unwrap_or(0) == 0 {
344            return self.bind_encryption_for_new();
345        }
346        let (_, image) = self.newest_superblock()?;
347        let data = &image[..];
348        let has_marker = reddb_file::paged_encryption_marker_present(data);
349
350        let key = self.config.encryption.clone();
351        match (has_marker, key) {
352            (true, Some(key)) => {
353                let header_bytes =
354                    reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
355                        PagerError::InvalidDatabase("encryption header parse failed".to_string())
356                    })?;
357                let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
358                    .map_err(|e| {
359                        PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
360                    })?;
361                if !header.validate(&key) {
362                    return Err(PagerError::InvalidKey);
363                }
364                let encryptor = crate::storage::encryption::PageEncryptor::new(key);
365                self.encryption = Some((encryptor, header));
366                Ok(())
367            }
368            (true, None) => Err(PagerError::EncryptionRequired),
369            (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
370            (false, None) => Ok(()),
371        }
372    }
373
374    /// New DB: if a key is configured, write the marker + header to
375    /// page 0 so subsequent opens detect encryption.
376    fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
377        let Some(key) = self.config.encryption.clone() else {
378            return Ok(());
379        };
380        let header = crate::storage::encryption::EncryptionHeader::new(&key);
381        let encryptor = crate::storage::encryption::PageEncryptor::new(key);
382
383        // Stamp the marker + header into the superblock zone if `initialize()`
384        // has already published it.
385        if self.page_count().unwrap_or(0) > 0 {
386            let header_bytes = header.to_bytes();
387            self.commit_superblock(|image| {
388                reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
389                    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
390            })?;
391        }
392        self.encryption = Some((encryptor, header));
393        Ok(())
394    }
395
396    /// Open with default configuration
397    pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
398        Self::open(path, PagerConfig::default())
399    }
400
401    /// Read a page through the configured encryptor if any. Page 0
402    /// is always returned plaintext (it carries the encryption marker
403    /// + header). Callers that want raw cipher bytes can use
404    ///   `read_page_no_checksum` directly.
405    pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
406        if page_id == 0 || self.encryption.is_none() {
407            return self.read_page(page_id);
408        }
409        let raw = self.read_page_no_checksum(page_id)?;
410        let (enc, _) = self
411            .encryption
412            .as_ref()
413            .expect("encryption presence checked above");
414        let plaintext = enc
415            .decrypt(page_id, raw.as_bytes())
416            .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
417        let mut buf = [0u8; PAGE_SIZE];
418        let n = plaintext.len().min(PAGE_SIZE);
419        buf[..n].copy_from_slice(&plaintext[..n]);
420        Ok(Page::from_bytes(buf))
421    }
422
423    /// Write a page through the configured encryptor if any. Page 0
424    /// bypasses encryption and goes through the normal checksummed
425    /// path. Encrypted pages skip the checksum update because
426    /// AES-GCM's authentication tag is the integrity guarantee.
427    pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
428        if page_id == 0 || self.encryption.is_none() {
429            return self.write_page(page_id, page);
430        }
431        // Canonical per-page envelope overhead (nonce + GCM tag),
432        // owned by reddb-io-crypto (#1053, ADR 0054).
433        const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
434        let plaintext_len = PAGE_SIZE - OVERHEAD;
435        let plaintext = &page.as_bytes()[..plaintext_len];
436        let (enc, _) = self
437            .encryption
438            .as_ref()
439            .expect("encryption presence checked above");
440        let ciphertext = enc.encrypt(page_id, plaintext);
441        debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
442        let mut buf = [0u8; PAGE_SIZE];
443        buf.copy_from_slice(&ciphertext);
444        let cipher_page = Page::from_bytes(buf);
445        self.write_page_no_checksum(page_id, cipher_page)
446    }
447
448    /// Attach a WAL writer to enforce WAL-first flush ordering.
449    ///
450    /// After this call, [`Pager::flush`] computes the maximum
451    /// `header.lsn` over all dirty pages and calls
452    /// `WalWriter::flush_until(max_lsn)` before any page is written
453    /// to the data file. This is the postgres rule: a page on disk
454    /// implies its WAL record is already durable on disk.
455    ///
456    /// Existing call sites that construct a Pager without a WAL
457    /// keep their previous behaviour (no LSN check) — wiring is
458    /// strictly opt-in.
459    pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
460        let mut slot = self
461            .core
462            .wal_barrier()
463            .0
464            .write()
465            .unwrap_or_else(|p| p.into_inner());
466        *slot = Some(wal);
467    }
468
469    /// Detach the WAL writer (test / shutdown path).
470    pub fn clear_wal_writer(&self) {
471        let mut slot = self
472            .core
473            .wal_barrier()
474            .0
475            .write()
476            .unwrap_or_else(|p| p.into_inner());
477        *slot = None;
478    }
479
480    /// Has a WAL writer been attached?
481    pub fn has_wal_writer(&self) -> bool {
482        self.core
483            .wal_barrier()
484            .0
485            .read()
486            .map(|s| s.is_some())
487            .unwrap_or(false)
488    }
489
490    /// Issue a best-effort OS read-ahead hint under the page-I/O lock.
491    pub fn prefetch_hint(&self, page_id: u32) {
492        let _ = self.core.with_file(|file| {
493            super::super::prefetch::prefetch_page(&file.0, u64::from(page_id), PAGE_SIZE as u32)
494        });
495    }
496
497    #[cfg(test)]
498    fn dwb_path(path: &Path) -> PathBuf {
499        reddb_file::layout::pager_dwb_shadow_path(path)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    fn temp_db_path(name: &str) -> PathBuf {
508        std::env::temp_dir().join(format!(
509            "reddb-pager-{}-{}-{}.rdb",
510            name,
511            std::process::id(),
512            crate::utils::now_unix_nanos()
513        ))
514    }
515
516    fn cleanup(path: &Path) {
517        let _ = std::fs::remove_file(path);
518        let _ = std::fs::remove_file(Pager::dwb_path(path));
519    }
520
521    fn read_zone(path: &Path) -> Vec<u8> {
522        let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
523        let mut file = File::open(path).expect("open() should succeed");
524        file.read_exact(&mut zone)
525            .expect("read_exact() should succeed");
526        zone
527    }
528
529    /// Overwrite one superblock slot in place, leaving the sibling untouched.
530    fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
531        let mut file = OpenOptions::new()
532            .write(true)
533            .open(path)
534            .expect("open() should succeed");
535        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
536            copy_index,
537        )))
538        .expect("operation should succeed");
539        file.write_all(bytes).expect("write_all() should succeed");
540        file.sync_all().expect("sync_all() should succeed");
541    }
542
543    fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
544        let zone = read_zone(path);
545        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
546            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
547            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
548            let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
549                .expect("fresh store should publish both slots");
550            reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
551                .expect("set_database_header_version() should succeed");
552            reddb_file::set_database_header_page_count(&mut slot, page_count)
553                .expect("set_database_header_page_count() should succeed");
554            reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
555                .expect("seal_paged_superblock_slot() should succeed");
556            poke_slot(path, copy_index, &slot);
557        }
558    }
559
560    #[test]
561    fn open_refuses_future_database_version() {
562        let path = temp_db_path("future-version");
563        let pager = Pager::open_default(&path).expect("open_default() should succeed");
564        drop(pager);
565
566        // A *valid* superblock slot — correct magic, copy index and CRC — whose
567        // database header names a version we cannot read. The slot is intact;
568        // the header is from the future.
569        let selection = reddb_file::select_paged_superblock(&read_zone(&path))
570            .expect("operation should succeed");
571        let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
572        reddb_file::init_database_header_page(&mut slot, 1)
573            .expect("init_database_header_page() should succeed");
574        reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
575            .expect("operation should succeed");
576        reddb_file::seal_paged_superblock_slot(
577            &mut slot,
578            selection.copy_index,
579            selection.generation + 1,
580        )
581        .expect("operation should succeed");
582        poke_slot(&path, selection.copy_index, &slot);
583
584        let err = match Pager::open_default(&path) {
585            Ok(_) => panic!("future database version should be rejected"),
586            Err(err) => err,
587        };
588        match err {
589            PagerError::InvalidDatabase(msg) => {
590                assert!(msg.contains("newer than supported"), "{msg}");
591            }
592            other => panic!("expected InvalidDatabase, got {other:?}"),
593        }
594
595        cleanup(&path);
596    }
597
598    #[test]
599    fn a_fresh_store_publishes_both_superblock_copies() {
600        // The ping-pong invariant must hold from the very first update, which
601        // means creation cannot leave one copy blank.
602        let path = temp_db_path("fresh-pair");
603        let pager = Pager::open_default(&path).expect("open_default() should succeed");
604        drop(pager);
605
606        let zone = read_zone(&path);
607        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
608            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
609            assert!(
610                reddb_file::paged_superblock_slot_generation(
611                    &zone[start..start + SUPERBLOCK_SLOT_SIZE],
612                    copy_index
613                )
614                .is_some(),
615                "copy {copy_index} must be valid on a fresh store"
616            );
617        }
618        cleanup(&path);
619    }
620
621    #[test]
622    fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
623        let path = temp_db_path("ping-pong");
624        let pager = Pager::open_default(&path).expect("open_default() should succeed");
625        let before = reddb_file::select_paged_superblock(&read_zone(&path))
626            .expect("operation should succeed");
627
628        pager
629            .allocate_page(PageType::BTreeLeaf)
630            .expect("allocate_page() should succeed");
631        pager.sync().expect("sync() should succeed");
632
633        let zone = read_zone(&path);
634        let after = reddb_file::select_paged_superblock(&zone)
635            .expect("select_paged_superblock() should succeed");
636        assert_ne!(
637            after.copy_index, before.copy_index,
638            "an update must target the stale copy"
639        );
640        assert!(after.generation > before.generation);
641
642        // The copy that was newest before the update is still readable — this
643        // is the byte-level statement of "a crash mid-write always leaves one
644        // valid copy".
645        let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
646        assert_eq!(
647            reddb_file::paged_superblock_slot_generation(
648                &zone[start..start + SUPERBLOCK_SLOT_SIZE],
649                before.copy_index
650            ),
651            Some(before.generation)
652        );
653
654        drop(pager);
655        cleanup(&path);
656    }
657
658    #[test]
659    fn failed_trunk_fence_is_completed_before_retrying_allocation() {
660        let path = temp_db_path("retry-trunk-fence");
661        let pager = Pager::open_default(&path).expect("pager");
662        let mut pages = Vec::new();
663        for _ in 0..8 {
664            pages.push(pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id());
665        }
666        pager.sync().unwrap();
667        for id in pages {
668            pager.free_page(id).unwrap();
669        }
670        pager.sync().unwrap();
671        let trunk = pager.header().unwrap().freelist_head;
672        assert_ne!(trunk, 0);
673
674        // A real write error after the trunk has been read must not turn a
675        // subsequent allocation into an unfenced fast-path reuse.
676        let writable = pager
677            .core
678            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
679            .unwrap();
680        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
681        pager.core.with_file(|file| file.0 = writable).unwrap();
682        pager.allocate_page(PageType::BTreeLeaf).unwrap();
683
684        let zone = read_zone(&path);
685        for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
686            let start = copy * SUPERBLOCK_SLOT_SIZE;
687            let header =
688                reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
689                    .unwrap();
690            assert_ne!(
691                header.freelist_head, trunk,
692                "copy {copy} still owns consumed trunk"
693            );
694        }
695        drop(pager);
696        cleanup(&path);
697    }
698
699    #[test]
700    fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
701        let path = temp_db_path("reused-allocator-pair");
702        let pager = Pager::open_default(&path).expect("pager");
703        let mut pages = Vec::new();
704        for _ in 0..32 {
705            pages.push(
706                pager
707                    .allocate_page(PageType::BTreeLeaf)
708                    .expect("allocate")
709                    .page_id(),
710            );
711        }
712        pager.sync().expect("seed");
713        for id in pages {
714            pager.free_page(id).expect("free");
715        }
716        pager.sync().expect("publish free list");
717        let mut live = std::collections::HashSet::new();
718        for _ in 0..8 {
719            let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
720            live.insert(page.page_id());
721            pager
722                .write_page(page.page_id(), page)
723                .expect("write valid page");
724        }
725        pager.sync().expect("publish reused ownership");
726        drop(pager);
727        let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
728        poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
729        let recovered = Pager::open_default(&path).expect("sibling recovery");
730        for _ in 0..64 {
731            let id = recovered
732                .allocate_page(PageType::BTreeLeaf)
733                .expect("allocate after recovery")
734                .page_id();
735            assert!(
736                !live.contains(&id),
737                "sibling allocator still considers live page {id} free"
738            );
739        }
740        drop(recovered);
741        cleanup(&path);
742    }
743
744    #[test]
745    fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
746        let path = temp_db_path("torn-newest");
747        {
748            let pager = Pager::open_default(&path).expect("open_default() should succeed");
749            pager
750                .allocate_page(PageType::BTreeLeaf)
751                .expect("allocate_page() should succeed");
752            pager.sync().expect("sync() should succeed");
753        }
754
755        let newest = reddb_file::select_paged_superblock(&read_zone(&path))
756            .expect("operation should succeed");
757        // Tear the newest copy: a torn write leaves its CRC broken.
758        let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
759        torn.copy_from_slice(
760            &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
761                ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
762        );
763        torn[64] ^= 0xFF;
764        poke_slot(&path, newest.copy_index, &torn);
765
766        let pager = Pager::open_default(&path).expect("older copy must root the store");
767        assert!(pager.page_count().expect("page_count() should succeed") >= 3);
768        drop(pager);
769        cleanup(&path);
770    }
771
772    #[test]
773    fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
774        let path = temp_db_path("both-invalid");
775        {
776            let _ = Pager::open_default(&path).expect("open_default() should succeed");
777        }
778
779        let zone = read_zone(&path);
780        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
781            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
782            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
783            slot[64] ^= 0xFF;
784            poke_slot(&path, copy_index, &slot);
785        }
786
787        let err = match Pager::open_default(&path) {
788            Ok(_) => panic!("a rootless store must not open"),
789            Err(err) => err,
790        };
791        let rendered = err.to_string();
792        assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
793        assert!(rendered.contains("superblock zone"), "{rendered}");
794        assert!(rendered.contains("salvage"), "{rendered}");
795
796        cleanup(&path);
797    }
798
799    #[test]
800    fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
801        let path = temp_db_path("legacy-sidecar");
802        {
803            let _ = Pager::open_default(&path).expect("open_default() should succeed");
804        }
805
806        let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
807        std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
808
809        let err = match Pager::open_default(&path) {
810            Ok(_) => panic!("legacy stores need migration first"),
811            Err(err) => err,
812        };
813        let rendered = err.to_string();
814        assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
815        assert!(rendered.contains("migration tool"), "{rendered}");
816
817        let _ = std::fs::remove_file(&sidecar);
818        cleanup(&path);
819    }
820
821    #[test]
822    fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
823        let path = temp_db_path("pre-phase3-large");
824        {
825            let _ = Pager::open_default(&path).expect("open_default() should succeed");
826        }
827        stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
828
829        let err = match Pager::open_default(&path) {
830            Ok(_) => panic!("pre-phase-3 stores need migration first"),
831            Err(err) => err,
832        };
833        let rendered = err.to_string();
834        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
835        assert!(rendered.contains("migration tool"), "{rendered}");
836
837        cleanup(&path);
838    }
839
840    #[test]
841    fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
842        let path = temp_db_path("pre-phase3-small");
843        {
844            let _ = Pager::open_default(&path).expect("open_default() should succeed");
845        }
846        stamp_pre_phase3_layout(&path, 3);
847        OpenOptions::new()
848            .write(true)
849            .open(&path)
850            .expect("open() should succeed")
851            .set_len(3 * PAGE_SIZE as u64)
852            .expect("set_len() should succeed");
853
854        let err = match Pager::open_default(&path) {
855            Ok(_) => panic!("pre-phase-3 stores need migration first"),
856            Err(err) => err,
857        };
858        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
859
860        cleanup(&path);
861    }
862
863    #[test]
864    fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
865        let path = temp_db_path("census");
866        {
867            let pager = Pager::open_default(&path).expect("open_default() should succeed");
868            let page = pager
869                .allocate_page(PageType::BTreeLeaf)
870                .expect("allocate_page() should succeed");
871            pager
872                .write_page(page.page_id(), page)
873                .expect("operation should succeed");
874            pager.sync().expect("sync() should succeed");
875        }
876        {
877            let pager = Pager::open_default(&path).expect("open_default() should succeed");
878            pager.sync().expect("sync() should succeed");
879        }
880
881        assert_eq!(
882            reddb_file::layout::retired::first_present_phase1_sidecar(&path),
883            None,
884            "the pager must never create a retired phase-1 sidecar"
885        );
886        cleanup(&path);
887    }
888}