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 #[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 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 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 let file = OpenOptions::new()
178 .read(true)
179 .write(!config.read_only)
180 .create(config.create && !config.read_only)
181 .open(&path)?;
182
183 #[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 if !config.read_only {
211 file.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
212 } else {
213 let _ = FileExt::try_lock_shared(&file);
214 }
215 let file = NativePagerFile(file);
217
218 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 pager.bind_encryption_for_new()?;
273 }
274
275 Ok(pager)
276 }
277
278 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 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 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 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 pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
396 Self::open(path, PagerConfig::default())
397 }
398
399 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 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 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 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 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 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 #[allow(dead_code)] 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 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 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 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 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 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 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 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 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 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 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}