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 #[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 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 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 let file = OpenOptions::new()
175 .read(true)
176 .write(!config.read_only)
177 .create(config.create && !config.read_only)
178 .open(&path)?;
179
180 #[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 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 let file = NativePagerFile(file);
214
215 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 pager.bind_encryption_for_new()?;
270 }
271
272 Ok(pager)
273 }
274
275 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 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 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 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 pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
393 Self::open(path, PagerConfig::default())
394 }
395
396 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 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 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 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 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 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 #[allow(dead_code)] 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 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 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 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 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 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 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 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 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 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 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 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 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}