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
216 let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
226 if exists {
227 if let Some(sidecar) =
228 reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
229 {
230 return Err(PagerError::LegacySidecarStore { sidecar });
231 }
232 }
233 if !config.double_write && !config.read_only && !fold_dwb {
234 let skip_dwb_on_cow =
235 Self::cow_filesystem_has_atomic_page_writes(&path, &file).is_some();
236 if !skip_dwb_on_cow {
237 tracing::warn!(
238 path = %path.display(),
239 "double_write=false requested, but the data file is not proven to be on \
240 ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
241 );
242 config.double_write = true;
243 }
244 }
245 let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
246
247 let core = SharedPager::open_owned(
248 NativePagerFile(file),
249 path,
250 SharedPagerConfig {
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 fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
597 let mut file = OpenOptions::new()
598 .write(true)
599 .open(path)
600 .expect("open() should succeed");
601 file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
602 copy_index,
603 )))
604 .expect("operation should succeed");
605 file.write_all(bytes).expect("write_all() should succeed");
606 file.sync_all().expect("sync_all() should succeed");
607 }
608
609 fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
610 let zone = read_zone(path);
611 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
612 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
613 let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
614 let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
615 .expect("fresh store should publish both slots");
616 reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
617 .expect("set_database_header_version() should succeed");
618 reddb_file::set_database_header_page_count(&mut slot, page_count)
619 .expect("set_database_header_page_count() should succeed");
620 reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
621 .expect("seal_paged_superblock_slot() should succeed");
622 poke_slot(path, copy_index, &slot);
623 }
624 }
625
626 #[test]
627 fn open_refuses_future_database_version() {
628 let path = temp_db_path("future-version");
629 let pager = Pager::open_default(&path).expect("open_default() should succeed");
630 drop(pager);
631
632 let selection = reddb_file::select_paged_superblock(&read_zone(&path))
636 .expect("operation should succeed");
637 let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
638 reddb_file::init_database_header_page(&mut slot, 1)
639 .expect("init_database_header_page() should succeed");
640 reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
641 .expect("operation should succeed");
642 reddb_file::seal_paged_superblock_slot(
643 &mut slot,
644 selection.copy_index,
645 selection.generation + 1,
646 )
647 .expect("operation should succeed");
648 poke_slot(&path, selection.copy_index, &slot);
649
650 let err = match Pager::open_default(&path) {
651 Ok(_) => panic!("future database version should be rejected"),
652 Err(err) => err,
653 };
654 match err {
655 PagerError::InvalidDatabase(msg) => {
656 assert!(msg.contains("newer than supported"), "{msg}");
657 }
658 other => panic!("expected InvalidDatabase, got {other:?}"),
659 }
660
661 cleanup(&path);
662 }
663
664 #[test]
665 fn a_fresh_store_publishes_both_superblock_copies() {
666 let path = temp_db_path("fresh-pair");
669 let pager = Pager::open_default(&path).expect("open_default() should succeed");
670 drop(pager);
671
672 let zone = read_zone(&path);
673 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
674 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
675 assert!(
676 reddb_file::paged_superblock_slot_generation(
677 &zone[start..start + SUPERBLOCK_SLOT_SIZE],
678 copy_index
679 )
680 .is_some(),
681 "copy {copy_index} must be valid on a fresh store"
682 );
683 }
684 cleanup(&path);
685 }
686
687 #[test]
688 fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
689 let path = temp_db_path("ping-pong");
690 let pager = Pager::open_default(&path).expect("open_default() should succeed");
691 let before = reddb_file::select_paged_superblock(&read_zone(&path))
692 .expect("operation should succeed");
693
694 pager
695 .allocate_page(PageType::BTreeLeaf)
696 .expect("allocate_page() should succeed");
697 pager.sync().expect("sync() should succeed");
698
699 let zone = read_zone(&path);
700 let after = reddb_file::select_paged_superblock(&zone)
701 .expect("select_paged_superblock() should succeed");
702 assert_ne!(
703 after.copy_index, before.copy_index,
704 "an update must target the stale copy"
705 );
706 assert!(after.generation > before.generation);
707
708 let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
712 assert_eq!(
713 reddb_file::paged_superblock_slot_generation(
714 &zone[start..start + SUPERBLOCK_SLOT_SIZE],
715 before.copy_index
716 ),
717 Some(before.generation)
718 );
719
720 drop(pager);
721 cleanup(&path);
722 }
723
724 #[test]
725 fn failed_trunk_fence_is_completed_before_retrying_allocation() {
726 let path = temp_db_path("retry-trunk-fence");
727 let pager = Pager::open_default(&path).expect("pager");
728 let page = pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id();
731 pager.sync().unwrap();
732 pager.free_page(page).unwrap();
733 pager.sync().unwrap();
734 let trunk = pager.header().unwrap().freelist_head;
735 assert_ne!(trunk, 0);
736
737 let writable = pager
740 .core
741 .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
742 .unwrap();
743 assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
744 pager.core.with_file(|file| file.0 = writable).unwrap();
745 pager.allocate_page(PageType::BTreeLeaf).unwrap();
746
747 let zone = read_zone(&path);
748 for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
749 let start = copy * SUPERBLOCK_SLOT_SIZE;
750 let header =
751 reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
752 .unwrap();
753 assert_ne!(
754 header.freelist_head, trunk,
755 "copy {copy} still owns consumed trunk"
756 );
757 }
758 drop(pager);
759 cleanup(&path);
760 }
761
762 #[test]
763 fn failed_unjournaled_trunk_write_requires_reopen_and_preserves_free_pages() {
764 let path = temp_db_path("failed-trunk-rewrite");
765 let pager = Pager::open_default(&path).expect("pager");
766 let mut pages = std::collections::HashSet::new();
767 for _ in 0..8 {
768 pages.insert(
769 pager
770 .allocate_page(PageType::BTreeLeaf)
771 .expect("seed page")
772 .page_id(),
773 );
774 }
775 pager.sync().expect("persist seed");
776 for &id in &pages {
777 pager.free_page(id).expect("free seed page");
778 }
779 pager.sync().expect("persist free list");
780 let baseline = pager.page_count().expect("page count");
781 let writable = pager
782 .core
783 .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).expect("read only")))
784 .expect("inject write failure");
785 assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
786 pager
787 .core
788 .with_file(|file| file.0 = writable)
789 .expect("restore file");
790 let error = pager
791 .allocate_page(PageType::BTreeLeaf)
792 .expect_err("writeback must stay fenced");
793 assert!(error.to_string().contains("reopen"), "{error}");
794 drop(pager);
795
796 let recovered = Pager::open_default(&path).expect("recover protected write");
797 let mut reused = std::collections::HashSet::new();
798 for _ in 0..pages.len() {
799 let id = recovered
800 .allocate_page(PageType::BTreeLeaf)
801 .expect("reuse after recovery")
802 .page_id();
803 if id >= baseline {
804 break;
805 }
806 assert!(pages.contains(&id), "unexpected free page {id}");
807 assert!(reused.insert(id), "duplicate reuse {id}");
808 }
809 assert!(reused.len() >= pages.len() - 1, "lost free-page remainder");
812 drop(recovered);
813 cleanup(&path);
814 }
815
816 #[test]
817 fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
818 let path = temp_db_path("reused-allocator-pair");
819 let pager = Pager::open_default(&path).expect("pager");
820 let mut pages = Vec::new();
821 for _ in 0..32 {
822 pages.push(
823 pager
824 .allocate_page(PageType::BTreeLeaf)
825 .expect("allocate")
826 .page_id(),
827 );
828 }
829 pager.sync().expect("seed");
830 for id in pages {
831 pager.free_page(id).expect("free");
832 }
833 pager.sync().expect("publish free list");
834 let mut live = std::collections::HashSet::new();
835 for _ in 0..8 {
836 let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
837 live.insert(page.page_id());
838 pager
839 .write_page(page.page_id(), page)
840 .expect("write valid page");
841 }
842 pager.sync().expect("publish reused ownership");
843 drop(pager);
844 let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
845 poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
846 let recovered = Pager::open_default(&path).expect("sibling recovery");
847 for _ in 0..64 {
848 let id = recovered
849 .allocate_page(PageType::BTreeLeaf)
850 .expect("allocate after recovery")
851 .page_id();
852 assert!(
853 !live.contains(&id),
854 "sibling allocator still considers live page {id} free"
855 );
856 }
857 drop(recovered);
858 cleanup(&path);
859 }
860
861 #[test]
862 fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
863 let path = temp_db_path("torn-newest");
864 {
865 let pager = Pager::open_default(&path).expect("open_default() should succeed");
866 pager
867 .allocate_page(PageType::BTreeLeaf)
868 .expect("allocate_page() should succeed");
869 pager.sync().expect("sync() should succeed");
870 }
871
872 let newest = reddb_file::select_paged_superblock(&read_zone(&path))
873 .expect("operation should succeed");
874 let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
876 torn.copy_from_slice(
877 &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
878 ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
879 );
880 torn[64] ^= 0xFF;
881 poke_slot(&path, newest.copy_index, &torn);
882
883 let pager = Pager::open_default(&path).expect("older copy must root the store");
884 assert!(pager.page_count().expect("page_count() should succeed") >= 3);
885 drop(pager);
886 cleanup(&path);
887 }
888
889 #[test]
890 fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
891 let path = temp_db_path("both-invalid");
892 {
893 let _ = Pager::open_default(&path).expect("open_default() should succeed");
894 }
895
896 let zone = read_zone(&path);
897 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
898 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
899 let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
900 slot[64] ^= 0xFF;
901 poke_slot(&path, copy_index, &slot);
902 }
903
904 let err = match Pager::open_default(&path) {
905 Ok(_) => panic!("a rootless store must not open"),
906 Err(err) => err,
907 };
908 let rendered = err.to_string();
909 assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
910 assert!(rendered.contains("superblock zone"), "{rendered}");
911 assert!(rendered.contains("salvage"), "{rendered}");
912
913 cleanup(&path);
914 }
915
916 #[test]
917 fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
918 let path = temp_db_path("legacy-sidecar");
919 {
920 let _ = Pager::open_default(&path).expect("open_default() should succeed");
921 }
922
923 let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
924 std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
925
926 let err = match Pager::open_default(&path) {
927 Ok(_) => panic!("legacy stores need migration first"),
928 Err(err) => err,
929 };
930 let rendered = err.to_string();
931 assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
932 assert!(rendered.contains("migration tool"), "{rendered}");
933
934 let _ = std::fs::remove_file(&sidecar);
935 cleanup(&path);
936 }
937
938 #[test]
939 fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
940 let path = temp_db_path("pre-phase3-large");
941 {
942 let _ = Pager::open_default(&path).expect("open_default() should succeed");
943 }
944 stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
945
946 let err = match Pager::open_default(&path) {
947 Ok(_) => panic!("pre-phase-3 stores need migration first"),
948 Err(err) => err,
949 };
950 let rendered = err.to_string();
951 assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
952 assert!(rendered.contains("migration tool"), "{rendered}");
953
954 cleanup(&path);
955 }
956
957 #[test]
958 fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
959 let path = temp_db_path("pre-phase3-small");
960 {
961 let _ = Pager::open_default(&path).expect("open_default() should succeed");
962 }
963 stamp_pre_phase3_layout(&path, 3);
964 OpenOptions::new()
965 .write(true)
966 .open(&path)
967 .expect("open() should succeed")
968 .set_len(3 * PAGE_SIZE as u64)
969 .expect("set_len() should succeed");
970
971 let err = match Pager::open_default(&path) {
972 Ok(_) => panic!("pre-phase-3 stores need migration first"),
973 Err(err) => err,
974 };
975 assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
976
977 cleanup(&path);
978 }
979
980 #[test]
981 fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
982 let path = temp_db_path("census");
983 {
984 let pager = Pager::open_default(&path).expect("open_default() should succeed");
985 let page = pager
986 .allocate_page(PageType::BTreeLeaf)
987 .expect("allocate_page() should succeed");
988 pager
989 .write_page(page.page_id(), page)
990 .expect("operation should succeed");
991 pager.sync().expect("sync() should succeed");
992 }
993 {
994 let pager = Pager::open_default(&path).expect("open_default() should succeed");
995 pager.sync().expect("sync() should succeed");
996 }
997
998 assert_eq!(
999 reddb_file::layout::retired::first_present_phase1_sidecar(&path),
1000 None,
1001 "the pager must never create a retired phase-1 sidecar"
1002 );
1003 cleanup(&path);
1004 }
1005}