1use super::*;
2
3pub(super) const BTRFS_SUPER_MAGIC: i64 = 0x9123_683e;
4pub(super) const ZFS_SUPER_MAGIC: i64 = 0x2fc1_2fc1;
5pub(super) const FS_NOCOW_FL: u64 = 0x0080_0000;
6
7#[cfg(test)]
8const PHASE3_DWB_ZONE_FILE_VERSION: u32 = reddb_file::PAGE_FILE_VERSION;
9
10#[cfg(test)]
11pub(super) const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
12#[cfg(test)]
13pub(super) const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(super) enum CowFilesystemKind {
17 Zfs,
18 BtrfsDataCow,
19 TestOverride,
20}
21
22pub(super) fn classify_cow_filesystem(
23 fs_type: i64,
24 mount_options: Option<&str>,
25 inode_flags: Option<u64>,
26) -> Option<CowFilesystemKind> {
27 match fs_type {
28 ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
29 BTRFS_SUPER_MAGIC => {
30 let mount_options = mount_options?;
31 if mount_options.split(',').any(|option| option == "nodatacow") {
32 return None;
33 }
34
35 let inode_flags = inode_flags?;
36 if inode_flags & FS_NOCOW_FL != 0 {
37 return None;
38 }
39
40 Some(CowFilesystemKind::BtrfsDataCow)
41 }
42 _ => None,
43 }
44}
45
46#[cfg(target_os = "linux")]
47fn linux_fstatfs_type(file: &File) -> Option<i64> {
48 use std::mem::MaybeUninit;
49 use std::os::fd::AsRawFd;
50
51 let mut stat = MaybeUninit::<libc::statfs>::uninit();
52 let rc = unsafe { libc::fstatfs(file.as_raw_fd(), stat.as_mut_ptr()) };
53 if rc != 0 {
54 return None;
55 }
56 let stat = unsafe { stat.assume_init() };
57 #[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 let lock_file = if !config.read_only {
209 let lf = OpenOptions::new().read(true).write(true).open(&path)?;
210 lf.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
211 Some(lf)
212 } else {
213 let lf = OpenOptions::new().read(true).open(&path)?;
214 match lf.try_lock_shared() {
215 Ok(_) => Some(lf),
216 Err(_) => None,
217 }
218 };
219
220 let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
230 if exists {
231 if let Some(sidecar) =
232 reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
233 {
234 return Err(PagerError::LegacySidecarStore { sidecar });
235 }
236 }
237 if !config.double_write && !config.read_only && !fold_dwb {
238 let skip_dwb_on_cow =
239 Self::cow_filesystem_has_atomic_page_writes(&path, &file).is_some();
240 if !skip_dwb_on_cow {
241 tracing::warn!(
242 path = %path.display(),
243 "double_write=false requested, but the data file is not proven to be on \
244 ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
245 );
246 config.double_write = true;
247 }
248 }
249 let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
250
251 let core = SharedPager::open_owned(
252 NativePagerFile(file),
253 path,
254 SharedPagerConfig {
255 cache_size: config.cache_size,
256 read_only: config.read_only,
257 verify_checksums: config.verify_checksums,
258 double_write: dwb_enabled,
259 },
260 NativeWalBarrier::default(),
261 !exists,
262 )?;
263 let mut pager = Self {
264 core,
265 _lock_file: lock_file,
266 config,
267 encryption: None,
268 };
269
270 if exists {
271 pager.bind_encryption_for_existing()?;
272 } else {
273 pager.bind_encryption_for_new()?;
275 }
276
277 Ok(pager)
278 }
279
280 pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
287 fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
288 }
289
290 #[cfg(test)]
291 fn cow_filesystem_test_override() -> Option<bool> {
292 match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
293 1 => Some(true),
294 2 => Some(false),
295 _ => None,
296 }
297 }
298
299 #[cfg(not(test))]
300 fn cow_filesystem_test_override() -> Option<bool> {
301 None
302 }
303
304 fn cow_filesystem_has_atomic_page_writes(
305 path: &Path,
306 file: &File,
307 ) -> Option<CowFilesystemKind> {
308 if let Some(allowed) = Self::cow_filesystem_test_override() {
309 return allowed.then_some(CowFilesystemKind::TestOverride);
310 }
311 Self::probe_cow_filesystem(path, file)
312 }
313
314 #[cfg(target_os = "linux")]
315 fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
316 let fs_type = linux_fstatfs_type(file)?;
317 match fs_type {
318 ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
319 BTRFS_SUPER_MAGIC => {
320 let mount_options = linux_mount_options_for_path(path)?;
321 let inode_flags = linux_inode_flags(file)?;
322 classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
323 }
324 _ => None,
325 }
326 }
327
328 #[cfg(not(target_os = "linux"))]
329 fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
330 None
331 }
332
333 fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
343 if self.page_count().unwrap_or(0) == 0 {
344 return self.bind_encryption_for_new();
345 }
346 let (_, image) = self.newest_superblock()?;
347 let data = &image[..];
348 let has_marker = reddb_file::paged_encryption_marker_present(data);
349
350 let key = self.config.encryption.clone();
351 match (has_marker, key) {
352 (true, Some(key)) => {
353 let header_bytes =
354 reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
355 PagerError::InvalidDatabase("encryption header parse failed".to_string())
356 })?;
357 let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
358 .map_err(|e| {
359 PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
360 })?;
361 if !header.validate(&key) {
362 return Err(PagerError::InvalidKey);
363 }
364 let encryptor = crate::storage::encryption::PageEncryptor::new(key);
365 self.encryption = Some((encryptor, header));
366 Ok(())
367 }
368 (true, None) => Err(PagerError::EncryptionRequired),
369 (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
370 (false, None) => Ok(()),
371 }
372 }
373
374 fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
377 let Some(key) = self.config.encryption.clone() else {
378 return Ok(());
379 };
380 let header = crate::storage::encryption::EncryptionHeader::new(&key);
381 let encryptor = crate::storage::encryption::PageEncryptor::new(key);
382
383 if self.page_count().unwrap_or(0) > 0 {
386 let header_bytes = header.to_bytes();
387 self.commit_superblock(|image| {
388 reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
389 .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
390 })?;
391 }
392 self.encryption = Some((encryptor, header));
393 Ok(())
394 }
395
396 pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
398 Self::open(path, PagerConfig::default())
399 }
400
401 pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
406 if page_id == 0 || self.encryption.is_none() {
407 return self.read_page(page_id);
408 }
409 let raw = self.read_page_no_checksum(page_id)?;
410 let (enc, _) = self
411 .encryption
412 .as_ref()
413 .expect("encryption presence checked above");
414 let plaintext = enc
415 .decrypt(page_id, raw.as_bytes())
416 .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
417 let mut buf = [0u8; PAGE_SIZE];
418 let n = plaintext.len().min(PAGE_SIZE);
419 buf[..n].copy_from_slice(&plaintext[..n]);
420 Ok(Page::from_bytes(buf))
421 }
422
423 pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
428 if page_id == 0 || self.encryption.is_none() {
429 return self.write_page(page_id, page);
430 }
431 const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
434 let plaintext_len = PAGE_SIZE - OVERHEAD;
435 let plaintext = &page.as_bytes()[..plaintext_len];
436 let (enc, _) = self
437 .encryption
438 .as_ref()
439 .expect("encryption presence checked above");
440 let ciphertext = enc.encrypt(page_id, plaintext);
441 debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
442 let mut buf = [0u8; PAGE_SIZE];
443 buf.copy_from_slice(&ciphertext);
444 let cipher_page = Page::from_bytes(buf);
445 self.write_page_no_checksum(page_id, cipher_page)
446 }
447
448 pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
460 let mut slot = self
461 .core
462 .wal_barrier()
463 .0
464 .write()
465 .unwrap_or_else(|p| p.into_inner());
466 *slot = Some(wal);
467 }
468
469 pub fn clear_wal_writer(&self) {
471 let mut slot = self
472 .core
473 .wal_barrier()
474 .0
475 .write()
476 .unwrap_or_else(|p| p.into_inner());
477 *slot = None;
478 }
479
480 pub fn has_wal_writer(&self) -> bool {
482 self.core
483 .wal_barrier()
484 .0
485 .read()
486 .map(|s| s.is_some())
487 .unwrap_or(false)
488 }
489
490 pub fn prefetch_hint(&self, page_id: u32) {
492 let _ = self.core.with_file(|file| {
493 super::super::prefetch::prefetch_page(&file.0, u64::from(page_id), PAGE_SIZE as u32)
494 });
495 }
496
497 #[cfg(test)]
498 fn dwb_path(path: &Path) -> PathBuf {
499 reddb_file::layout::pager_dwb_shadow_path(path)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 fn temp_db_path(name: &str) -> PathBuf {
508 std::env::temp_dir().join(format!(
509 "reddb-pager-{}-{}-{}.rdb",
510 name,
511 std::process::id(),
512 crate::utils::now_unix_nanos()
513 ))
514 }
515
516 fn cleanup(path: &Path) {
517 let _ = std::fs::remove_file(path);
518 let _ = std::fs::remove_file(Pager::dwb_path(path));
519 }
520
521 fn read_zone(path: &Path) -> Vec<u8> {
522 let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
523 let mut file = File::open(path).expect("open() should succeed");
524 file.read_exact(&mut zone)
525 .expect("read_exact() should succeed");
526 zone
527 }
528
529 fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
531 let mut file = OpenOptions::new()
532 .write(true)
533 .open(path)
534 .expect("open() should succeed");
535 file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
536 copy_index,
537 )))
538 .expect("operation should succeed");
539 file.write_all(bytes).expect("write_all() should succeed");
540 file.sync_all().expect("sync_all() should succeed");
541 }
542
543 fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
544 let zone = read_zone(path);
545 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
546 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
547 let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
548 let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
549 .expect("fresh store should publish both slots");
550 reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
551 .expect("set_database_header_version() should succeed");
552 reddb_file::set_database_header_page_count(&mut slot, page_count)
553 .expect("set_database_header_page_count() should succeed");
554 reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
555 .expect("seal_paged_superblock_slot() should succeed");
556 poke_slot(path, copy_index, &slot);
557 }
558 }
559
560 #[test]
561 fn open_refuses_future_database_version() {
562 let path = temp_db_path("future-version");
563 let pager = Pager::open_default(&path).expect("open_default() should succeed");
564 drop(pager);
565
566 let selection = reddb_file::select_paged_superblock(&read_zone(&path))
570 .expect("operation should succeed");
571 let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
572 reddb_file::init_database_header_page(&mut slot, 1)
573 .expect("init_database_header_page() should succeed");
574 reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
575 .expect("operation should succeed");
576 reddb_file::seal_paged_superblock_slot(
577 &mut slot,
578 selection.copy_index,
579 selection.generation + 1,
580 )
581 .expect("operation should succeed");
582 poke_slot(&path, selection.copy_index, &slot);
583
584 let err = match Pager::open_default(&path) {
585 Ok(_) => panic!("future database version should be rejected"),
586 Err(err) => err,
587 };
588 match err {
589 PagerError::InvalidDatabase(msg) => {
590 assert!(msg.contains("newer than supported"), "{msg}");
591 }
592 other => panic!("expected InvalidDatabase, got {other:?}"),
593 }
594
595 cleanup(&path);
596 }
597
598 #[test]
599 fn a_fresh_store_publishes_both_superblock_copies() {
600 let path = temp_db_path("fresh-pair");
603 let pager = Pager::open_default(&path).expect("open_default() should succeed");
604 drop(pager);
605
606 let zone = read_zone(&path);
607 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
608 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
609 assert!(
610 reddb_file::paged_superblock_slot_generation(
611 &zone[start..start + SUPERBLOCK_SLOT_SIZE],
612 copy_index
613 )
614 .is_some(),
615 "copy {copy_index} must be valid on a fresh store"
616 );
617 }
618 cleanup(&path);
619 }
620
621 #[test]
622 fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
623 let path = temp_db_path("ping-pong");
624 let pager = Pager::open_default(&path).expect("open_default() should succeed");
625 let before = reddb_file::select_paged_superblock(&read_zone(&path))
626 .expect("operation should succeed");
627
628 pager
629 .allocate_page(PageType::BTreeLeaf)
630 .expect("allocate_page() should succeed");
631 pager.sync().expect("sync() should succeed");
632
633 let zone = read_zone(&path);
634 let after = reddb_file::select_paged_superblock(&zone)
635 .expect("select_paged_superblock() should succeed");
636 assert_ne!(
637 after.copy_index, before.copy_index,
638 "an update must target the stale copy"
639 );
640 assert!(after.generation > before.generation);
641
642 let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
646 assert_eq!(
647 reddb_file::paged_superblock_slot_generation(
648 &zone[start..start + SUPERBLOCK_SLOT_SIZE],
649 before.copy_index
650 ),
651 Some(before.generation)
652 );
653
654 drop(pager);
655 cleanup(&path);
656 }
657
658 #[test]
659 fn failed_trunk_fence_is_completed_before_retrying_allocation() {
660 let path = temp_db_path("retry-trunk-fence");
661 let pager = Pager::open_default(&path).expect("pager");
662 let mut pages = Vec::new();
663 for _ in 0..8 {
664 pages.push(pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id());
665 }
666 pager.sync().unwrap();
667 for id in pages {
668 pager.free_page(id).unwrap();
669 }
670 pager.sync().unwrap();
671 let trunk = pager.header().unwrap().freelist_head;
672 assert_ne!(trunk, 0);
673
674 let writable = pager
677 .core
678 .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
679 .unwrap();
680 assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
681 pager.core.with_file(|file| file.0 = writable).unwrap();
682 pager.allocate_page(PageType::BTreeLeaf).unwrap();
683
684 let zone = read_zone(&path);
685 for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
686 let start = copy * SUPERBLOCK_SLOT_SIZE;
687 let header =
688 reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
689 .unwrap();
690 assert_ne!(
691 header.freelist_head, trunk,
692 "copy {copy} still owns consumed trunk"
693 );
694 }
695 drop(pager);
696 cleanup(&path);
697 }
698
699 #[test]
700 fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
701 let path = temp_db_path("reused-allocator-pair");
702 let pager = Pager::open_default(&path).expect("pager");
703 let mut pages = Vec::new();
704 for _ in 0..32 {
705 pages.push(
706 pager
707 .allocate_page(PageType::BTreeLeaf)
708 .expect("allocate")
709 .page_id(),
710 );
711 }
712 pager.sync().expect("seed");
713 for id in pages {
714 pager.free_page(id).expect("free");
715 }
716 pager.sync().expect("publish free list");
717 let mut live = std::collections::HashSet::new();
718 for _ in 0..8 {
719 let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
720 live.insert(page.page_id());
721 pager
722 .write_page(page.page_id(), page)
723 .expect("write valid page");
724 }
725 pager.sync().expect("publish reused ownership");
726 drop(pager);
727 let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
728 poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
729 let recovered = Pager::open_default(&path).expect("sibling recovery");
730 for _ in 0..64 {
731 let id = recovered
732 .allocate_page(PageType::BTreeLeaf)
733 .expect("allocate after recovery")
734 .page_id();
735 assert!(
736 !live.contains(&id),
737 "sibling allocator still considers live page {id} free"
738 );
739 }
740 drop(recovered);
741 cleanup(&path);
742 }
743
744 #[test]
745 fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
746 let path = temp_db_path("torn-newest");
747 {
748 let pager = Pager::open_default(&path).expect("open_default() should succeed");
749 pager
750 .allocate_page(PageType::BTreeLeaf)
751 .expect("allocate_page() should succeed");
752 pager.sync().expect("sync() should succeed");
753 }
754
755 let newest = reddb_file::select_paged_superblock(&read_zone(&path))
756 .expect("operation should succeed");
757 let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
759 torn.copy_from_slice(
760 &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
761 ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
762 );
763 torn[64] ^= 0xFF;
764 poke_slot(&path, newest.copy_index, &torn);
765
766 let pager = Pager::open_default(&path).expect("older copy must root the store");
767 assert!(pager.page_count().expect("page_count() should succeed") >= 3);
768 drop(pager);
769 cleanup(&path);
770 }
771
772 #[test]
773 fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
774 let path = temp_db_path("both-invalid");
775 {
776 let _ = Pager::open_default(&path).expect("open_default() should succeed");
777 }
778
779 let zone = read_zone(&path);
780 for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
781 let start = copy_index * SUPERBLOCK_SLOT_SIZE;
782 let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
783 slot[64] ^= 0xFF;
784 poke_slot(&path, copy_index, &slot);
785 }
786
787 let err = match Pager::open_default(&path) {
788 Ok(_) => panic!("a rootless store must not open"),
789 Err(err) => err,
790 };
791 let rendered = err.to_string();
792 assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
793 assert!(rendered.contains("superblock zone"), "{rendered}");
794 assert!(rendered.contains("salvage"), "{rendered}");
795
796 cleanup(&path);
797 }
798
799 #[test]
800 fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
801 let path = temp_db_path("legacy-sidecar");
802 {
803 let _ = Pager::open_default(&path).expect("open_default() should succeed");
804 }
805
806 let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
807 std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
808
809 let err = match Pager::open_default(&path) {
810 Ok(_) => panic!("legacy stores need migration first"),
811 Err(err) => err,
812 };
813 let rendered = err.to_string();
814 assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
815 assert!(rendered.contains("migration tool"), "{rendered}");
816
817 let _ = std::fs::remove_file(&sidecar);
818 cleanup(&path);
819 }
820
821 #[test]
822 fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
823 let path = temp_db_path("pre-phase3-large");
824 {
825 let _ = Pager::open_default(&path).expect("open_default() should succeed");
826 }
827 stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
828
829 let err = match Pager::open_default(&path) {
830 Ok(_) => panic!("pre-phase-3 stores need migration first"),
831 Err(err) => err,
832 };
833 let rendered = err.to_string();
834 assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
835 assert!(rendered.contains("migration tool"), "{rendered}");
836
837 cleanup(&path);
838 }
839
840 #[test]
841 fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
842 let path = temp_db_path("pre-phase3-small");
843 {
844 let _ = Pager::open_default(&path).expect("open_default() should succeed");
845 }
846 stamp_pre_phase3_layout(&path, 3);
847 OpenOptions::new()
848 .write(true)
849 .open(&path)
850 .expect("open() should succeed")
851 .set_len(3 * PAGE_SIZE as u64)
852 .expect("set_len() should succeed");
853
854 let err = match Pager::open_default(&path) {
855 Ok(_) => panic!("pre-phase-3 stores need migration first"),
856 Err(err) => err,
857 };
858 assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
859
860 cleanup(&path);
861 }
862
863 #[test]
864 fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
865 let path = temp_db_path("census");
866 {
867 let pager = Pager::open_default(&path).expect("open_default() should succeed");
868 let page = pager
869 .allocate_page(PageType::BTreeLeaf)
870 .expect("allocate_page() should succeed");
871 pager
872 .write_page(page.page_id(), page)
873 .expect("operation should succeed");
874 pager.sync().expect("sync() should succeed");
875 }
876 {
877 let pager = Pager::open_default(&path).expect("open_default() should succeed");
878 pager.sync().expect("sync() should succeed");
879 }
880
881 assert_eq!(
882 reddb_file::layout::retired::first_present_phase1_sidecar(&path),
883 None,
884 "the pager must never create a retired phase-1 sidecar"
885 );
886 cleanup(&path);
887 }
888}