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 = 0x0001_0001;
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        // Lock the same handle that performs page I/O. Windows byte-range
208        // locks deny reads/writes through a separately opened handle even in
209        // this process. The owned VFS handle holds the fence until it closes.
210        if !config.read_only {
211            file.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
212        } else {
213            let _ = FileExt::try_lock_shared(&file);
214        }
215        // Own lock release across both ordinary close and later open failures.
216        let file = NativePagerFile(file);
217
218        // ADR 0038 phase 3: the DWB sidecar is retired. When DWB is active,
219        // the recovery frame is written to a reserved zone inside the data
220        // file and fsynced before in-place page writes. A pre-existing sidecar
221        // marks a legacy store that must go through the offline migration tool.
222        //
223        // gh-895: an explicit `double_write = false` request is honored only
224        // when the already-open data file is proven to live on a filesystem
225        // with atomic CoW page writes. Unknown and non-CoW filesystems fail
226        // closed by keeping the in-file DWB zone enabled.
227        let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
228        if exists {
229            if let Some(sidecar) =
230                reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
231            {
232                return Err(PagerError::LegacySidecarStore { sidecar });
233            }
234        }
235        if !config.double_write && !config.read_only && !fold_dwb {
236            let skip_dwb_on_cow =
237                Self::cow_filesystem_has_atomic_page_writes(&path, &file.0).is_some();
238            if !skip_dwb_on_cow {
239                tracing::warn!(
240                    path = %path.display(),
241                    "double_write=false requested, but the data file is not proven to be on \
242                     ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
243                );
244                config.double_write = true;
245            }
246        }
247        let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
248
249        let core = SharedPager::open_owned(
250            file,
251            path,
252            SharedPagerConfig {
253                disk_residency: config.disk_residency,
254                cache_size: config.cache_size,
255                read_only: config.read_only,
256                verify_checksums: config.verify_checksums,
257                double_write: dwb_enabled,
258            },
259            NativeWalBarrier::default(),
260            !exists,
261        )?;
262        let mut pager = Self {
263            core,
264            config,
265            encryption: None,
266        };
267
268        if exists {
269            pager.bind_encryption_for_existing()?;
270        } else {
271            // Initialize new database
272            pager.bind_encryption_for_new()?;
273        }
274
275        Ok(pager)
276    }
277
278    /// gh-892 diagnostic predicate: returns `true` when the database
279    /// `page_size` is **not** an integer multiple of the filesystem's
280    /// reported block size (`st_blksize`), i.e. `page_size % fs_block_size
281    /// != 0`. A `fs_block_size` of `0` (probe unavailable / unknown) is
282    /// treated as aligned so a missing probe never produces a warning.
283    /// Pure function with no I/O so the warn decision is unit-testable.
284    pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
285        fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
286    }
287
288    #[cfg(test)]
289    fn cow_filesystem_test_override() -> Option<bool> {
290        match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
291            1 => Some(true),
292            2 => Some(false),
293            _ => None,
294        }
295    }
296
297    #[cfg(not(test))]
298    fn cow_filesystem_test_override() -> Option<bool> {
299        None
300    }
301
302    fn cow_filesystem_has_atomic_page_writes(
303        path: &Path,
304        file: &File,
305    ) -> Option<CowFilesystemKind> {
306        if let Some(allowed) = Self::cow_filesystem_test_override() {
307            return allowed.then_some(CowFilesystemKind::TestOverride);
308        }
309        Self::probe_cow_filesystem(path, file)
310    }
311
312    #[cfg(target_os = "linux")]
313    fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
314        let fs_type = linux_fstatfs_type(file)?;
315        match fs_type {
316            ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
317            BTRFS_SUPER_MAGIC => {
318                let mount_options = linux_mount_options_for_path(path)?;
319                let inode_flags = linux_inode_flags(file)?;
320                classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
321            }
322            _ => None,
323        }
324    }
325
326    #[cfg(not(target_os = "linux"))]
327    fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
328        None
329    }
330
331    /// Inspect page 0 for the `RDBE` encryption marker, then resolve
332    /// the (key, marker) matrix:
333    ///
334    /// | Marker | Key supplied | Result                              |
335    /// |--------|--------------|-------------------------------------|
336    /// | yes    | yes          | Bind encryptor; validate key        |
337    /// | yes    | no           | `EncryptionRequired` (fail closed)  |
338    /// | no     | yes          | `PlainDatabaseRefusesKey`           |
339    /// | no     | no           | Plain pager — no binding needed     |
340    fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
341        if self.page_count().unwrap_or(0) == 0 {
342            return self.bind_encryption_for_new();
343        }
344        let (_, image) = self.newest_superblock()?;
345        let data = &image[..];
346        let has_marker = reddb_file::paged_encryption_marker_present(data);
347
348        let key = self.config.encryption.clone();
349        match (has_marker, key) {
350            (true, Some(key)) => {
351                let header_bytes =
352                    reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
353                        PagerError::InvalidDatabase("encryption header parse failed".to_string())
354                    })?;
355                let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
356                    .map_err(|e| {
357                        PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
358                    })?;
359                if !header.validate(&key) {
360                    return Err(PagerError::InvalidKey);
361                }
362                let encryptor = crate::storage::encryption::PageEncryptor::new(key);
363                self.encryption = Some((encryptor, header));
364                Ok(())
365            }
366            (true, None) => Err(PagerError::EncryptionRequired),
367            (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
368            (false, None) => Ok(()),
369        }
370    }
371
372    /// New DB: if a key is configured, write the marker + header to
373    /// page 0 so subsequent opens detect encryption.
374    fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
375        let Some(key) = self.config.encryption.clone() else {
376            return Ok(());
377        };
378        let header = crate::storage::encryption::EncryptionHeader::new(&key);
379        let encryptor = crate::storage::encryption::PageEncryptor::new(key);
380
381        // Stamp the marker + header into the superblock zone if `initialize()`
382        // has already published it.
383        if self.page_count().unwrap_or(0) > 0 {
384            let header_bytes = header.to_bytes();
385            self.commit_superblock(|image| {
386                reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
387                    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
388            })?;
389        }
390        self.encryption = Some((encryptor, header));
391        Ok(())
392    }
393
394    /// Open with default configuration
395    pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
396        Self::open(path, PagerConfig::default())
397    }
398
399    /// Read a page through the configured encryptor if any. Page 0
400    /// is always returned plaintext (it carries the encryption marker
401    /// + header). Callers that want raw cipher bytes can use
402    ///   `read_page_no_checksum` directly.
403    pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
404        if page_id == 0 || self.encryption.is_none() {
405            return self.read_page(page_id);
406        }
407        let raw = self.read_page_no_checksum(page_id)?;
408        let (enc, _) = self
409            .encryption
410            .as_ref()
411            .expect("encryption presence checked above");
412        let plaintext = enc
413            .decrypt(page_id, raw.as_bytes())
414            .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
415        let mut buf = [0u8; PAGE_SIZE];
416        let n = plaintext.len().min(PAGE_SIZE);
417        buf[..n].copy_from_slice(&plaintext[..n]);
418        Ok(Page::from_bytes(buf))
419    }
420
421    /// Write a page through the configured encryptor if any. Page 0
422    /// bypasses encryption and goes through the normal checksummed
423    /// path. Encrypted pages skip the checksum update because
424    /// AES-GCM's authentication tag is the integrity guarantee.
425    pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
426        if page_id == 0 || self.encryption.is_none() {
427            return self.write_page(page_id, page);
428        }
429        // Canonical per-page envelope overhead (nonce + GCM tag),
430        // owned by reddb-io-crypto (#1053, ADR 0054).
431        const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
432        let plaintext_len = PAGE_SIZE - OVERHEAD;
433        let plaintext = &page.as_bytes()[..plaintext_len];
434        let (enc, _) = self
435            .encryption
436            .as_ref()
437            .expect("encryption presence checked above");
438        let ciphertext = enc.encrypt(page_id, plaintext);
439        debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
440        let mut buf = [0u8; PAGE_SIZE];
441        buf.copy_from_slice(&ciphertext);
442        let cipher_page = Page::from_bytes(buf);
443        self.write_page_no_checksum(page_id, cipher_page)
444    }
445
446    /// Attach a WAL writer to enforce WAL-first flush ordering.
447    ///
448    /// After this call, [`Pager::flush`] computes the maximum
449    /// `header.lsn` over all dirty pages and calls
450    /// `WalWriter::flush_until(max_lsn)` before any page is written
451    /// to the data file. This is the postgres rule: a page on disk
452    /// implies its WAL record is already durable on disk.
453    ///
454    /// Existing call sites that construct a Pager without a WAL
455    /// keep their previous behaviour (no LSN check) — wiring is
456    /// strictly opt-in.
457    pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
458        let mut slot = self
459            .core
460            .wal_barrier()
461            .0
462            .write()
463            .unwrap_or_else(|p| p.into_inner());
464        *slot = Some(wal);
465    }
466
467    /// Detach the WAL writer (test / shutdown path).
468    pub fn clear_wal_writer(&self) {
469        let mut slot = self
470            .core
471            .wal_barrier()
472            .0
473            .write()
474            .unwrap_or_else(|p| p.into_inner());
475        *slot = None;
476    }
477
478    /// Has a WAL writer been attached?
479    pub fn has_wal_writer(&self) -> bool {
480        self.core
481            .wal_barrier()
482            .0
483            .read()
484            .map(|s| s.is_some())
485            .unwrap_or(false)
486    }
487
488    /// Install an ownership recorder while all other mutators are fenced.
489    #[allow(dead_code)] // Used when ownership recovery is activated.
490    pub(crate) fn install_ownership_recorder(
491        &self,
492        recorder: Arc<dyn super::PageOwnershipRecorder>,
493    ) -> std::io::Result<()> {
494        let mut slot = self
495            .core
496            .wal_barrier()
497            .1
498            .write()
499            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
500        if slot.is_some() {
501            return Err(std::io::Error::other(
502                "ownership recorder already installed",
503            ));
504        }
505        *slot = Some(recorder);
506        Ok(())
507    }
508
509    /// Remove the recorder after allocator copies and journal completion are
510    /// durable, while other mutators remain fenced.
511    pub(crate) fn clear_ownership_recorder(&self) -> std::io::Result<()> {
512        let mut slot = self
513            .core
514            .wal_barrier()
515            .1
516            .write()
517            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
518        if slot.take().is_none() {
519            return Err(std::io::Error::other("ownership recorder is not installed"));
520        }
521        Ok(())
522    }
523
524    /// Issue a best-effort OS read-ahead hint under the page-I/O lock.
525    pub fn prefetch_hint(&self, page_id: u32) {
526        let _ = self.core.with_file(|file| {
527            super::super::prefetch::prefetch_page(&file.0, u64::from(page_id), PAGE_SIZE as u32)
528        });
529    }
530
531    #[cfg(test)]
532    fn dwb_path(path: &Path) -> PathBuf {
533        reddb_file::layout::pager_dwb_shadow_path(path)
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn ownership_scope_serializes_other_allocators_and_fences_errors() {
543        let directory = tempfile::tempdir().expect("temporary directory");
544        let pager = Arc::new(
545            Pager::open(directory.path().join("data.rdb"), Default::default()).expect("pager"),
546        );
547        let scope = pager.lock_ownership_mutations();
548        let (started_tx, started_rx) = std::sync::mpsc::channel();
549        let (done_tx, done_rx) = std::sync::mpsc::channel();
550        let other_pager = Arc::clone(&pager);
551        let worker = std::thread::spawn(move || {
552            started_tx.send(()).expect("signal worker started");
553            let page = other_pager.allocate_page(PageType::BTreeLeaf);
554            done_tx
555                .send(page.map(|page| page.page_id()))
556                .expect("signal allocation");
557        });
558        started_rx.recv().expect("worker started");
559        assert!(done_rx
560            .recv_timeout(std::time::Duration::from_millis(100))
561            .is_err());
562        let own = pager
563            .allocate_page(PageType::BTreeLeaf)
564            .expect("reentrant allocation");
565        drop(scope);
566        let other = done_rx
567            .recv()
568            .expect("worker result")
569            .expect("other allocation");
570        worker.join().expect("worker");
571        assert_ne!(own.page_id(), other);
572        pager.fence_interrupted_ownership();
573        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
574    }
575
576    fn temp_db_path(name: &str) -> PathBuf {
577        std::env::temp_dir().join(format!(
578            "reddb-pager-{}-{}-{}.rdb",
579            name,
580            std::process::id(),
581            crate::utils::now_unix_nanos()
582        ))
583    }
584
585    fn cleanup(path: &Path) {
586        let _ = std::fs::remove_file(path);
587        let _ = std::fs::remove_file(Pager::dwb_path(path));
588    }
589
590    fn read_zone(path: &Path) -> Vec<u8> {
591        let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
592        let mut file = File::open(path).expect("open() should succeed");
593        file.read_exact(&mut zone)
594            .expect("read_exact() should succeed");
595        zone
596    }
597
598    /// Overwrite one superblock slot in place, leaving the sibling untouched.
599    fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
600        let mut file = OpenOptions::new()
601            .write(true)
602            .open(path)
603            .expect("open() should succeed");
604        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
605            copy_index,
606        )))
607        .expect("operation should succeed");
608        file.write_all(bytes).expect("write_all() should succeed");
609        file.sync_all().expect("sync_all() should succeed");
610    }
611
612    fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
613        let zone = read_zone(path);
614        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
615            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
616            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
617            let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
618                .expect("fresh store should publish both slots");
619            reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
620                .expect("set_database_header_version() should succeed");
621            reddb_file::set_database_header_page_count(&mut slot, page_count)
622                .expect("set_database_header_page_count() should succeed");
623            reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
624                .expect("seal_paged_superblock_slot() should succeed");
625            poke_slot(path, copy_index, &slot);
626        }
627    }
628
629    #[test]
630    fn open_refuses_future_database_version() {
631        let path = temp_db_path("future-version");
632        let pager = Pager::open_default(&path).expect("open_default() should succeed");
633        drop(pager);
634
635        // A *valid* superblock slot — correct magic, copy index and CRC — whose
636        // database header names a version we cannot read. The slot is intact;
637        // the header is from the future.
638        let selection = reddb_file::select_paged_superblock(&read_zone(&path))
639            .expect("operation should succeed");
640        let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
641        reddb_file::init_database_header_page(&mut slot, 1)
642            .expect("init_database_header_page() should succeed");
643        reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
644            .expect("operation should succeed");
645        reddb_file::seal_paged_superblock_slot(
646            &mut slot,
647            selection.copy_index,
648            selection.generation + 1,
649        )
650        .expect("operation should succeed");
651        poke_slot(&path, selection.copy_index, &slot);
652
653        let err = match Pager::open_default(&path) {
654            Ok(_) => panic!("future database version should be rejected"),
655            Err(err) => err,
656        };
657        match err {
658            PagerError::InvalidDatabase(msg) => {
659                assert!(msg.contains("newer than supported"), "{msg}");
660            }
661            other => panic!("expected InvalidDatabase, got {other:?}"),
662        }
663
664        cleanup(&path);
665    }
666
667    #[test]
668    fn a_fresh_store_publishes_both_superblock_copies() {
669        // The ping-pong invariant must hold from the very first update, which
670        // means creation cannot leave one copy blank.
671        let path = temp_db_path("fresh-pair");
672        let pager = Pager::open_default(&path).expect("open_default() should succeed");
673        drop(pager);
674
675        let zone = read_zone(&path);
676        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
677            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
678            assert!(
679                reddb_file::paged_superblock_slot_generation(
680                    &zone[start..start + SUPERBLOCK_SLOT_SIZE],
681                    copy_index
682                )
683                .is_some(),
684                "copy {copy_index} must be valid on a fresh store"
685            );
686        }
687        cleanup(&path);
688    }
689
690    #[test]
691    fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
692        let path = temp_db_path("ping-pong");
693        let pager = Pager::open_default(&path).expect("open_default() should succeed");
694        let before = reddb_file::select_paged_superblock(&read_zone(&path))
695            .expect("operation should succeed");
696
697        pager
698            .allocate_page(PageType::BTreeLeaf)
699            .expect("allocate_page() should succeed");
700        pager.sync().expect("sync() should succeed");
701
702        let zone = read_zone(&path);
703        let after = reddb_file::select_paged_superblock(&zone)
704            .expect("select_paged_superblock() should succeed");
705        assert_ne!(
706            after.copy_index, before.copy_index,
707            "an update must target the stale copy"
708        );
709        assert!(after.generation > before.generation);
710
711        // The copy that was newest before the update is still readable — this
712        // is the byte-level statement of "a crash mid-write always leaves one
713        // valid copy".
714        let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
715        assert_eq!(
716            reddb_file::paged_superblock_slot_generation(
717                &zone[start..start + SUPERBLOCK_SLOT_SIZE],
718                before.copy_index
719            ),
720            Some(before.generation)
721        );
722
723        drop(pager);
724        cleanup(&path);
725    }
726
727    #[test]
728    fn failed_trunk_fence_is_completed_before_retrying_allocation() {
729        let path = temp_db_path("retry-trunk-fence");
730        let pager = Pager::open_default(&path).expect("pager");
731        // An empty trunk is itself reusable, so consuming its last page must
732        // still fence both superblocks even outside an ownership epoch.
733        let page = pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id();
734        pager.sync().unwrap();
735        pager.free_page(page).unwrap();
736        pager.sync().unwrap();
737        let trunk = pager.header().unwrap().freelist_head;
738        assert_ne!(trunk, 0);
739
740        // A real write error after the trunk has been read must not turn a
741        // subsequent allocation into an unfenced fast-path reuse.
742        let writable = pager
743            .core
744            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
745            .unwrap();
746        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
747        pager.core.with_file(|file| file.0 = writable).unwrap();
748        pager.allocate_page(PageType::BTreeLeaf).unwrap();
749
750        let zone = read_zone(&path);
751        for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
752            let start = copy * SUPERBLOCK_SLOT_SIZE;
753            let header =
754                reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
755                    .unwrap();
756            assert_ne!(
757                header.freelist_head, trunk,
758                "copy {copy} still owns consumed trunk"
759            );
760        }
761        drop(pager);
762        cleanup(&path);
763    }
764
765    #[test]
766    fn failed_unjournaled_trunk_write_requires_reopen_and_preserves_free_pages() {
767        let path = temp_db_path("failed-trunk-rewrite");
768        let pager = Pager::open_default(&path).expect("pager");
769        let mut pages = std::collections::HashSet::new();
770        for _ in 0..8 {
771            pages.insert(
772                pager
773                    .allocate_page(PageType::BTreeLeaf)
774                    .expect("seed page")
775                    .page_id(),
776            );
777        }
778        pager.sync().expect("persist seed");
779        for &id in &pages {
780            pager.free_page(id).expect("free seed page");
781        }
782        pager.sync().expect("persist free list");
783        let baseline = pager.page_count().expect("page count");
784        let writable = pager
785            .core
786            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).expect("read only")))
787            .expect("inject write failure");
788        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
789        pager
790            .core
791            .with_file(|file| file.0 = writable)
792            .expect("restore file");
793        let error = pager
794            .allocate_page(PageType::BTreeLeaf)
795            .expect_err("writeback must stay fenced");
796        assert!(error.to_string().contains("reopen"), "{error}");
797        drop(pager);
798
799        let recovered = Pager::open_default(&path).expect("recover protected write");
800        let mut reused = std::collections::HashSet::new();
801        for _ in 0..pages.len() {
802            let id = recovered
803                .allocate_page(PageType::BTreeLeaf)
804                .expect("reuse after recovery")
805                .page_id();
806            if id >= baseline {
807                break;
808            }
809            assert!(pages.contains(&id), "unexpected free page {id}");
810            assert!(reused.insert(id), "duplicate reuse {id}");
811        }
812        // Only the single uncertain reservation may have been removed. The
813        // protected write must never detach and lose the rest of the trunk.
814        assert!(reused.len() >= pages.len() - 1, "lost free-page remainder");
815        drop(recovered);
816        cleanup(&path);
817    }
818
819    #[test]
820    fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
821        let path = temp_db_path("reused-allocator-pair");
822        let pager = Pager::open_default(&path).expect("pager");
823        let mut pages = Vec::new();
824        for _ in 0..32 {
825            pages.push(
826                pager
827                    .allocate_page(PageType::BTreeLeaf)
828                    .expect("allocate")
829                    .page_id(),
830            );
831        }
832        pager.sync().expect("seed");
833        for id in pages {
834            pager.free_page(id).expect("free");
835        }
836        pager.sync().expect("publish free list");
837        let mut live = std::collections::HashSet::new();
838        for _ in 0..8 {
839            let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
840            live.insert(page.page_id());
841            pager
842                .write_page(page.page_id(), page)
843                .expect("write valid page");
844        }
845        pager.sync().expect("publish reused ownership");
846        drop(pager);
847        let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
848        poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
849        let recovered = Pager::open_default(&path).expect("sibling recovery");
850        for _ in 0..64 {
851            let id = recovered
852                .allocate_page(PageType::BTreeLeaf)
853                .expect("allocate after recovery")
854                .page_id();
855            assert!(
856                !live.contains(&id),
857                "sibling allocator still considers live page {id} free"
858            );
859        }
860        drop(recovered);
861        cleanup(&path);
862    }
863
864    #[test]
865    fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
866        let path = temp_db_path("torn-newest");
867        {
868            let pager = Pager::open_default(&path).expect("open_default() should succeed");
869            pager
870                .allocate_page(PageType::BTreeLeaf)
871                .expect("allocate_page() should succeed");
872            pager.sync().expect("sync() should succeed");
873        }
874
875        let newest = reddb_file::select_paged_superblock(&read_zone(&path))
876            .expect("operation should succeed");
877        // Tear the newest copy: a torn write leaves its CRC broken.
878        let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
879        torn.copy_from_slice(
880            &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
881                ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
882        );
883        torn[64] ^= 0xFF;
884        poke_slot(&path, newest.copy_index, &torn);
885
886        let pager = Pager::open_default(&path).expect("older copy must root the store");
887        assert!(pager.page_count().expect("page_count() should succeed") >= 3);
888        drop(pager);
889        cleanup(&path);
890    }
891
892    #[test]
893    fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
894        let path = temp_db_path("both-invalid");
895        {
896            let _ = Pager::open_default(&path).expect("open_default() should succeed");
897        }
898
899        let zone = read_zone(&path);
900        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
901            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
902            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
903            slot[64] ^= 0xFF;
904            poke_slot(&path, copy_index, &slot);
905        }
906
907        let err = match Pager::open_default(&path) {
908            Ok(_) => panic!("a rootless store must not open"),
909            Err(err) => err,
910        };
911        let rendered = err.to_string();
912        assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
913        assert!(rendered.contains("superblock zone"), "{rendered}");
914        assert!(rendered.contains("salvage"), "{rendered}");
915
916        cleanup(&path);
917    }
918
919    #[test]
920    fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
921        let path = temp_db_path("legacy-sidecar");
922        {
923            let _ = Pager::open_default(&path).expect("open_default() should succeed");
924        }
925
926        let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
927        std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
928
929        let err = match Pager::open_default(&path) {
930            Ok(_) => panic!("legacy stores need migration first"),
931            Err(err) => err,
932        };
933        let rendered = err.to_string();
934        assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
935        assert!(rendered.contains("migration tool"), "{rendered}");
936
937        let _ = std::fs::remove_file(&sidecar);
938        cleanup(&path);
939    }
940
941    #[test]
942    fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
943        let path = temp_db_path("pre-phase3-large");
944        {
945            let _ = Pager::open_default(&path).expect("open_default() should succeed");
946        }
947        stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
948
949        let err = match Pager::open_default(&path) {
950            Ok(_) => panic!("pre-phase-3 stores need migration first"),
951            Err(err) => err,
952        };
953        let rendered = err.to_string();
954        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
955        assert!(rendered.contains("migration tool"), "{rendered}");
956
957        cleanup(&path);
958    }
959
960    #[test]
961    fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
962        let path = temp_db_path("pre-phase3-small");
963        {
964            let _ = Pager::open_default(&path).expect("open_default() should succeed");
965        }
966        stamp_pre_phase3_layout(&path, 3);
967        OpenOptions::new()
968            .write(true)
969            .open(&path)
970            .expect("open() should succeed")
971            .set_len(3 * PAGE_SIZE as u64)
972            .expect("set_len() should succeed");
973
974        let err = match Pager::open_default(&path) {
975            Ok(_) => panic!("pre-phase-3 stores need migration first"),
976            Err(err) => err,
977        };
978        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
979
980        cleanup(&path);
981    }
982
983    #[test]
984    fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
985        let path = temp_db_path("census");
986        {
987            let pager = Pager::open_default(&path).expect("open_default() should succeed");
988            let page = pager
989                .allocate_page(PageType::BTreeLeaf)
990                .expect("allocate_page() should succeed");
991            pager
992                .write_page(page.page_id(), page)
993                .expect("operation should succeed");
994            pager.sync().expect("sync() should succeed");
995        }
996        {
997            let pager = Pager::open_default(&path).expect("open_default() should succeed");
998            pager.sync().expect("sync() should succeed");
999        }
1000
1001        assert_eq!(
1002            reddb_file::layout::retired::first_present_phase1_sidecar(&path),
1003            None,
1004            "the pager must never create a retired phase-1 sidecar"
1005        );
1006        cleanup(&path);
1007    }
1008}