1use std::ffi::OsString;
21use std::fs::{File, OpenOptions};
22use std::io::{Read, Seek, SeekFrom, Write};
23use std::path::{Path, PathBuf};
24use std::sync::{Arc, Mutex};
25
26use advisory_lock::{AdvisoryFileLock, FileLockError, FileLockMode};
27use fsqlite_error::{FrankenError, Result};
28
29use crate::traits::FileIdentity;
30
31const GATE_SUFFIX: &str = "-fsqlite-ns-gate";
32const USE_SUFFIX: &str = "-fsqlite-ns-use";
33const RECORD_MAGIC: [u8; 8] = *b"FSQLNS01";
34const RECORD_VERSION: u8 = 1;
35const IDENTITY_BYTES: usize = 25;
36const RECORD_BYTES: usize = 40;
37const TRANSITION_MAGIC: [u8; 8] = *b"FSQLNT01";
38const TRANSITION_VERSION: u8 = 1;
39const TRANSITION_BYTES: usize = 88;
40const TRANSITION_CHECKSUM_OFFSET: usize = 80;
41const PREPARE_MAGIC: [u8; 8] = *b"FSQLNP01";
42const PREPARE_VERSION: u8 = 1;
43const PREPARE_BYTES: usize = TRANSITION_BYTES;
44const PREPARE_CHECKSUM_OFFSET: usize = TRANSITION_CHECKSUM_OFFSET;
45const FINISH_MAGIC: [u8; 8] = *b"FSQLNF01";
46const FINISH_VERSION: u8 = 1;
47const FINISH_BYTES: usize = TRANSITION_BYTES;
48const FINISH_CHECKSUM_OFFSET: usize = TRANSITION_CHECKSUM_OFFSET;
49const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
50const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum NamespaceOpenIntent {
55 Shared,
58 ReadOnlyExisting,
61 ReservedExclusive,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum NamespaceGenerationTransitionOutcome {
68 Published,
70 AlreadyPublished,
72}
73
74#[derive(Debug)]
81pub struct DatabaseNamespaceGenerationTransition {
82 stable_path: PathBuf,
83 gate: Option<File>,
84 use_file: Option<File>,
85 current_identity: FileIdentity,
86 last_sequence: u64,
87 prepare_offset: u64,
88 append_offset: u64,
89 interrupted_tail: Vec<u8>,
90 finished: bool,
91 poisoned: bool,
92}
93
94#[derive(Debug)]
95enum PendingLease {
96 NewShared {
97 gate: File,
98 use_file: File,
99 },
100 JoinShared {
101 gate: File,
102 use_file: File,
103 generation_identity: FileIdentity,
104 },
105 BootstrapExclusive {
106 gate: File,
107 use_file: File,
108 },
109 ReadOnlyUnadmitted,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum NamespaceBindMode {
120 PreserveRecord,
121 ReplaceQuiescentRecord,
122}
123
124#[derive(Debug)]
128pub struct PendingNamespaceOpen {
129 stable_path: PathBuf,
130 lease: Option<PendingLease>,
131}
132
133impl PendingNamespaceOpen {
134 pub fn begin(stable_path: &Path, intent: NamespaceOpenIntent) -> Result<Self> {
137 validate_stable_path(stable_path)?;
138 let (gate, mut use_file) = if intent == NamespaceOpenIntent::ReadOnlyExisting {
139 let gate_path = sidecar_path(stable_path, GATE_SUFFIX);
147 let use_path = sidecar_path(stable_path, USE_SUFFIX);
148 let sidecar_missing = |path: &Path| {
149 matches!(
150 std::fs::metadata(path),
151 Err(ref error) if error.kind() == std::io::ErrorKind::NotFound
152 )
153 };
154 if sidecar_missing(&gate_path) || sidecar_missing(&use_path) {
155 return Ok(Self {
156 stable_path: stable_path.to_owned(),
157 lease: Some(PendingLease::ReadOnlyUnadmitted),
158 });
159 }
160 (
161 open_existing_secure_lock_file(&gate_path)?,
162 open_existing_secure_lock_file(&use_path)?,
163 )
164 } else {
165 (
166 open_secure_lock_file(&sidecar_path(stable_path, GATE_SUFFIX))?,
167 open_secure_lock_file(&sidecar_path(stable_path, USE_SUFFIX))?,
168 )
169 };
170 let gate_mode = if intent == NamespaceOpenIntent::ReadOnlyExisting {
171 FileLockMode::Shared
172 } else {
173 FileLockMode::Exclusive
174 };
175 try_lock(&gate, gate_mode)?;
176
177 let lease = match intent {
178 NamespaceOpenIntent::ReservedExclusive => {
179 if let Err(error) = try_lock(&use_file, FileLockMode::Exclusive) {
180 release_namespace_locks(&gate, &use_file);
181 return Err(error);
182 }
183 PendingLease::BootstrapExclusive { gate, use_file }
184 }
185 NamespaceOpenIntent::Shared => {
186 match AdvisoryFileLock::try_lock(&use_file, FileLockMode::Exclusive) {
187 Ok(()) => PendingLease::NewShared { gate, use_file },
188 Err(FileLockError::AlreadyLocked) => {
189 if let Err(error) = try_lock(&use_file, FileLockMode::Shared) {
190 release_namespace_locks(&gate, &use_file);
191 return Err(error);
192 }
193 let generation_identity =
194 match read_identity_record(&mut use_file, stable_path) {
195 Ok(identity) => identity,
196 Err(error) => {
197 release_namespace_locks(&gate, &use_file);
198 return Err(error);
199 }
200 };
201 PendingLease::JoinShared {
202 gate,
203 use_file,
204 generation_identity,
205 }
206 }
207 Err(FileLockError::Io(error)) => {
208 release_namespace_locks(&gate, &use_file);
209 return Err(error.into());
210 }
211 }
212 }
213 NamespaceOpenIntent::ReadOnlyExisting => {
214 if let Err(error) = try_lock(&use_file, FileLockMode::Shared) {
215 release_namespace_locks(&gate, &use_file);
216 return Err(error);
217 }
218 let generation_identity = match read_identity_record(&mut use_file, stable_path) {
219 Ok(identity) => identity,
220 Err(error) => {
221 release_namespace_locks(&gate, &use_file);
222 return Err(error);
223 }
224 };
225 PendingLease::JoinShared {
226 gate,
227 use_file,
228 generation_identity,
229 }
230 }
231 };
232
233 Ok(Self {
234 stable_path: stable_path.to_owned(),
235 lease: Some(lease),
236 })
237 }
238
239 #[must_use]
243 pub fn expected_identity(&self) -> Option<FileIdentity> {
244 match self.lease.as_ref() {
245 Some(PendingLease::JoinShared {
246 generation_identity,
247 ..
248 }) => Some(*generation_identity),
249 _ => None,
250 }
251 }
252
253 pub fn has_quiescent_record_bytes(&self) -> Result<bool> {
259 match self.lease.as_ref() {
260 Some(PendingLease::NewShared { use_file, .. }) => Ok(use_file.metadata()?.len() != 0),
261 _ => Ok(false),
262 }
263 }
264
265 pub fn bind(self, identity: FileIdentity) -> Result<Arc<DatabaseNamespaceBinding>> {
268 self.bind_with_gate_release(identity, release_gate)
269 }
270
271 pub fn bind_replacing_quiescent_record(
280 self,
281 identity: FileIdentity,
282 ) -> Result<Arc<DatabaseNamespaceBinding>> {
283 self.bind_with_gate_release_mode(
284 identity,
285 release_gate,
286 NamespaceBindMode::ReplaceQuiescentRecord,
287 )
288 }
289
290 fn bind_with_gate_release<F>(
291 self,
292 identity: FileIdentity,
293 release_gate_fn: F,
294 ) -> Result<Arc<DatabaseNamespaceBinding>>
295 where
296 F: FnOnce(&File) -> Result<()>,
297 {
298 self.bind_with_gate_release_mode(
299 identity,
300 release_gate_fn,
301 NamespaceBindMode::PreserveRecord,
302 )
303 }
304
305 fn bind_with_gate_release_mode<F>(
306 mut self,
307 identity: FileIdentity,
308 release_gate_fn: F,
309 bind_mode: NamespaceBindMode,
310 ) -> Result<Arc<DatabaseNamespaceBinding>>
311 where
312 F: FnOnce(&File) -> Result<()>,
313 {
314 let lease = self
315 .lease
316 .take()
317 .ok_or_else(|| FrankenError::internal("namespace admission already consumed"))?;
318
319 let state = match lease {
320 PendingLease::NewShared { gate, mut use_file } => {
321 let write_result = match bind_mode {
322 NamespaceBindMode::PreserveRecord => {
323 write_identity_record(&mut use_file, &self.stable_path, identity)
324 }
325 NamespaceBindMode::ReplaceQuiescentRecord => replace_quiescent_identity_record(
326 &mut use_file,
327 &self.stable_path,
328 identity,
329 ),
330 };
331 if let Err(error) = write_result {
332 release_namespace_locks(&gate, &use_file);
333 return Err(error);
334 }
335 BindingLease::BootstrapExclusive { gate, use_file }
339 }
340 PendingLease::JoinShared {
341 gate,
342 mut use_file,
343 generation_identity,
344 } => {
345 if bind_mode == NamespaceBindMode::ReplaceQuiescentRecord {
346 release_namespace_locks(&gate, &use_file);
347 return Err(cannot_open(&self.stable_path));
348 }
349 let observed_identity = match read_identity_record(&mut use_file, &self.stable_path)
350 {
351 Ok(identity) => identity,
352 Err(error) => {
353 release_namespace_locks(&gate, &use_file);
354 return Err(error);
355 }
356 };
357 if observed_identity != generation_identity || identity != generation_identity {
358 release_namespace_locks(&gate, &use_file);
359 return Err(cannot_open(&self.stable_path));
360 }
361 if let Err(error) = release_gate_fn(&gate) {
362 release_namespace_locks(&gate, &use_file);
363 return Err(error);
364 }
365 drop(gate);
366 BindingLease::Shared { use_file }
367 }
368 PendingLease::BootstrapExclusive { gate, mut use_file } => {
369 if bind_mode == NamespaceBindMode::ReplaceQuiescentRecord {
370 release_namespace_locks(&gate, &use_file);
371 return Err(cannot_open(&self.stable_path));
372 }
373 if let Err(error) =
374 write_identity_record(&mut use_file, &self.stable_path, identity)
375 {
376 release_namespace_locks(&gate, &use_file);
377 return Err(error);
378 }
379 BindingLease::BootstrapExclusive { gate, use_file }
380 }
381 PendingLease::ReadOnlyUnadmitted => {
382 if bind_mode == NamespaceBindMode::ReplaceQuiescentRecord {
383 return Err(cannot_open(&self.stable_path));
384 }
385 BindingLease::ReadOnlyUnadmitted
386 }
387 };
388
389 Ok(Arc::new(DatabaseNamespaceBinding {
390 stable_path: std::mem::take(&mut self.stable_path),
391 identity,
392 lease: Mutex::new(state),
393 }))
394 }
395}
396
397impl Drop for PendingNamespaceOpen {
398 fn drop(&mut self) {
399 let Some(lease) = self.lease.take() else {
400 return;
401 };
402 let (gate, use_file) = match lease {
403 PendingLease::NewShared { gate, use_file }
404 | PendingLease::JoinShared { gate, use_file, .. }
405 | PendingLease::BootstrapExclusive { gate, use_file } => (gate, use_file),
406 PendingLease::ReadOnlyUnadmitted => return,
408 };
409 let _ = AdvisoryFileLock::unlock(&use_file);
410 let _ = AdvisoryFileLock::unlock(&gate);
411 }
412}
413
414#[derive(Debug)]
415enum BindingLease {
416 Shared {
417 use_file: File,
418 },
419 BootstrapExclusive {
420 gate: File,
421 use_file: File,
422 },
423 BootstrapUseShared {
424 gate: File,
425 use_file: File,
426 },
427 Transitioning,
428 ReadOnlyUnadmitted,
430}
431
432#[derive(Debug)]
435pub struct DatabaseNamespaceBinding {
436 stable_path: PathBuf,
437 identity: FileIdentity,
438 lease: Mutex<BindingLease>,
439}
440
441impl DatabaseNamespaceBinding {
442 #[must_use]
444 pub fn stable_path(&self) -> &Path {
445 &self.stable_path
446 }
447
448 #[must_use]
450 pub const fn identity(&self) -> FileIdentity {
451 self.identity
452 }
453
454 pub fn validate_identity(&self, current: Option<FileIdentity>) -> Result<()> {
457 if current == Some(self.identity) {
458 Ok(())
459 } else {
460 Err(cannot_open(&self.stable_path))
461 }
462 }
463
464 pub fn validate_path_identity(&self) -> Result<()> {
483 #[cfg(unix)]
484 {
485 use std::os::unix::fs::MetadataExt as _;
486
487 let metadata = std::fs::symlink_metadata(&self.stable_path)
488 .map_err(|_| cannot_open(&self.stable_path))?;
489 if !metadata.is_file() {
490 return Err(cannot_open(&self.stable_path));
491 }
492 self.validate_identity(Some(FileIdentity::from_unix_parts(
493 metadata.dev(),
494 metadata.ino(),
495 )))
496 }
497
498 #[cfg(not(unix))]
502 {
503 let file = open_identity_probe(&self.stable_path)?;
504 self.validate_identity(FileIdentity::from_file(&file)?)
505 }
506 }
507
508 pub fn finish_bootstrap(&self) -> Result<()> {
511 self.finish_bootstrap_with_gate_release(release_gate)
512 }
513
514 fn finish_bootstrap_with_gate_release<F>(&self, release_gate_fn: F) -> Result<()>
515 where
516 F: FnOnce(&File) -> Result<()>,
517 {
518 let mut lease = self
519 .lease
520 .lock()
521 .map_err(|_| FrankenError::internal("namespace lease mutex poisoned"))?;
522 if matches!(
523 *lease,
524 BindingLease::Shared { .. } | BindingLease::ReadOnlyUnadmitted
525 ) {
526 return Ok(());
527 }
528 let old = std::mem::replace(&mut *lease, BindingLease::Transitioning);
529 let (gate, use_file, use_is_shared) = match old {
530 BindingLease::BootstrapExclusive { gate, use_file } => (gate, use_file, false),
531 BindingLease::BootstrapUseShared { gate, use_file } => (gate, use_file, true),
532 other => {
533 *lease = other;
534 return Err(FrankenError::internal(
535 "namespace bootstrap transition re-entered",
536 ));
537 }
538 };
539
540 if !use_is_shared && let Err(error) = downgrade_to_shared(&use_file) {
541 *lease = BindingLease::BootstrapExclusive { gate, use_file };
542 return Err(error);
543 }
544 if let Err(error) = release_gate_fn(&gate) {
545 *lease = BindingLease::BootstrapUseShared { gate, use_file };
546 return Err(error);
547 }
548 drop(gate);
549 *lease = BindingLease::Shared { use_file };
550 Ok(())
551 }
552
553 #[must_use]
555 pub fn bootstrap_is_exclusive(&self) -> bool {
556 self.lease.lock().is_ok_and(|lease| {
557 matches!(
558 *lease,
559 BindingLease::BootstrapExclusive { .. } | BindingLease::BootstrapUseShared { .. }
560 )
561 })
562 }
563}
564
565impl Drop for DatabaseNamespaceBinding {
566 fn drop(&mut self) {
567 let lease = match self.lease.get_mut() {
573 Ok(lease) => lease,
574 Err(poisoned) => poisoned.into_inner(),
575 };
576 match lease {
577 BindingLease::Shared { use_file } => {
578 let _ = AdvisoryFileLock::unlock(use_file);
579 }
580 BindingLease::BootstrapExclusive { gate, use_file }
581 | BindingLease::BootstrapUseShared { gate, use_file } => {
582 let _ = AdvisoryFileLock::unlock(use_file);
583 let _ = AdvisoryFileLock::unlock(gate);
584 }
585 BindingLease::Transitioning | BindingLease::ReadOnlyUnadmitted => {}
586 }
587 }
588}
589
590pub fn begin_database_namespace_generation_transition(
624 database_path: &Path,
625 expected_old_identity: FileIdentity,
626) -> Result<DatabaseNamespaceGenerationTransition> {
627 begin_database_namespace_generation_transition_inner(
628 database_path,
629 expected_old_identity,
630 || Ok(()),
631 )
632}
633
634fn begin_database_namespace_generation_transition_inner<F>(
635 database_path: &Path,
636 expected_old_identity: FileIdentity,
637 before_prepare: F,
638) -> Result<DatabaseNamespaceGenerationTransition>
639where
640 F: FnOnce() -> Result<()>,
641{
642 validate_stable_path(database_path)?;
643
644 let gate_path = sidecar_path(database_path, GATE_SUFFIX);
645 let use_path = sidecar_path(database_path, USE_SUFFIX);
646 let gate = open_existing_transition_lock_file(&gate_path)?;
647 let mut use_file = open_existing_transition_lock_file(&use_path)?;
648 try_lock(&gate, FileLockMode::Exclusive)?;
649 if let Err(error) = try_lock(&use_file, FileLockMode::Exclusive) {
650 let _ = AdvisoryFileLock::unlock(&gate);
651 return Err(error);
652 }
653
654 let preparation = (|| {
655 let state = read_namespace_record_state(&mut use_file, database_path, true)?;
656 if state.current_identity != expected_old_identity {
657 return Err(cannot_open(database_path));
658 }
659
660 let next_sequence = state
661 .last_sequence
662 .checked_add(1)
663 .ok_or_else(|| cannot_open(database_path))?;
664 let (prepare_offset, append_offset, interrupted_tail) = if let Some(prepared_sequence) =
665 state.prepared_sequence
666 {
667 if prepared_sequence != next_sequence {
668 return Err(cannot_open(database_path));
669 }
670 (
671 state
672 .prepare_offset
673 .ok_or_else(|| cannot_open(database_path))?,
674 state.valid_bytes,
675 state.interrupted_tail,
676 )
677 } else {
678 let prepare = encode_prepare_record(next_sequence, expected_old_identity);
679 if !state.interrupted_tail.is_empty() && !prepare.starts_with(&state.interrupted_tail) {
680 return Err(cannot_open(database_path));
681 }
682
683 let resuming_partial_prepare = !state.interrupted_tail.is_empty();
684 if !resuming_partial_prepare {
685 validate_generation_path_identity(database_path, expected_old_identity)?;
686 }
687 before_prepare()?;
688 if !resuming_partial_prepare {
689 validate_generation_path_identity(database_path, expected_old_identity)?;
690 }
691 if resuming_partial_prepare {
692 use_file.set_len(state.valid_bytes)?;
693 use_file.sync_data()?;
694 }
695 use_file.seek(SeekFrom::Start(state.valid_bytes))?;
696 use_file.write_all(&prepare)?;
697 let append_offset = state
698 .valid_bytes
699 .checked_add(PREPARE_BYTES as u64)
700 .ok_or_else(|| cannot_open(database_path))?;
701 use_file.set_len(append_offset)?;
702 use_file.flush()?;
703 use_file.sync_data()?;
704 if !resuming_partial_prepare {
705 validate_generation_path_identity(database_path, expected_old_identity)?;
706 }
707 (state.valid_bytes, append_offset, Vec::new())
708 };
709
710 Ok((
711 state.last_sequence,
712 prepare_offset,
713 append_offset,
714 interrupted_tail,
715 ))
716 })();
717 let (last_sequence, prepare_offset, append_offset, interrupted_tail) = match preparation {
718 Ok(preparation) => preparation,
719 Err(error) => {
720 let _ = AdvisoryFileLock::unlock(&use_file);
721 let _ = AdvisoryFileLock::unlock(&gate);
722 return Err(error);
723 }
724 };
725
726 Ok(DatabaseNamespaceGenerationTransition {
727 stable_path: database_path.to_owned(),
728 gate: Some(gate),
729 use_file: Some(use_file),
730 current_identity: expected_old_identity,
731 last_sequence,
732 prepare_offset,
733 append_offset,
734 interrupted_tail,
735 finished: false,
736 poisoned: false,
737 })
738}
739
740impl DatabaseNamespaceGenerationTransition {
741 #[must_use]
743 pub const fn current_identity(&self) -> FileIdentity {
744 self.current_identity
745 }
746
747 pub fn publish_replacement(
755 &mut self,
756 replacement_identity: FileIdentity,
757 ) -> Result<NamespaceGenerationTransitionOutcome> {
758 self.publish_replacement_inner(replacement_identity, || Ok(()))
759 }
760
761 fn publish_replacement_inner<F>(
762 &mut self,
763 replacement_identity: FileIdentity,
764 before_publish: F,
765 ) -> Result<NamespaceGenerationTransitionOutcome>
766 where
767 F: FnOnce() -> Result<()>,
768 {
769 if self.finished {
770 return Err(FrankenError::internal(
771 "namespace generation transition already finished",
772 ));
773 }
774 self.validate_prepare_marker()?;
775 self.validate_interrupted_tail()?;
776 validate_generation_path_identity(&self.stable_path, replacement_identity)?;
777
778 if replacement_identity == self.current_identity && self.interrupted_tail.is_empty() {
779 return Ok(NamespaceGenerationTransitionOutcome::AlreadyPublished);
780 }
781 if replacement_identity == self.current_identity {
782 return Err(cannot_open(&self.stable_path));
783 }
784
785 let sequence = self
786 .last_sequence
787 .checked_add(1)
788 .ok_or_else(|| cannot_open(&self.stable_path))?;
789 let old_identity = self.current_identity;
790 let transition = encode_transition_record(sequence, old_identity, replacement_identity);
791 let next_prepare = encode_prepare_record(
792 sequence
793 .checked_add(1)
794 .ok_or_else(|| cannot_open(&self.stable_path))?,
795 replacement_identity,
796 );
797 let mut publication = [0_u8; TRANSITION_BYTES + PREPARE_BYTES];
798 publication[..TRANSITION_BYTES].copy_from_slice(&transition);
799 publication[TRANSITION_BYTES..].copy_from_slice(&next_prepare);
800 if !publication.starts_with(&self.interrupted_tail) {
801 return Err(cannot_open(&self.stable_path));
802 }
803
804 before_publish()?;
805 validate_generation_path_identity(&self.stable_path, replacement_identity)?;
806
807 let append_offset = self.append_offset;
808 let use_file = self
809 .use_file
810 .as_mut()
811 .ok_or_else(|| FrankenError::internal("namespace transition lease missing"))?;
812 if !self.interrupted_tail.is_empty() {
813 use_file.set_len(append_offset)?;
814 use_file.sync_data()?;
815 }
816 use_file.seek(SeekFrom::Start(append_offset))?;
817 use_file.write_all(&publication)?;
818 let next_append_offset = append_offset
819 .checked_add(TRANSITION_BYTES as u64)
820 .and_then(|offset| offset.checked_add(PREPARE_BYTES as u64))
821 .ok_or_else(|| cannot_open(&self.stable_path))?;
822 use_file.set_len(next_append_offset)?;
823 use_file.flush()?;
824 use_file.sync_data()?;
825
826 self.current_identity = replacement_identity;
827 self.last_sequence = sequence;
828 self.prepare_offset = append_offset + TRANSITION_BYTES as u64;
829 self.append_offset = next_append_offset;
830 self.interrupted_tail.clear();
831 Ok(NamespaceGenerationTransitionOutcome::Published)
832 }
833
834 pub fn finish(&mut self) -> Result<FileIdentity> {
846 self.finish_inner(|| Ok(()))
847 }
848
849 fn finish_inner<F>(&mut self, before_sync: F) -> Result<FileIdentity>
850 where
851 F: FnOnce() -> Result<()>,
852 {
853 if self.finished {
854 return Ok(self.current_identity);
855 }
856 let stable_path = self.stable_path.clone();
857 let current_identity = self.current_identity;
858 let append_offset = self.append_offset;
859 let mut must_fail_stop_on_drop = false;
860 let result = (|| {
861 self.validate_prepare_marker()?;
862 validate_generation_path_identity(&stable_path, current_identity)?;
863
864 let sequence = self
865 .last_sequence
866 .checked_add(1)
867 .ok_or_else(|| cannot_open(&stable_path))?;
868 let finish = encode_finish_record(sequence, current_identity);
869 let finish_end = append_offset
870 .checked_add(FINISH_BYTES as u64)
871 .ok_or_else(|| cannot_open(&stable_path))?;
872 if !finish.starts_with(&self.interrupted_tail) {
873 return Err(cannot_open(&stable_path));
874 }
875 let use_file = self
876 .use_file
877 .as_mut()
878 .ok_or_else(|| FrankenError::internal("namespace transition lease missing"))?;
879 let file_len = use_file.metadata()?.len();
880 if file_len < append_offset || file_len > finish_end {
881 return Err(cannot_open(&stable_path));
882 }
883 let observed_len =
884 usize::try_from(file_len - append_offset).map_err(|_| cannot_open(&stable_path))?;
885 let mut observed = vec![0_u8; observed_len];
886 use_file.seek(SeekFrom::Start(append_offset))?;
887 use_file.read_exact(&mut observed)?;
888 if !finish.starts_with(&observed) {
889 return Err(cannot_open(&stable_path));
890 }
891 if !observed.is_empty() {
892 must_fail_stop_on_drop = true;
893 use_file.set_len(append_offset)?;
894 use_file.sync_data()?;
895 }
896 must_fail_stop_on_drop = true;
897 use_file.seek(SeekFrom::Start(append_offset))?;
898 use_file.write_all(&finish)?;
899 use_file.set_len(finish_end)?;
900 use_file.flush()?;
901 before_sync()?;
902 use_file.sync_data()?;
903 Ok((sequence, finish_end))
904 })();
905
906 match result {
907 Ok((sequence, finish_end)) => {
908 self.last_sequence = sequence;
909 self.append_offset = finish_end;
910 self.interrupted_tail.clear();
911 self.finished = true;
912 self.poisoned = false;
913 self.release_locks();
914 Ok(current_identity)
915 }
916 Err(error) => {
917 self.poisoned |= must_fail_stop_on_drop;
918 Err(error)
919 }
920 }
921 }
922
923 fn validate_prepare_marker(&mut self) -> Result<()> {
924 let expected = encode_prepare_record(
925 self.last_sequence
926 .checked_add(1)
927 .ok_or_else(|| cannot_open(&self.stable_path))?,
928 self.current_identity,
929 );
930 let mut observed = [0_u8; PREPARE_BYTES];
931 let use_file = self
932 .use_file
933 .as_mut()
934 .ok_or_else(|| FrankenError::internal("namespace transition lease missing"))?;
935 use_file.seek(SeekFrom::Start(self.prepare_offset))?;
936 use_file.read_exact(&mut observed)?;
937 if observed != expected {
938 return Err(cannot_open(&self.stable_path));
939 }
940 Ok(())
941 }
942
943 fn validate_interrupted_tail(&mut self) -> Result<()> {
944 let expected_len = self
945 .append_offset
946 .checked_add(
947 u64::try_from(self.interrupted_tail.len())
948 .map_err(|_| cannot_open(&self.stable_path))?,
949 )
950 .ok_or_else(|| cannot_open(&self.stable_path))?;
951 let use_file = self
952 .use_file
953 .as_mut()
954 .ok_or_else(|| FrankenError::internal("namespace transition lease missing"))?;
955 if use_file.metadata()?.len() != expected_len {
956 return Err(cannot_open(&self.stable_path));
957 }
958 let mut observed = vec![0_u8; self.interrupted_tail.len()];
959 use_file.seek(SeekFrom::Start(self.append_offset))?;
960 use_file.read_exact(&mut observed)?;
961 if observed != self.interrupted_tail {
962 return Err(cannot_open(&self.stable_path));
963 }
964 Ok(())
965 }
966
967 fn release_locks(&mut self) {
968 if let Some(use_file) = self.use_file.take() {
972 let _ = AdvisoryFileLock::unlock(&use_file);
973 drop(use_file);
974 }
975 if let Some(gate) = self.gate.take() {
976 let _ = AdvisoryFileLock::unlock(&gate);
977 drop(gate);
978 }
979 }
980}
981
982impl Drop for DatabaseNamespaceGenerationTransition {
983 fn drop(&mut self) {
984 if self.poisoned && !self.finished {
985 if let Some(gate) = self.gate.take() {
991 std::mem::forget(gate);
992 }
993 if let Some(use_file) = self.use_file.take() {
994 std::mem::forget(use_file);
995 }
996 return;
997 }
998
999 self.release_locks();
1000 }
1001}
1002
1003fn validate_stable_path(path: &Path) -> Result<()> {
1004 if !path.is_absolute() || path.file_name().is_none() {
1005 return Err(cannot_open(path));
1006 }
1007 Ok(())
1008}
1009
1010fn sidecar_path(database_path: &Path, suffix: &str) -> PathBuf {
1011 let mut path: OsString = database_path.as_os_str().to_owned();
1012 path.push(suffix);
1013 PathBuf::from(path)
1014}
1015
1016fn open_secure_lock_file(path: &Path) -> Result<File> {
1017 let file = match configured_open_options(true).open(path) {
1018 Ok(file) => file,
1019 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1020 configured_open_options(false)
1021 .open(path)
1022 .map_err(|_| cannot_open(path))?
1023 }
1024 Err(_) => return Err(cannot_open(path)),
1025 };
1026 validate_secure_lock_file(path, &file)?;
1027 Ok(file)
1028}
1029
1030fn open_existing_secure_lock_file(path: &Path) -> Result<File> {
1031 let file = configured_existing_readonly_open_options()
1032 .open(path)
1033 .map_err(|_| cannot_open(path))?;
1034 validate_secure_lock_file(path, &file)?;
1035 Ok(file)
1036}
1037
1038fn open_existing_transition_lock_file(path: &Path) -> Result<File> {
1039 let file = configured_open_options(false)
1040 .open(path)
1041 .map_err(|_| cannot_open(path))?;
1042 validate_secure_lock_file(path, &file)?;
1043 Ok(file)
1044}
1045
1046fn configured_open_options(create_new: bool) -> OpenOptions {
1047 let mut options = OpenOptions::new();
1048 options.read(true).write(true).create_new(create_new);
1049
1050 #[cfg(unix)]
1051 {
1052 use std::os::unix::fs::OpenOptionsExt as _;
1053 options
1054 .mode(0o600)
1055 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
1056 }
1057 #[cfg(windows)]
1058 {
1059 use std::os::windows::fs::OpenOptionsExt as _;
1060 use windows_sys::Win32::Storage::FileSystem::{
1061 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
1062 };
1063 options
1064 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
1065 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1066 }
1067 options
1068}
1069
1070fn configured_existing_readonly_open_options() -> OpenOptions {
1071 let mut options = OpenOptions::new();
1072 options.read(true);
1073
1074 #[cfg(unix)]
1075 {
1076 use std::os::unix::fs::OpenOptionsExt as _;
1077 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
1078 }
1079 #[cfg(windows)]
1080 {
1081 use std::os::windows::fs::OpenOptionsExt as _;
1082 use windows_sys::Win32::Storage::FileSystem::{
1083 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
1084 };
1085 options
1086 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
1087 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1088 }
1089 options
1090}
1091
1092fn cleanup_open_options() -> OpenOptions {
1099 let mut options = OpenOptions::new();
1100 options.read(true).write(true);
1101
1102 #[cfg(unix)]
1103 {
1104 use std::os::unix::fs::OpenOptionsExt as _;
1105 options
1106 .mode(0o600)
1107 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
1108 }
1109 #[cfg(windows)]
1110 {
1111 use std::os::windows::fs::OpenOptionsExt as _;
1112 use windows_sys::Win32::Storage::FileSystem::{
1113 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1114 };
1115 options
1116 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
1117 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1118 }
1119 options
1120}
1121
1122fn open_cleanup_lock_file(path: &Path) -> Result<Option<File>> {
1123 let file = match cleanup_open_options().open(path) {
1124 Ok(file) => file,
1125 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1126 Err(_) => return Err(cannot_open(path)),
1127 };
1128 validate_secure_lock_file(path, &file)?;
1129 Ok(Some(file))
1130}
1131
1132fn existing_regular_cleanup_entry(database_path: &Path, path: &Path) -> Result<bool> {
1133 match std::fs::symlink_metadata(path) {
1134 Ok(metadata) if metadata.file_type().is_file() => Ok(true),
1135 Ok(_) => Err(cannot_open(database_path)),
1136 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1137 Err(error) => Err(error.into()),
1138 }
1139}
1140
1141pub fn cleanup_abandoned_private_database(
1155 database_path: &Path,
1156 expected_identity: FileIdentity,
1157) -> Result<bool> {
1158 validate_stable_path(database_path)?;
1159 let gate_path = sidecar_path(database_path, GATE_SUFFIX);
1160 let use_path = sidecar_path(database_path, USE_SUFFIX);
1161 let Some(gate) = open_cleanup_lock_file(&gate_path)? else {
1162 return Ok(false);
1163 };
1164 let Some(mut use_file) = open_cleanup_lock_file(&use_path)? else {
1165 return Ok(false);
1166 };
1167
1168 match AdvisoryFileLock::try_lock(&gate, FileLockMode::Exclusive) {
1169 Ok(()) => {}
1170 Err(FileLockError::AlreadyLocked) => return Ok(false),
1171 Err(FileLockError::Io(error)) => return Err(error.into()),
1172 }
1173 match AdvisoryFileLock::try_lock(&use_file, FileLockMode::Exclusive) {
1174 Ok(()) => {}
1175 Err(FileLockError::AlreadyLocked) => return Ok(false),
1176 Err(FileLockError::Io(error)) => return Err(error.into()),
1177 }
1178
1179 if read_identity_record(&mut use_file, database_path)? != expected_identity {
1180 return Ok(false);
1181 }
1182 let main_probe = match open_cleanup_identity_probe(database_path) {
1183 Ok(file) => file,
1184 Err(FrankenError::CannotOpen { .. }) => return Ok(false),
1185 Err(error) => return Err(error),
1186 };
1187 if FileIdentity::from_file(&main_probe)? != Some(expected_identity) {
1188 return Ok(false);
1189 }
1190
1191 let companion_paths = [
1196 sidecar_path(database_path, "-journal"),
1197 sidecar_path(database_path, "-wal"),
1198 sidecar_path(database_path, "-wal-fec"),
1199 sidecar_path(database_path, "-wal-fec").with_extension("wal-fec.tmp"),
1200 sidecar_path(database_path, "-shm"),
1201 sidecar_path(database_path, "-lock-shared"),
1202 sidecar_path(database_path, "-lock-reserved"),
1203 sidecar_path(database_path, "-lock-pending"),
1204 ];
1205 let companion_exists = companion_paths
1206 .iter()
1207 .map(|path| existing_regular_cleanup_entry(database_path, path))
1208 .collect::<Result<Vec<_>>>()?;
1209
1210 let final_main_probe = match open_cleanup_identity_probe(database_path) {
1213 Ok(file) => file,
1214 Err(FrankenError::CannotOpen { .. }) => return Ok(false),
1215 Err(error) => return Err(error),
1216 };
1217 if FileIdentity::from_file(&final_main_probe)? != Some(expected_identity) {
1218 return Ok(false);
1219 }
1220
1221 for (path, exists) in companion_paths.iter().zip(companion_exists) {
1222 if exists {
1223 std::fs::remove_file(path)?;
1224 }
1225 }
1226 std::fs::remove_file(database_path)?;
1227 std::fs::remove_file(&use_path)?;
1228 std::fs::remove_file(&gate_path)?;
1229
1230 #[cfg(unix)]
1231 {
1232 let parent = database_path
1233 .parent()
1234 .filter(|parent| !parent.as_os_str().is_empty())
1235 .unwrap_or_else(|| Path::new("."));
1236 File::open(parent)?.sync_all()?;
1237 }
1238 Ok(true)
1243}
1244
1245#[cfg(not(unix))]
1246fn open_identity_probe(path: &Path) -> Result<File> {
1247 let mut options = OpenOptions::new();
1248 options.read(true);
1249 #[cfg(unix)]
1250 {
1251 use std::os::unix::fs::OpenOptionsExt as _;
1252 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
1253 }
1254 #[cfg(windows)]
1255 {
1256 use std::os::windows::fs::OpenOptionsExt as _;
1257 use windows_sys::Win32::Storage::FileSystem::{
1258 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE,
1259 };
1260 options
1261 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
1262 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1263 }
1264
1265 let file = options.open(path).map_err(|_| cannot_open(path))?;
1266 let metadata = file.metadata().map_err(|_| cannot_open(path))?;
1267 if !metadata.is_file() {
1268 return Err(cannot_open(path));
1269 }
1270 #[cfg(windows)]
1271 {
1272 use std::os::windows::fs::MetadataExt as _;
1273 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
1274 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1275 return Err(cannot_open(path));
1276 }
1277 }
1278 Ok(file)
1279}
1280
1281fn open_cleanup_identity_probe(path: &Path) -> Result<File> {
1282 let mut options = OpenOptions::new();
1283 options.read(true);
1284 #[cfg(unix)]
1285 {
1286 use std::os::unix::fs::OpenOptionsExt as _;
1287 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK);
1288 }
1289 #[cfg(windows)]
1290 {
1291 use std::os::windows::fs::OpenOptionsExt as _;
1292 use windows_sys::Win32::Storage::FileSystem::{
1293 FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
1294 };
1295 options
1296 .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
1297 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
1298 }
1299
1300 let file = options.open(path).map_err(|_| cannot_open(path))?;
1301 let metadata = file.metadata().map_err(|_| cannot_open(path))?;
1302 if !metadata.is_file() {
1303 return Err(cannot_open(path));
1304 }
1305 #[cfg(windows)]
1306 {
1307 use std::os::windows::fs::MetadataExt as _;
1308 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
1309 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1310 return Err(cannot_open(path));
1311 }
1312 }
1313 Ok(file)
1314}
1315
1316fn validate_secure_lock_file(path: &Path, file: &File) -> Result<()> {
1317 let metadata = file.metadata().map_err(|_| cannot_open(path))?;
1318 if !metadata.is_file() {
1319 return Err(cannot_open(path));
1320 }
1321
1322 #[cfg(unix)]
1323 {
1324 use std::os::unix::fs::MetadataExt as _;
1325 let effective_uid = unsafe { libc::geteuid() };
1327 if metadata.uid() != effective_uid || metadata.nlink() != 1 || metadata.mode() & 0o077 != 0
1328 {
1329 return Err(cannot_open(path));
1330 }
1331 }
1332 #[cfg(windows)]
1333 {
1334 use std::os::windows::fs::MetadataExt as _;
1335 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
1336 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1337 || metadata.number_of_links() != Some(1)
1338 {
1339 return Err(cannot_open(path));
1340 }
1341 }
1342 Ok(())
1343}
1344
1345fn validate_generation_path_identity(
1346 database_path: &Path,
1347 expected_identity: FileIdentity,
1348) -> Result<()> {
1349 #[cfg(unix)]
1350 {
1351 use std::os::unix::fs::MetadataExt as _;
1352
1353 let metadata =
1354 std::fs::symlink_metadata(database_path).map_err(|_| cannot_open(database_path))?;
1355 if !metadata.file_type().is_file() || metadata.nlink() != 1 {
1356 return Err(cannot_open(database_path));
1357 }
1358 let current = FileIdentity::from_unix_parts(metadata.dev(), metadata.ino());
1359 if current != expected_identity {
1360 return Err(cannot_open(database_path));
1361 }
1362 Ok(())
1363 }
1364
1365 #[cfg(windows)]
1366 {
1367 use std::os::windows::fs::MetadataExt as _;
1368
1369 let file = open_identity_probe(database_path)?;
1370 if file.metadata()?.number_of_links() != Some(1)
1371 || FileIdentity::from_file(&file)? != Some(expected_identity)
1372 {
1373 return Err(cannot_open(database_path));
1374 }
1375 Ok(())
1376 }
1377}
1378
1379fn write_identity_record(
1380 file: &mut File,
1381 database_path: &Path,
1382 identity: FileIdentity,
1383) -> Result<()> {
1384 let existing_len = file.metadata()?.len();
1390 if existing_len != 0 {
1391 let state = read_namespace_record_state(file, database_path, false)?;
1392 if state.current_identity != identity {
1393 return Err(cannot_open(database_path));
1394 }
1395 file.sync_data()?;
1396 return Ok(());
1397 }
1398
1399 write_fresh_identity_record(file, identity)
1400}
1401
1402fn replace_quiescent_identity_record(
1403 file: &mut File,
1404 database_path: &Path,
1405 identity: FileIdentity,
1406) -> Result<()> {
1407 let existing_len = file.metadata()?.len();
1413 validate_generation_path_identity(database_path, identity)?;
1414 if existing_len >= RECORD_BYTES as u64 {
1415 match read_namespace_record_state(file, database_path, false) {
1416 Ok(state) if state.current_identity == identity => {
1417 file.sync_data()?;
1418 return Ok(());
1419 }
1420 Ok(_) => {}
1421 Err(FrankenError::CannotOpen { .. }) if existing_len <= RECORD_BYTES as u64 => {}
1422 Err(FrankenError::CannotOpen { .. }) => return Err(cannot_open(database_path)),
1423 Err(error) => return Err(error),
1424 }
1425 }
1426 write_fresh_identity_record(file, identity)
1427}
1428
1429fn write_fresh_identity_record(file: &mut File, identity: FileIdentity) -> Result<()> {
1430 let mut record = [0_u8; RECORD_BYTES];
1431 record[..8].copy_from_slice(&RECORD_MAGIC);
1432 record[8] = RECORD_VERSION;
1433 record[9..9 + IDENTITY_BYTES].copy_from_slice(&identity.to_namespace_bytes());
1434 file.set_len(0)?;
1435 file.seek(SeekFrom::Start(0))?;
1436 file.write_all(&record)?;
1437 file.flush()?;
1438 file.sync_data()?;
1439 Ok(())
1440}
1441
1442fn read_identity_record(file: &mut File, database_path: &Path) -> Result<FileIdentity> {
1443 let state = read_namespace_record_state(file, database_path, false)?;
1444 Ok(state.current_identity)
1445}
1446
1447#[derive(Debug)]
1448struct NamespaceRecordState {
1449 current_identity: FileIdentity,
1450 last_sequence: u64,
1451 prepared_sequence: Option<u64>,
1452 prepare_offset: Option<u64>,
1453 valid_bytes: u64,
1454 interrupted_tail: Vec<u8>,
1455}
1456
1457fn read_namespace_record_state(
1458 file: &mut File,
1459 database_path: &Path,
1460 allow_interrupted_tail: bool,
1461) -> Result<NamespaceRecordState> {
1462 let file_len = file.metadata()?.len();
1463 if file_len < RECORD_BYTES as u64 {
1464 return Err(cannot_open(database_path));
1465 }
1466
1467 let mut record = [0_u8; RECORD_BYTES];
1468 file.seek(SeekFrom::Start(0))?;
1469 file.read_exact(&mut record)?;
1470 if record[..8] != RECORD_MAGIC
1471 || record[8] != RECORD_VERSION
1472 || record[9 + IDENTITY_BYTES..].iter().any(|byte| *byte != 0)
1473 {
1474 return Err(cannot_open(database_path));
1475 }
1476 let mut encoded = [0_u8; IDENTITY_BYTES];
1477 encoded.copy_from_slice(&record[9..9 + IDENTITY_BYTES]);
1478 let mut current_identity =
1479 FileIdentity::from_namespace_bytes(encoded).ok_or_else(|| cannot_open(database_path))?;
1480
1481 let remaining = file_len - RECORD_BYTES as u64;
1482 let complete_records = remaining / TRANSITION_BYTES as u64;
1483 let tail_len = usize::try_from(remaining % TRANSITION_BYTES as u64)
1484 .map_err(|_| cannot_open(database_path))?;
1485
1486 let mut last_sequence = 0_u64;
1487 let mut prepared_sequence = None;
1488 let mut prepare_offset = None;
1489 for record_index in 0..complete_records {
1490 let mut ledger_record = [0_u8; TRANSITION_BYTES];
1491 file.read_exact(&mut ledger_record)?;
1492 let record_offset = (RECORD_BYTES as u64)
1493 .checked_add(
1494 record_index
1495 .checked_mul(TRANSITION_BYTES as u64)
1496 .ok_or_else(|| cannot_open(database_path))?,
1497 )
1498 .ok_or_else(|| cannot_open(database_path))?;
1499
1500 if let Some((sequence, old_identity, new_identity)) =
1501 decode_transition_record(&ledger_record)
1502 {
1503 let expected_sequence = prepared_sequence.ok_or_else(|| cannot_open(database_path))?;
1504 if sequence != expected_sequence || old_identity != current_identity {
1505 return Err(cannot_open(database_path));
1506 }
1507 last_sequence = sequence;
1508 current_identity = new_identity;
1509 prepared_sequence = None;
1510 prepare_offset = None;
1511 continue;
1512 }
1513
1514 if let Some((sequence, identity)) = decode_prepare_record(&ledger_record) {
1515 if prepared_sequence.is_some()
1516 || sequence
1517 != last_sequence
1518 .checked_add(1)
1519 .ok_or_else(|| cannot_open(database_path))?
1520 || identity != current_identity
1521 {
1522 return Err(cannot_open(database_path));
1523 }
1524 prepared_sequence = Some(sequence);
1525 prepare_offset = Some(record_offset);
1526 continue;
1527 }
1528
1529 if let Some((sequence, identity)) = decode_finish_record(&ledger_record) {
1530 if prepared_sequence != Some(sequence) || identity != current_identity {
1531 return Err(cannot_open(database_path));
1532 }
1533 last_sequence = sequence;
1534 prepared_sequence = None;
1535 prepare_offset = None;
1536 continue;
1537 }
1538
1539 return Err(cannot_open(database_path));
1540 }
1541
1542 if !allow_interrupted_tail && (tail_len != 0 || prepared_sequence.is_some()) {
1543 return Err(cannot_open(database_path));
1544 }
1545
1546 if prepared_sequence.is_some() && prepare_offset.is_none() {
1547 return Err(cannot_open(database_path));
1548 }
1549
1550 let mut interrupted_tail = vec![0_u8; tail_len];
1551 file.read_exact(&mut interrupted_tail)?;
1552 let valid_bytes = (RECORD_BYTES as u64)
1553 .checked_add(
1554 complete_records
1555 .checked_mul(TRANSITION_BYTES as u64)
1556 .ok_or_else(|| cannot_open(database_path))?,
1557 )
1558 .ok_or_else(|| cannot_open(database_path))?;
1559 Ok(NamespaceRecordState {
1560 current_identity,
1561 last_sequence,
1562 prepared_sequence,
1563 prepare_offset,
1564 valid_bytes,
1565 interrupted_tail,
1566 })
1567}
1568
1569fn encode_transition_record(
1570 sequence: u64,
1571 old_identity: FileIdentity,
1572 new_identity: FileIdentity,
1573) -> [u8; TRANSITION_BYTES] {
1574 let mut record = [0_u8; TRANSITION_BYTES];
1575 record[..8].copy_from_slice(&TRANSITION_MAGIC);
1576 record[8] = TRANSITION_VERSION;
1577 record[16..24].copy_from_slice(&sequence.to_be_bytes());
1578 record[24..24 + IDENTITY_BYTES].copy_from_slice(&old_identity.to_namespace_bytes());
1579 record[49..49 + IDENTITY_BYTES].copy_from_slice(&new_identity.to_namespace_bytes());
1580 let checksum = transition_checksum(&record[..TRANSITION_CHECKSUM_OFFSET]);
1581 record[TRANSITION_CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_be_bytes());
1582 record
1583}
1584
1585fn encode_prepare_record(sequence: u64, current_identity: FileIdentity) -> [u8; PREPARE_BYTES] {
1586 let mut record = [0_u8; PREPARE_BYTES];
1587 record[..8].copy_from_slice(&PREPARE_MAGIC);
1588 record[8] = PREPARE_VERSION;
1589 record[16..24].copy_from_slice(&sequence.to_be_bytes());
1590 record[24..24 + IDENTITY_BYTES].copy_from_slice(¤t_identity.to_namespace_bytes());
1591 let checksum = transition_checksum(&record[..PREPARE_CHECKSUM_OFFSET]);
1592 record[PREPARE_CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_be_bytes());
1593 record
1594}
1595
1596fn decode_prepare_record(record: &[u8; PREPARE_BYTES]) -> Option<(u64, FileIdentity)> {
1597 decode_identity_ledger_record(
1598 record,
1599 PREPARE_MAGIC,
1600 PREPARE_VERSION,
1601 PREPARE_CHECKSUM_OFFSET,
1602 )
1603}
1604
1605fn encode_finish_record(sequence: u64, current_identity: FileIdentity) -> [u8; FINISH_BYTES] {
1606 let mut record = [0_u8; FINISH_BYTES];
1607 record[..8].copy_from_slice(&FINISH_MAGIC);
1608 record[8] = FINISH_VERSION;
1609 record[16..24].copy_from_slice(&sequence.to_be_bytes());
1610 record[24..24 + IDENTITY_BYTES].copy_from_slice(¤t_identity.to_namespace_bytes());
1611 let checksum = transition_checksum(&record[..FINISH_CHECKSUM_OFFSET]);
1612 record[FINISH_CHECKSUM_OFFSET..].copy_from_slice(&checksum.to_be_bytes());
1613 record
1614}
1615
1616fn decode_finish_record(record: &[u8; FINISH_BYTES]) -> Option<(u64, FileIdentity)> {
1617 decode_identity_ledger_record(record, FINISH_MAGIC, FINISH_VERSION, FINISH_CHECKSUM_OFFSET)
1618}
1619
1620fn decode_identity_ledger_record(
1621 record: &[u8; TRANSITION_BYTES],
1622 magic: [u8; 8],
1623 version: u8,
1624 checksum_offset: usize,
1625) -> Option<(u64, FileIdentity)> {
1626 if record[..8] != magic
1627 || record[8] != version
1628 || record[9..16].iter().any(|byte| *byte != 0)
1629 || record[49..checksum_offset].iter().any(|byte| *byte != 0)
1630 {
1631 return None;
1632 }
1633 let mut checksum_bytes = [0_u8; 8];
1634 checksum_bytes.copy_from_slice(&record[checksum_offset..]);
1635 if u64::from_be_bytes(checksum_bytes) != transition_checksum(&record[..checksum_offset]) {
1636 return None;
1637 }
1638 let mut sequence_bytes = [0_u8; 8];
1639 sequence_bytes.copy_from_slice(&record[16..24]);
1640 let mut identity_bytes = [0_u8; IDENTITY_BYTES];
1641 identity_bytes.copy_from_slice(&record[24..24 + IDENTITY_BYTES]);
1642 Some((
1643 u64::from_be_bytes(sequence_bytes),
1644 FileIdentity::from_namespace_bytes(identity_bytes)?,
1645 ))
1646}
1647
1648fn decode_transition_record(
1649 record: &[u8; TRANSITION_BYTES],
1650) -> Option<(u64, FileIdentity, FileIdentity)> {
1651 if record[..8] != TRANSITION_MAGIC
1652 || record[8] != TRANSITION_VERSION
1653 || record[9..16].iter().any(|byte| *byte != 0)
1654 || record[74..TRANSITION_CHECKSUM_OFFSET]
1655 .iter()
1656 .any(|byte| *byte != 0)
1657 {
1658 return None;
1659 }
1660 let mut checksum_bytes = [0_u8; 8];
1661 checksum_bytes.copy_from_slice(&record[TRANSITION_CHECKSUM_OFFSET..]);
1662 if u64::from_be_bytes(checksum_bytes)
1663 != transition_checksum(&record[..TRANSITION_CHECKSUM_OFFSET])
1664 {
1665 return None;
1666 }
1667
1668 let mut sequence_bytes = [0_u8; 8];
1669 sequence_bytes.copy_from_slice(&record[16..24]);
1670 let mut old_encoded = [0_u8; IDENTITY_BYTES];
1671 old_encoded.copy_from_slice(&record[24..24 + IDENTITY_BYTES]);
1672 let mut new_encoded = [0_u8; IDENTITY_BYTES];
1673 new_encoded.copy_from_slice(&record[49..49 + IDENTITY_BYTES]);
1674 let old_identity = FileIdentity::from_namespace_bytes(old_encoded)?;
1675 let new_identity = FileIdentity::from_namespace_bytes(new_encoded)?;
1676 if old_identity == new_identity {
1677 return None;
1678 }
1679 Some((
1680 u64::from_be_bytes(sequence_bytes),
1681 old_identity,
1682 new_identity,
1683 ))
1684}
1685
1686fn transition_checksum(bytes: &[u8]) -> u64 {
1687 let mut hash = FNV_OFFSET_BASIS;
1688 for byte in bytes {
1689 hash ^= u64::from(*byte);
1690 hash = hash.wrapping_mul(FNV_PRIME);
1691 }
1692 hash
1693}
1694
1695fn try_lock(file: &File, mode: FileLockMode) -> Result<()> {
1696 AdvisoryFileLock::try_lock(file, mode).map_err(lock_error)
1697}
1698
1699fn lock_error(error: FileLockError) -> FrankenError {
1700 match error {
1701 FileLockError::AlreadyLocked => FrankenError::Busy,
1702 FileLockError::Io(error) => FrankenError::Io(error),
1703 }
1704}
1705
1706#[cfg(unix)]
1707fn downgrade_to_shared(file: &File) -> Result<()> {
1708 try_lock(file, FileLockMode::Shared)
1711}
1712
1713#[cfg(windows)]
1714fn downgrade_to_shared(file: &File) -> Result<()> {
1715 AdvisoryFileLock::unlock(file).map_err(lock_error)?;
1718 try_lock(file, FileLockMode::Shared)
1719}
1720
1721fn release_gate(gate: &File) -> Result<()> {
1722 AdvisoryFileLock::unlock(gate).map_err(lock_error)
1723}
1724
1725fn release_namespace_locks(gate: &File, use_file: &File) {
1726 let _ = AdvisoryFileLock::unlock(use_file);
1727 let _ = AdvisoryFileLock::unlock(gate);
1728}
1729
1730fn cannot_open(path: &Path) -> FrankenError {
1731 FrankenError::CannotOpen {
1732 path: path.to_owned(),
1733 }
1734}
1735
1736#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1741pub enum WindowsLockSidecarPolicy {
1742 RejectAll,
1744 AllowExpected,
1746}
1747
1748pub fn validate_reserved_database_artifacts(
1751 database_path: &Path,
1752 windows_lock_sidecars: WindowsLockSidecarPolicy,
1753) -> Result<()> {
1754 validate_stable_path(database_path)?;
1755 for suffix in ["-journal", "-wal", "-wal-fec", "-shm"] {
1756 reject_existing_entry(database_path, &sidecar_path(database_path, suffix))?;
1757 }
1758
1759 #[cfg(windows)]
1760 if windows_lock_sidecars == WindowsLockSidecarPolicy::RejectAll {
1761 for suffix in ["-lock-shared", "-lock-reserved", "-lock-pending"] {
1762 reject_existing_entry(database_path, &sidecar_path(database_path, suffix))?;
1763 }
1764 }
1765 #[cfg(not(windows))]
1766 let _ = windows_lock_sidecars;
1767
1768 let wal_fec_temp = sidecar_path(database_path, "-wal-fec").with_extension("wal-fec.tmp");
1769 reject_existing_entry(database_path, &wal_fec_temp)?;
1770
1771 let parent = database_path
1772 .parent()
1773 .ok_or_else(|| cannot_open(database_path))?;
1774 let db_name = database_path
1775 .file_name()
1776 .ok_or_else(|| cannot_open(database_path))?
1777 .to_string_lossy();
1778 let segment_prefix = format!("{db_name}-wal-seg-");
1779 for entry in std::fs::read_dir(parent)? {
1780 let entry = entry?;
1781 if entry
1782 .file_name()
1783 .to_string_lossy()
1784 .starts_with(&segment_prefix)
1785 {
1786 return Err(cannot_open(database_path));
1787 }
1788 }
1789 Ok(())
1790}
1791
1792fn reject_existing_entry(database_path: &Path, candidate: &Path) -> Result<()> {
1793 match std::fs::symlink_metadata(candidate) {
1794 Ok(_) => Err(cannot_open(database_path)),
1795 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1796 Err(error) => Err(error.into()),
1797 }
1798}
1799
1800#[cfg(test)]
1801mod tests {
1802 use std::fs::{self, FileTimes};
1803 use std::process::Command;
1804 use std::time::{Duration, UNIX_EPOCH};
1805
1806 use tempfile::tempdir;
1807
1808 use super::*;
1809
1810 fn create_database(path: &Path, bytes: &[u8]) -> FileIdentity {
1811 fs::write(path, bytes).expect("create test database");
1812 let file = File::open(path).expect("open test database");
1813 FileIdentity::from_file(&file)
1814 .expect("query test database identity")
1815 .expect("native filesystem identity")
1816 }
1817
1818 fn publish_generation(database: &Path, identity: FileIdentity) {
1819 let binding = PendingNamespaceOpen::begin(database, NamespaceOpenIntent::Shared)
1820 .expect("admit generation")
1821 .bind(identity)
1822 .expect("bind generation");
1823 binding.finish_bootstrap().expect("publish generation");
1824 }
1825
1826 #[test]
1827 fn new_generation_stays_exclusive_until_bootstrap_finishes() {
1828 let dir = tempdir().expect("tempdir");
1829 let database = dir.path().join("bootstrap.db");
1830 let identity = create_database(&database, b"");
1831
1832 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
1833 .expect("admit new generation");
1834 assert_eq!(pending.expected_identity(), None);
1835 let binding = pending.bind(identity).expect("bind new generation");
1836 assert!(binding.bootstrap_is_exclusive());
1837 assert!(matches!(
1838 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
1839 Err(FrankenError::Busy)
1840 ));
1841
1842 assert!(
1843 binding
1844 .finish_bootstrap_with_gate_release(|_| {
1845 Err(FrankenError::internal(
1846 "injected namespace gate release failure",
1847 ))
1848 })
1849 .is_err()
1850 );
1851 assert!(
1852 matches!(
1853 *binding.lease.lock().expect("inspect bootstrap lease"),
1854 BindingLease::BootstrapUseShared { .. }
1855 ),
1856 "a gate-release error after downgrade must preserve the exact intermediate lock state"
1857 );
1858 assert!(binding.bootstrap_is_exclusive());
1859 assert!(matches!(
1860 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
1861 Err(FrankenError::Busy)
1862 ));
1863
1864 binding.finish_bootstrap().expect("finish bootstrap");
1865 assert!(!binding.bootstrap_is_exclusive());
1866 binding
1867 .finish_bootstrap()
1868 .expect("finishing bootstrap twice is harmless");
1869
1870 let join = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
1871 .expect("join live generation");
1872 assert_eq!(join.expected_identity(), Some(identity));
1873 let peer = join.bind(identity).expect("bind peer");
1874 assert!(!peer.bootstrap_is_exclusive());
1875 }
1876
1877 #[test]
1878 fn binding_last_arc_drop_releases_shared_lease_cross_process() {
1879 const CHILD_DATABASE: &str = "FSQLITE_NS_BINDING_DROP_CHILD_DATABASE";
1880 const CHILD_EXPECT_TRANSITION: &str = "FSQLITE_NS_BINDING_DROP_CHILD_EXPECT_TRANSITION";
1881
1882 if let Some(database) = std::env::var_os(CHILD_DATABASE) {
1883 let database = PathBuf::from(database);
1884 let identity =
1885 FileIdentity::from_file(&File::open(&database).expect("open child generation"))
1886 .expect("query child generation identity")
1887 .expect("native child generation identity");
1888 let transition = begin_database_namespace_generation_transition(&database, identity);
1889 if std::env::var_os(CHILD_EXPECT_TRANSITION).is_some() {
1890 transition.expect("last binding Arc drop releases shared lease cross-process");
1891 } else {
1892 assert!(matches!(transition, Err(FrankenError::Busy)));
1893 }
1894 return;
1895 }
1896
1897 let dir = tempdir().expect("tempdir");
1898 let database = dir.path().join("binding-drop.db");
1899 let identity = create_database(&database, b"generation");
1900 let binding = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
1901 .expect("admit generation")
1902 .bind(identity)
1903 .expect("bind generation");
1904 binding.finish_bootstrap().expect("publish generation");
1905 let final_arc = Arc::clone(&binding);
1906 drop(binding);
1907
1908 let run_child = |expect_transition: bool| {
1909 let mut command =
1910 Command::new(std::env::current_exe().expect("resolve test executable"));
1911 command
1912 .arg("--exact")
1913 .arg("namespace::tests::binding_last_arc_drop_releases_shared_lease_cross_process")
1914 .arg("--nocapture")
1915 .env(CHILD_DATABASE, &database);
1916 if expect_transition {
1917 command.env(CHILD_EXPECT_TRANSITION, "1");
1918 }
1919 let output = command.output().expect("run binding-drop child");
1920 assert!(
1921 output.status.success(),
1922 "child failed:\nstdout:\n{}\nstderr:\n{}",
1923 String::from_utf8_lossy(&output.stdout),
1924 String::from_utf8_lossy(&output.stderr)
1925 );
1926 };
1927
1928 run_child(false);
1929 drop(final_arc);
1930 run_child(true);
1931 }
1932
1933 #[test]
1934 fn readonly_existing_generation_preserves_namespace_records() {
1935 let dir = tempdir().expect("tempdir");
1936 let database = dir.path().join("readonly-existing.db");
1937 let identity = create_database(&database, b"existing generation");
1938 let writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
1939 .expect("admit generation")
1940 .bind(identity)
1941 .expect("bind generation");
1942 writer.finish_bootstrap().expect("publish generation");
1943 drop(writer);
1944
1945 let gate_path = sidecar_path(&database, GATE_SUFFIX);
1946 let use_path = sidecar_path(&database, USE_SUFFIX);
1947 let sentinel_modified = UNIX_EPOCH + Duration::from_hours(262_968);
1948 File::options()
1949 .write(true)
1950 .open(&use_path)
1951 .expect("open identity record for timestamp sentinel")
1952 .set_times(FileTimes::new().set_modified(sentinel_modified))
1953 .expect("set identity-record timestamp sentinel");
1954 let before_gate = fs::read(&gate_path).expect("snapshot gate record");
1955 let before_use = fs::read(&use_path).expect("snapshot identity record");
1956 let before_use_modified = fs::metadata(&use_path)
1957 .expect("identity record metadata")
1958 .modified()
1959 .expect("identity record modification time");
1960
1961 let failed_pending =
1962 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
1963 .expect("begin injected gate-release failure");
1964 let retained_gate: std::cell::RefCell<Option<File>> = std::cell::RefCell::new(None);
1965 assert!(
1966 failed_pending
1967 .bind_with_gate_release(identity, |gate| {
1968 #[cfg(unix)]
1969 retained_gate.replace(Some(gate.try_clone()?));
1970 #[cfg(not(unix))]
1971 let _ = &gate;
1972 Err(FrankenError::internal(
1973 "injected namespace gate release failure",
1974 ))
1975 })
1976 .is_err()
1977 );
1978
1979 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
1980 .expect("explicit cleanup releases the injected failed gate lease");
1981 assert_eq!(pending.expected_identity(), Some(identity));
1982 let reader = pending.bind(identity).expect("bind read-only generation");
1983 reader
1984 .validate_path_identity()
1985 .expect("read-only generation remains bound");
1986 reader
1987 .finish_bootstrap()
1988 .expect("shared read-only binding has no bootstrap transition");
1989 drop(reader);
1990 drop(retained_gate);
1991
1992 assert_eq!(fs::read(&gate_path).expect("read gate record"), before_gate);
1993 assert_eq!(
1994 fs::read(&use_path).expect("read identity record"),
1995 before_use
1996 );
1997 assert_eq!(
1998 fs::metadata(&use_path)
1999 .expect("identity record metadata")
2000 .modified()
2001 .expect("identity record modification time"),
2002 before_use_modified,
2003 "read-only admission must not rewrite an unchanged identity record"
2004 );
2005 }
2006
2007 #[test]
2008 fn readonly_admission_of_never_admitted_database_creates_no_sidecars() {
2009 let dir = tempdir().expect("tempdir");
2013 let database = dir.path().join("never-admitted.db");
2014 let identity = create_database(&database, b"stock-like database");
2015 let gate_path = sidecar_path(&database, GATE_SUFFIX);
2016 let use_path = sidecar_path(&database, USE_SUFFIX);
2017 assert!(!gate_path.exists() && !use_path.exists());
2018
2019 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2020 .expect("sidecar-less read-only admission must succeed");
2021 assert_eq!(pending.expected_identity(), None);
2022 assert!(
2023 !pending
2024 .has_quiescent_record_bytes()
2025 .expect("sidecar-less admission has no record")
2026 );
2027 let binding = pending
2028 .bind(identity)
2029 .expect("bind sidecar-less read-only admission");
2030 assert!(!binding.bootstrap_is_exclusive());
2031 binding
2032 .finish_bootstrap()
2033 .expect("sidecar-less binding has no bootstrap transition");
2034 drop(binding);
2035
2036 assert!(
2037 !gate_path.exists(),
2038 "read-only admission must not create the gate sidecar"
2039 );
2040 assert!(
2041 !use_path.exists(),
2042 "read-only admission must not create the identity sidecar"
2043 );
2044
2045 let writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2047 .expect("subsequent shared admission")
2048 .bind(identity)
2049 .expect("bind shared generation");
2050 writer.finish_bootstrap().expect("publish generation");
2051 drop(writer);
2052 assert!(gate_path.exists() && use_path.exists());
2053 }
2054
2055 #[test]
2056 fn readonly_admission_blocks_generation_transition_then_holds_use_lease() {
2057 let dir = tempdir().expect("tempdir");
2058 let database = dir.path().join("readonly-transition.db");
2059 let displaced = dir.path().join("readonly-transition.displaced.db");
2060 let original_identity = create_database(&database, b"original generation");
2061 let writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2062 .expect("admit generation")
2063 .bind(original_identity)
2064 .expect("bind generation");
2065 writer.finish_bootstrap().expect("publish generation");
2066 drop(writer);
2067
2068 let pending_reader =
2069 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2070 .expect("begin read-only admission");
2071 assert!(matches!(
2072 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2073 Err(FrankenError::Busy)
2074 ));
2075 let reader = pending_reader
2076 .bind(original_identity)
2077 .expect("bind read-only generation");
2078
2079 fs::rename(&database, &displaced).expect("displace original generation");
2080 let replacement_identity = create_database(&database, b"replacement generation");
2081 let stale_writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2082 .expect("writer joins the reader-held generation");
2083 assert_eq!(stale_writer.expected_identity(), Some(original_identity));
2084 assert!(matches!(
2085 stale_writer.bind(replacement_identity),
2086 Err(FrankenError::CannotOpen { .. })
2087 ));
2088
2089 drop(reader);
2090 assert!(matches!(
2091 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2092 .expect("admission reaches exact identity validation")
2093 .bind(replacement_identity),
2094 Err(FrankenError::CannotOpen { .. })
2095 ));
2096
2097 let replacement_staging = dir.path().join("readonly-transition.replacement.db");
2098 fs::rename(&database, &replacement_staging).expect("stage replacement");
2099 fs::rename(&displaced, &database).expect("restore old generation before guard");
2100 let mut transition =
2101 begin_database_namespace_generation_transition(&database, original_identity)
2102 .expect("guard exact old generation");
2103 fs::rename(&database, &displaced).expect("quarantine old generation under guard");
2104 fs::rename(&replacement_staging, &database).expect("activate replacement under guard");
2105 assert_eq!(
2106 transition
2107 .publish_replacement(replacement_identity)
2108 .expect("publish exact replacement"),
2109 NamespaceGenerationTransitionOutcome::Published
2110 );
2111 transition.finish().expect("finish replacement transition");
2112 }
2113
2114 #[test]
2115 fn readonly_existing_generation_admits_missing_records_without_creating_them() {
2116 let dir = tempdir().expect("tempdir");
2121 let database = dir.path().join("readonly-missing-records.db");
2122 create_database(&database, b"external database");
2123 let entries_before = fs::read_dir(dir.path())
2124 .expect("list pristine namespace")
2125 .map(|entry| entry.expect("namespace entry").file_name())
2126 .collect::<std::collections::BTreeSet<_>>();
2127
2128 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2129 .expect("missing records admit sidecar-less (GH#140)");
2130 assert_eq!(pending.expected_identity(), None);
2131 drop(pending);
2132
2133 let entries_after = fs::read_dir(dir.path())
2134 .expect("list namespace after sidecar-less admission")
2135 .map(|entry| entry.expect("namespace entry").file_name())
2136 .collect::<std::collections::BTreeSet<_>>();
2137 assert_eq!(entries_after, entries_before);
2138 assert!(!sidecar_path(&database, GATE_SUFFIX).exists());
2139 assert!(!sidecar_path(&database, USE_SUFFIX).exists());
2140 }
2141
2142 #[test]
2143 fn readonly_existing_generation_refuses_corrupt_record_without_repairing_it() {
2144 let dir = tempdir().expect("tempdir");
2145 let database = dir.path().join("readonly-corrupt-record.db");
2146 let identity = create_database(&database, b"existing generation");
2147 let writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2148 .expect("admit generation")
2149 .bind(identity)
2150 .expect("bind generation");
2151 writer.finish_bootstrap().expect("publish generation");
2152 drop(writer);
2153
2154 let use_path = sidecar_path(&database, USE_SUFFIX);
2155 fs::write(&use_path, b"corrupt identity record").expect("corrupt identity record");
2156 let before = fs::read(&use_path).expect("snapshot corrupt identity record");
2157
2158 assert!(matches!(
2159 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting),
2160 Err(FrankenError::CannotOpen { .. })
2161 ));
2162 assert_eq!(
2163 fs::read(&use_path).expect("read refused identity record"),
2164 before,
2165 "read-only admission must not repair or rewrite a corrupt record"
2166 );
2167 }
2168
2169 #[test]
2170 fn readonly_existing_generation_refuses_main_identity_drift_without_rebinding() {
2171 let dir = tempdir().expect("tempdir");
2172 let database = dir.path().join("readonly-identity-drift.db");
2173 let displaced = dir.path().join("readonly-identity-drift.displaced.db");
2174 let original_identity = create_database(&database, b"original generation");
2175 let writer = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2176 .expect("admit generation")
2177 .bind(original_identity)
2178 .expect("bind generation");
2179 writer.finish_bootstrap().expect("publish generation");
2180 drop(writer);
2181
2182 fs::rename(&database, &displaced).expect("displace original generation");
2183 let replacement_identity = create_database(&database, b"replacement generation");
2184 let use_path = sidecar_path(&database, USE_SUFFIX);
2185 let record_before = fs::read(&use_path).expect("snapshot original identity record");
2186
2187 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2188 .expect("read original recorded identity");
2189 assert_eq!(pending.expected_identity(), Some(original_identity));
2190 assert!(matches!(
2191 pending.bind(replacement_identity),
2192 Err(FrankenError::CannotOpen { .. })
2193 ));
2194 assert_eq!(
2195 fs::read(&use_path).expect("read refused identity record"),
2196 record_before,
2197 "read-only identity refusal must not rebind the record to a replacement file"
2198 );
2199 }
2200
2201 #[test]
2202 fn quiescent_rebind_repairs_stale_record_but_live_join_fails_closed() {
2203 let dir = tempdir().expect("tempdir");
2204 let database = dir.path().join("quiescent-rebind.db");
2205 let displaced = dir.path().join("quiescent-rebind.displaced.db");
2206 let original_identity = create_database(&database, b"original generation");
2207 let original = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2208 .expect("admit original generation")
2209 .bind(original_identity)
2210 .expect("bind original generation");
2211 original
2212 .finish_bootstrap()
2213 .expect("publish original generation");
2214 drop(original);
2215
2216 fs::rename(&database, &displaced).expect("displace original generation");
2217 let replacement_identity = create_database(&database, b"replacement generation");
2218 let use_path = sidecar_path(&database, USE_SUFFIX);
2219 let stale_record = fs::read(&use_path).expect("snapshot stale identity record");
2220
2221 let ordinary = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2222 .expect("obtain quiescent namespace exclusively");
2223 assert_eq!(ordinary.expected_identity(), None);
2224 assert!(ordinary.has_quiescent_record_bytes().unwrap());
2225 assert!(matches!(
2226 ordinary.bind(replacement_identity),
2227 Err(FrankenError::CannotOpen { .. })
2228 ));
2229 assert_eq!(
2230 fs::read(&use_path).expect("read preserved stale record"),
2231 stale_record,
2232 "ordinary admission must retain fail-closed replacement semantics"
2233 );
2234
2235 let wrong_generation = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2236 .expect("reacquire quiescent namespace for identity check");
2237 assert!(matches!(
2238 wrong_generation.bind_replacing_quiescent_record(original_identity),
2239 Err(FrankenError::CannotOpen { .. })
2240 ));
2241 assert_eq!(
2242 fs::read(&use_path).expect("read record after rejected identity"),
2243 stale_record,
2244 "repair must validate the current pathname identity before rewriting"
2245 );
2246
2247 let mut transition_bearing_record = stale_record.clone();
2248 transition_bearing_record.push(0x7f);
2249 fs::write(&use_path, &transition_bearing_record)
2250 .expect("append simulated transition evidence");
2251 let transition_bearing =
2252 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2253 .expect("reacquire namespace with transition evidence");
2254 assert!(matches!(
2255 transition_bearing.bind_replacing_quiescent_record(replacement_identity),
2256 Err(FrankenError::CannotOpen { .. })
2257 ));
2258 assert_eq!(
2259 fs::read(&use_path).expect("read preserved transition evidence"),
2260 transition_bearing_record,
2261 "repair must never discard namespace transition evidence"
2262 );
2263 fs::write(&use_path, &stale_record).expect("restore plain stale admission record");
2264
2265 let replacement = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2266 .expect("reacquire quiescent namespace exclusively")
2267 .bind_replacing_quiescent_record(replacement_identity)
2268 .expect("replace copied machine-local namespace record");
2269 replacement
2270 .finish_bootstrap()
2271 .expect("publish replacement namespace generation");
2272
2273 let joined = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2274 .expect("join live replacement generation");
2275 assert_eq!(joined.expected_identity(), Some(replacement_identity));
2276 assert!(!joined.has_quiescent_record_bytes().unwrap());
2277 let replacement_record = fs::read(&use_path).expect("snapshot replacement record");
2278 assert!(matches!(
2279 joined.bind_replacing_quiescent_record(replacement_identity),
2280 Err(FrankenError::CannotOpen { .. })
2281 ));
2282 assert_eq!(
2283 fs::read(&use_path).expect("read live replacement record"),
2284 replacement_record,
2285 "a live joined generation must never enter the repair path"
2286 );
2287 }
2288
2289 #[test]
2290 fn quiescent_rebind_collapses_completed_copied_transition_history() {
2291 let dir = tempdir().expect("tempdir");
2292 let database = dir.path().join("completed-ledger-source.db");
2293 let displaced = dir.path().join("completed-ledger-source.displaced.db");
2294 let original_identity = create_database(&database, b"original generation");
2295 publish_generation(&database, original_identity);
2296
2297 let mut transition =
2298 begin_database_namespace_generation_transition(&database, original_identity)
2299 .expect("prepare generation transition");
2300 fs::rename(&database, &displaced).expect("displace original generation");
2301 let replacement_identity = create_database(&database, b"replacement generation");
2302 transition
2303 .publish_replacement(replacement_identity)
2304 .expect("publish replacement generation");
2305 transition.finish().expect("finish replacement generation");
2306
2307 let source_use_path = sidecar_path(&database, USE_SUFFIX);
2308 let terminal_ledger_len = fs::metadata(&source_use_path).unwrap().len();
2309 assert!(
2310 terminal_ledger_len > RECORD_BYTES as u64,
2311 "completed transition must leave durable history for this keeper"
2312 );
2313 let reopened = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2314 .expect("admit current terminal namespace")
2315 .bind_replacing_quiescent_record(replacement_identity)
2316 .expect("retain current terminal namespace history");
2317 reopened
2318 .finish_bootstrap()
2319 .expect("publish current namespace");
2320 drop(reopened);
2321 assert_eq!(
2322 fs::metadata(&source_use_path).unwrap().len(),
2323 terminal_ledger_len,
2324 "an already-current terminal ledger must not be rewritten"
2325 );
2326 let copied = dir.path().join("completed-ledger-copy.db");
2327 fs::copy(&database, &copied).expect("copy replacement main database");
2328 for suffix in [GATE_SUFFIX, USE_SUFFIX] {
2329 fs::copy(
2330 sidecar_path(&database, suffix),
2331 sidecar_path(&copied, suffix),
2332 )
2333 .expect("copy namespace sidecar");
2334 }
2335 let copied_file = File::open(&copied).expect("open copied main database");
2336 let copied_identity = FileIdentity::from_file(&copied_file)
2337 .expect("query copied main identity")
2338 .expect("native copied main identity");
2339
2340 let rebound = PendingNamespaceOpen::begin(&copied, NamespaceOpenIntent::Shared)
2341 .expect("admit copied completed namespace")
2342 .bind_replacing_quiescent_record(copied_identity)
2343 .expect("collapse terminal copied transition history");
2344 rebound
2345 .finish_bootstrap()
2346 .expect("publish copied generation");
2347 assert_eq!(
2348 fs::metadata(sidecar_path(&copied, USE_SUFFIX))
2349 .unwrap()
2350 .len(),
2351 RECORD_BYTES as u64,
2352 "copied terminal history should collapse to one current base record"
2353 );
2354 }
2355
2356 #[test]
2357 fn live_generation_rejects_replacement_identity_then_requires_guarded_transition() {
2358 let dir = tempdir().expect("tempdir");
2359 let database = dir.path().join("replace.db");
2360 let displaced = dir.path().join("replace.displaced.db");
2361 let first_identity = create_database(&database, b"first");
2362 let first = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2363 .expect("admit first")
2364 .bind(first_identity)
2365 .expect("bind first");
2366 first.finish_bootstrap().expect("finish first bootstrap");
2367
2368 fs::rename(&database, &displaced).expect("displace main path");
2369 let replacement_identity = create_database(&database, b"replacement");
2370 assert!(matches!(
2371 first.validate_path_identity(),
2372 Err(FrankenError::CannotOpen { .. })
2373 ));
2374
2375 let stale_join = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2376 .expect("admission reads live record");
2377 assert_eq!(stale_join.expected_identity(), Some(first_identity));
2378 assert!(matches!(
2379 stale_join.bind(replacement_identity),
2380 Err(FrankenError::CannotOpen { .. })
2381 ));
2382
2383 drop(first);
2384 let replacement_staging = dir.path().join("replace.replacement.db");
2385 fs::rename(&database, &replacement_staging).expect("stage replacement");
2386 fs::rename(&displaced, &database).expect("restore first generation");
2387 let mut transition =
2388 begin_database_namespace_generation_transition(&database, first_identity)
2389 .expect("guard first generation");
2390 fs::rename(&database, &displaced).expect("quarantine first generation under guard");
2391 fs::rename(&replacement_staging, &database).expect("activate replacement under guard");
2392 transition
2393 .publish_replacement(replacement_identity)
2394 .expect("publish replacement generation");
2395 transition.finish().expect("finish guarded transition");
2396
2397 let replacement = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2398 .expect("admit published replacement")
2399 .bind(replacement_identity)
2400 .expect("bind published replacement");
2401 replacement
2402 .finish_bootstrap()
2403 .expect("finish replacement bootstrap");
2404 replacement
2405 .validate_path_identity()
2406 .expect("replacement remains bound");
2407 }
2408
2409 #[test]
2410 fn guarded_generation_transition_reopens_replacement_and_supports_exact_rollback() {
2411 let dir = tempdir().expect("tempdir");
2412 let database = dir.path().join("recover.db");
2413 let quarantine = dir.path().join("recover.db.corrupt");
2414 let replacement_staging = dir.path().join("recover.db.replacement");
2415 let old_identity = create_database(&database, b"corrupt generation");
2416 publish_generation(&database, old_identity);
2417 let replacement_identity =
2418 create_database(&replacement_staging, b"reconstructed generation");
2419
2420 let mut transition =
2421 begin_database_namespace_generation_transition(&database, old_identity)
2422 .expect("guard old namespace generation");
2423 fs::rename(&database, &quarantine).expect("quarantine old generation");
2424 fs::rename(&replacement_staging, &database).expect("activate replacement");
2425
2426 assert_eq!(
2427 transition
2428 .publish_replacement(replacement_identity)
2429 .expect("publish replacement namespace generation"),
2430 NamespaceGenerationTransitionOutcome::Published
2431 );
2432 assert_eq!(
2433 transition
2434 .publish_replacement(replacement_identity)
2435 .expect("classify same-guard exact retry"),
2436 NamespaceGenerationTransitionOutcome::AlreadyPublished
2437 );
2438 assert!(matches!(
2439 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2440 Err(FrankenError::Busy)
2441 ));
2442
2443 fs::rename(&database, &replacement_staging).expect("stage replacement for rollback");
2444 fs::rename(&quarantine, &database).expect("restore old generation under guard");
2445 assert_eq!(
2446 transition
2447 .publish_replacement(old_identity)
2448 .expect("publish exact rollback"),
2449 NamespaceGenerationTransitionOutcome::Published
2450 );
2451 assert_eq!(transition.current_identity(), old_identity);
2452
2453 fs::rename(&database, &quarantine).expect("requarantine old generation");
2454 fs::rename(&replacement_staging, &database).expect("reactivate replacement");
2455 assert_eq!(
2456 transition
2457 .publish_replacement(replacement_identity)
2458 .expect("republish replacement after rollback"),
2459 NamespaceGenerationTransitionOutcome::Published
2460 );
2461 assert_eq!(
2462 transition.finish().expect("finish replacement publication"),
2463 replacement_identity
2464 );
2465
2466 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2467 .expect("read-only reopen of replacement");
2468 assert_eq!(pending.expected_identity(), Some(replacement_identity));
2469 let replacement = pending
2470 .bind(replacement_identity)
2471 .expect("bind replacement identity");
2472 replacement
2473 .validate_path_identity()
2474 .expect("replacement path remains exact");
2475 drop(replacement);
2476
2477 let ordinary = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2478 .expect("ordinary reopen after transition");
2479 assert_eq!(ordinary.expected_identity(), None);
2480 ordinary
2481 .bind(replacement_identity)
2482 .expect("bind ordinary replacement reopen")
2483 .finish_bootstrap()
2484 .expect("publish replacement reopen");
2485 assert_eq!(
2486 fs::read(&quarantine).expect("read quarantined generation"),
2487 b"corrupt generation"
2488 );
2489 assert!(sidecar_path(&database, GATE_SUFFIX).exists());
2490 assert!(sidecar_path(&database, USE_SUFFIX).exists());
2491 }
2492
2493 #[test]
2494 fn generation_transition_rejects_live_peer_and_wrong_identities() {
2495 let dir = tempdir().expect("tempdir");
2496 let database = dir.path().join("exact.db");
2497 let unrelated = dir.path().join("unrelated.db");
2498 let old_identity = create_database(&database, b"old");
2499 let live = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2500 .expect("admit old generation")
2501 .bind(old_identity)
2502 .expect("bind old generation");
2503 live.finish_bootstrap().expect("publish old generation");
2504 let unrelated_identity = create_database(&unrelated, b"unrelated");
2505
2506 assert!(matches!(
2507 begin_database_namespace_generation_transition(&database, old_identity),
2508 Err(FrankenError::Busy)
2509 ));
2510 drop(live);
2511
2512 assert!(matches!(
2513 begin_database_namespace_generation_transition(&database, unrelated_identity),
2514 Err(FrankenError::CannotOpen { .. })
2515 ));
2516
2517 let mut use_file = open_existing_transition_lock_file(&sidecar_path(&database, USE_SUFFIX))
2518 .expect("open unchanged namespace record");
2519 assert_eq!(
2520 read_identity_record(&mut use_file, &database).expect("read unchanged generation"),
2521 old_identity
2522 );
2523 }
2524
2525 #[test]
2526 fn generation_transition_excludes_shared_admission_for_entire_mutation_window() {
2527 let dir = tempdir().expect("tempdir");
2528 let database = dir.path().join("admission-race.db");
2529 let quarantine = dir.path().join("admission-race.db.corrupt");
2530 let replacement_staging = dir.path().join("admission-race.db.replacement");
2531 let old_identity = create_database(&database, b"old");
2532 publish_generation(&database, old_identity);
2533 let replacement_identity = create_database(&replacement_staging, b"replacement");
2534
2535 let mut transition =
2536 begin_database_namespace_generation_transition(&database, old_identity)
2537 .expect("guard before caller-owned mutation");
2538 assert!(matches!(
2539 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2540 Err(FrankenError::Busy)
2541 ));
2542
2543 fs::rename(&database, &quarantine).expect("quarantine old generation");
2544 assert!(matches!(
2545 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2546 Err(FrankenError::Busy)
2547 ));
2548 fs::rename(&replacement_staging, &database).expect("activate replacement");
2549 assert!(matches!(
2550 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2551 Err(FrankenError::Busy)
2552 ));
2553 transition
2554 .publish_replacement(replacement_identity)
2555 .expect("publish replacement while still exclusive");
2556 assert!(matches!(
2557 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2558 Err(FrankenError::Busy)
2559 ));
2560 transition.finish().expect("finish transition");
2561
2562 let pending = PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2563 .expect("replacement generation remains readable");
2564 assert_eq!(pending.expected_identity(), Some(replacement_identity));
2565 pending
2566 .bind(replacement_identity)
2567 .expect("finished transition admits exact replacement");
2568 }
2569
2570 #[test]
2571 fn generation_transition_rejects_missing_and_malformed_records() {
2572 let dir = tempdir().expect("tempdir");
2573 let missing_database = dir.path().join("missing.db");
2574 let missing_old_identity = create_database(&missing_database, b"old");
2575 assert!(matches!(
2576 begin_database_namespace_generation_transition(&missing_database, missing_old_identity),
2577 Err(FrankenError::CannotOpen { .. })
2578 ));
2579 assert!(!sidecar_path(&missing_database, GATE_SUFFIX).exists());
2580 assert!(!sidecar_path(&missing_database, USE_SUFFIX).exists());
2581
2582 let database = dir.path().join("malformed.db");
2583 let old_identity = create_database(&database, b"old");
2584 publish_generation(&database, old_identity);
2585 let use_path = sidecar_path(&database, USE_SUFFIX);
2586 fs::write(&use_path, b"malformed namespace record").expect("corrupt namespace record");
2587 let malformed_before = fs::read(&use_path).expect("snapshot malformed record");
2588
2589 assert!(matches!(
2590 begin_database_namespace_generation_transition(&database, old_identity),
2591 Err(FrankenError::CannotOpen { .. })
2592 ));
2593 assert_eq!(
2594 fs::read(&use_path).expect("read refused malformed record"),
2595 malformed_before
2596 );
2597 }
2598
2599 #[test]
2600 fn generation_transition_detects_path_replacement_before_publication() {
2601 let dir = tempdir().expect("tempdir");
2602 let database = dir.path().join("race.db");
2603 let quarantine = dir.path().join("race.db.corrupt");
2604 let replacement_staging = dir.path().join("race.db.replacement");
2605 let displaced_replacement = dir.path().join("race.db.displaced");
2606 let old_identity = create_database(&database, b"old");
2607 publish_generation(&database, old_identity);
2608 let replacement_identity = create_database(&replacement_staging, b"replacement");
2609 let mut transition =
2610 begin_database_namespace_generation_transition(&database, old_identity)
2611 .expect("guard old generation");
2612 fs::rename(&database, &quarantine).expect("quarantine old generation");
2613 fs::rename(&replacement_staging, &database).expect("activate replacement");
2614
2615 let result = transition.publish_replacement_inner(replacement_identity, || {
2616 fs::rename(&database, &displaced_replacement)
2617 .expect("displace replacement during transition");
2618 create_database(&database, b"racing replacement");
2619 Ok(())
2620 });
2621 assert!(matches!(result, Err(FrankenError::CannotOpen { .. })));
2622 assert!(matches!(
2623 transition.finish(),
2624 Err(FrankenError::CannotOpen { .. })
2625 ));
2626
2627 drop(transition);
2628 let racing_identity =
2629 FileIdentity::from_file(&File::open(&database).expect("open racing replacement"))
2630 .expect("query racing replacement identity")
2631 .expect("native racing identity");
2632 assert!(matches!(
2633 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2634 .expect("admission reaches fail-closed record validation")
2635 .bind(racing_identity),
2636 Err(FrankenError::CannotOpen { .. })
2637 ));
2638 assert_eq!(
2639 fs::read(displaced_replacement).expect("read displaced replacement"),
2640 b"replacement"
2641 );
2642 }
2643
2644 #[test]
2645 fn interrupted_generation_transition_releases_locks_and_retries_exactly() {
2646 let dir = tempdir().expect("tempdir");
2647 let database = dir.path().join("interrupt.db");
2648 let quarantine = dir.path().join("interrupt.db.corrupt");
2649 let replacement_staging = dir.path().join("interrupt.db.replacement");
2650 let old_identity = create_database(&database, b"old");
2651 publish_generation(&database, old_identity);
2652 let replacement_identity = create_database(&replacement_staging, b"replacement");
2653 let mut transition =
2654 begin_database_namespace_generation_transition(&database, old_identity)
2655 .expect("guard old generation");
2656 fs::rename(&database, &quarantine).expect("quarantine old generation");
2657 fs::rename(&replacement_staging, &database).expect("activate replacement");
2658
2659 let interrupted = transition.publish_replacement_inner(replacement_identity, || {
2660 Err(FrankenError::internal(
2661 "injected pre-publication interruption",
2662 ))
2663 });
2664 assert!(matches!(interrupted, Err(FrankenError::Internal(_))));
2665 drop(transition);
2666 assert!(matches!(
2667 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2668 .expect("admission reaches fail-closed record validation")
2669 .bind(replacement_identity),
2670 Err(FrankenError::CannotOpen { .. })
2671 ));
2672
2673 let mut retry = begin_database_namespace_generation_transition(&database, old_identity)
2674 .expect("resume prepared transition with replacement already installed");
2675 assert_eq!(
2676 retry
2677 .publish_replacement(replacement_identity)
2678 .expect("retry interrupted transition"),
2679 NamespaceGenerationTransitionOutcome::Published
2680 );
2681 retry.finish().expect("finish retried transition");
2682 }
2683
2684 #[test]
2685 fn prepared_transition_resumes_while_main_path_is_absent() {
2686 let dir = tempdir().expect("tempdir");
2687 let database = dir.path().join("absent.db");
2688 let quarantine = dir.path().join("absent.db.quarantined");
2689 let replacement_staging = dir.path().join("absent.db.replacement");
2690 let old_identity = create_database(&database, b"old");
2691 publish_generation(&database, old_identity);
2692 let replacement_identity = create_database(&replacement_staging, b"replacement");
2693
2694 let transition = begin_database_namespace_generation_transition(&database, old_identity)
2695 .expect("prepare transition before quarantine");
2696 fs::rename(&database, &quarantine).expect("quarantine old generation");
2697 drop(transition);
2698 assert!(!database.exists());
2699
2700 let mut resumed = begin_database_namespace_generation_transition(&database, old_identity)
2701 .expect("resume exact durable prepare while main path is absent");
2702 fs::rename(&replacement_staging, &database).expect("activate replacement after resume");
2703 resumed
2704 .publish_replacement(replacement_identity)
2705 .expect("publish replacement after absent-path resume");
2706 resumed.finish().expect("finish resumed transition");
2707 }
2708
2709 #[test]
2710 fn partial_transition_and_prepare_writes_resume_exactly() {
2711 for (name, transition_prefix, prepare_prefix) in [
2712 ("partial-transition", 37_usize, 0_usize),
2713 ("complete-transition", TRANSITION_BYTES, 0_usize),
2714 ("partial-next-prepare", TRANSITION_BYTES, 37_usize),
2715 ] {
2716 let dir = tempdir().expect("tempdir");
2717 let database = dir.path().join(format!("{name}.db"));
2718 let quarantine = dir.path().join(format!("{name}.db.quarantined"));
2719 let replacement_staging = dir.path().join(format!("{name}.db.replacement"));
2720 let old_identity = create_database(&database, b"old");
2721 publish_generation(&database, old_identity);
2722 let replacement_identity = create_database(&replacement_staging, b"replacement");
2723 let mut transition =
2724 begin_database_namespace_generation_transition(&database, old_identity)
2725 .expect("prepare exact transition");
2726 fs::rename(&database, &quarantine).expect("quarantine old generation");
2727 fs::rename(&replacement_staging, &database).expect("activate replacement");
2728
2729 let record = encode_transition_record(1, old_identity, replacement_identity);
2730 let next_prepare = encode_prepare_record(2, replacement_identity);
2731 let append_offset = transition.append_offset;
2732 let use_file = transition
2733 .use_file
2734 .as_mut()
2735 .expect("transition retains use-sidecar descriptor");
2736 use_file
2737 .seek(SeekFrom::Start(append_offset))
2738 .expect("seek interrupted publication offset");
2739 use_file
2740 .write_all(&record[..transition_prefix])
2741 .expect("write requested transition prefix");
2742 use_file
2743 .write_all(&next_prepare[..prepare_prefix])
2744 .expect("write requested next-prepare prefix");
2745 use_file
2746 .sync_data()
2747 .expect("durably inject interrupted publication");
2748 drop(transition);
2749
2750 let expected_recorded_identity = if transition_prefix == TRANSITION_BYTES {
2751 replacement_identity
2752 } else {
2753 old_identity
2754 };
2755 let mut resumed = begin_database_namespace_generation_transition(
2756 &database,
2757 expected_recorded_identity,
2758 )
2759 .expect("resume exact interrupted ledger state");
2760 if expected_recorded_identity == old_identity {
2761 assert_eq!(
2762 resumed
2763 .publish_replacement(replacement_identity)
2764 .expect("complete exact interrupted transition"),
2765 NamespaceGenerationTransitionOutcome::Published
2766 );
2767 }
2768 resumed.finish().expect("finish resumed publication");
2769
2770 let pending =
2771 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting)
2772 .expect("admit completed replacement");
2773 assert_eq!(pending.expected_identity(), Some(replacement_identity));
2774 }
2775 }
2776
2777 #[test]
2778 fn finish_error_retains_exclusive_retryable_guard() {
2779 const POISONED_DROP_CHILD_DATABASE: &str = "FSQLITE_NS_POISONED_DROP_CHILD_DATABASE";
2780
2781 if let Some(database) = std::env::var_os(POISONED_DROP_CHILD_DATABASE) {
2782 let database = PathBuf::from(database);
2783 let identity =
2784 FileIdentity::from_file(&File::open(&database).expect("open child generation"))
2785 .expect("query child generation identity")
2786 .expect("native child generation identity");
2787 let mut dropped = begin_database_namespace_generation_transition(&database, identity)
2788 .expect("prepare transition for poisoned-drop proof");
2789 assert!(matches!(
2790 dropped.finish_inner(|| {
2791 Err(FrankenError::internal(
2792 "injected failure after complete finish bytes",
2793 ))
2794 }),
2795 Err(FrankenError::Internal(_))
2796 ));
2797 drop(dropped);
2798 assert!(matches!(
2799 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2800 Err(FrankenError::Busy)
2801 ));
2802 return;
2803 }
2804
2805 let dir = tempdir().expect("tempdir");
2806 let database = dir.path().join("finish-retry.db");
2807 let identity = create_database(&database, b"generation");
2808 publish_generation(&database, identity);
2809 let mut transition = begin_database_namespace_generation_transition(&database, identity)
2810 .expect("prepare transition");
2811
2812 let result = transition.finish_inner(|| {
2813 Err(FrankenError::internal(
2814 "injected failure after finish write before sync",
2815 ))
2816 });
2817 assert!(matches!(result, Err(FrankenError::Internal(_))));
2818 assert!(matches!(
2819 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
2820 Err(FrankenError::Busy)
2821 ));
2822 assert_eq!(transition.finish().expect("retry exact finish"), identity);
2823 drop(transition);
2824 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2825 .expect("admission resumes after confirmed finish");
2826
2827 let dropped_database = dir.path().join("finish-drop.db");
2828 let dropped_identity = create_database(&dropped_database, b"generation");
2829 publish_generation(&dropped_database, dropped_identity);
2830 let output = Command::new(std::env::current_exe().expect("resolve test executable"))
2831 .arg("--exact")
2832 .arg("namespace::tests::finish_error_retains_exclusive_retryable_guard")
2833 .arg("--nocapture")
2834 .env(POISONED_DROP_CHILD_DATABASE, &dropped_database)
2835 .output()
2836 .expect("run poisoned-drop child");
2837 assert!(
2838 output.status.success(),
2839 "poisoned-drop child failed:\nstdout:\n{}\nstderr:\n{}",
2840 String::from_utf8_lossy(&output.stdout),
2841 String::from_utf8_lossy(&output.stderr)
2842 );
2843 PendingNamespaceOpen::begin(&dropped_database, NamespaceOpenIntent::Shared)
2844 .expect("process exit releases intentionally leaked fail-stop locks");
2845 }
2846
2847 #[test]
2848 fn partial_finish_resumes_exactly_and_foreign_finish_tail_is_rejected() {
2849 let dir = tempdir().expect("tempdir");
2850 let database = dir.path().join("partial-finish.db");
2851 let identity = create_database(&database, b"generation");
2852 publish_generation(&database, identity);
2853 let mut transition = begin_database_namespace_generation_transition(&database, identity)
2854 .expect("prepare finish interruption");
2855 let finish = encode_finish_record(1, identity);
2856 let append_offset = transition.append_offset;
2857 let use_file = transition
2858 .use_file
2859 .as_mut()
2860 .expect("transition retains use-sidecar descriptor");
2861 use_file
2862 .seek(SeekFrom::Start(append_offset))
2863 .expect("seek finish offset");
2864 use_file
2865 .write_all(&finish[..37])
2866 .expect("write exact partial finish");
2867 use_file.sync_data().expect("sync exact partial finish");
2868 drop(transition);
2869
2870 let mut resumed = begin_database_namespace_generation_transition(&database, identity)
2871 .expect("reacquire prepared transition with partial finish");
2872 resumed.finish().expect("complete exact partial finish");
2873 drop(resumed);
2874 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
2875 .expect("admit after completed finish");
2876
2877 let foreign_database = dir.path().join("foreign-finish.db");
2878 let foreign_identity = create_database(&foreign_database, b"generation");
2879 publish_generation(&foreign_database, foreign_identity);
2880 let mut foreign =
2881 begin_database_namespace_generation_transition(&foreign_database, foreign_identity)
2882 .expect("prepare foreign-tail proof");
2883 let foreign_append_offset = foreign.append_offset;
2884 let foreign_file = foreign
2885 .use_file
2886 .as_mut()
2887 .expect("transition retains use-sidecar descriptor");
2888 foreign_file
2889 .seek(SeekFrom::Start(foreign_append_offset))
2890 .expect("seek foreign finish offset");
2891 foreign_file
2892 .write_all(b"foreign finish tail")
2893 .expect("write foreign finish tail");
2894 foreign_file.sync_data().expect("sync foreign tail");
2895 drop(foreign);
2896
2897 let mut refused =
2898 begin_database_namespace_generation_transition(&foreign_database, foreign_identity)
2899 .expect("reacquire guarded foreign tail");
2900 assert!(matches!(
2901 refused.finish(),
2902 Err(FrankenError::CannotOpen { .. })
2903 ));
2904 drop(refused);
2905 assert!(matches!(
2906 PendingNamespaceOpen::begin(&foreign_database, NamespaceOpenIntent::Shared)
2907 .expect("ordinary admission reaches fail-closed validation")
2908 .bind(foreign_identity),
2909 Err(FrankenError::CannotOpen { .. })
2910 ));
2911 }
2912
2913 #[test]
2914 fn transition_ledger_remains_usable_beyond_legacy_record_bound() {
2915 let dir = tempdir().expect("tempdir");
2916 let database = dir.path().join("long-lived.db");
2917 let identity = create_database(&database, b"generation");
2918 publish_generation(&database, identity);
2919 let use_path = sidecar_path(&database, USE_SUFFIX);
2920 let mut use_file = OpenOptions::new()
2921 .append(true)
2922 .open(&use_path)
2923 .expect("open long-lived namespace ledger");
2924 for sequence in 1..=1_025_u64 {
2925 use_file
2926 .write_all(&encode_prepare_record(sequence, identity))
2927 .expect("append historical prepare");
2928 use_file
2929 .write_all(&encode_finish_record(sequence, identity))
2930 .expect("append historical finish");
2931 }
2932 use_file.sync_data().expect("sync long-lived ledger");
2933 drop(use_file);
2934
2935 let mut transition = begin_database_namespace_generation_transition(&database, identity)
2936 .expect("begin after more than 1,024 historical records");
2937 transition
2938 .finish()
2939 .expect("finish after legacy bound is exceeded");
2940 }
2941
2942 #[test]
2943 fn exact_partial_prepare_append_is_repaired_but_foreign_tail_is_rejected() {
2944 let dir = tempdir().expect("tempdir");
2945 let database = dir.path().join("partial.db");
2946 let old_identity = create_database(&database, b"old");
2947 publish_generation(&database, old_identity);
2948 let use_path = sidecar_path(&database, USE_SUFFIX);
2949 let prepare = encode_prepare_record(1, old_identity);
2950 let mut use_file = OpenOptions::new()
2951 .append(true)
2952 .open(&use_path)
2953 .expect("open namespace record for interrupted prepare");
2954 use_file
2955 .write_all(&prepare[..37])
2956 .expect("write exact interrupted prefix");
2957 use_file.sync_data().expect("sync interrupted prefix");
2958 drop(use_file);
2959
2960 begin_database_namespace_generation_transition(&database, old_identity)
2961 .expect("repair exact interrupted prepare")
2962 .finish()
2963 .expect("finish repaired no-op transition");
2964
2965 let second_database = dir.path().join("foreign-tail.db");
2966 let second_old_identity = create_database(&second_database, b"second old");
2967 publish_generation(&second_database, second_old_identity);
2968 let second_use_path = sidecar_path(&second_database, USE_SUFFIX);
2969 let mut second_use_file = OpenOptions::new()
2970 .append(true)
2971 .open(&second_use_path)
2972 .expect("open second namespace record");
2973 second_use_file
2974 .write_all(b"foreign interrupted bytes")
2975 .expect("write foreign partial tail");
2976 second_use_file.sync_data().expect("sync foreign tail");
2977 drop(second_use_file);
2978 let foreign_before = fs::read(&second_use_path).expect("snapshot foreign tail");
2979
2980 assert!(matches!(
2981 begin_database_namespace_generation_transition(&second_database, second_old_identity),
2982 Err(FrankenError::CannotOpen { .. })
2983 ));
2984 assert_eq!(
2985 fs::read(&second_use_path).expect("read refused foreign tail"),
2986 foreign_before
2987 );
2988 }
2989
2990 #[test]
2991 fn generation_transition_rejects_corrupt_or_unprepared_complete_transition_record() {
2992 let dir = tempdir().expect("tempdir");
2993 let database = dir.path().join("corrupt-transition.db");
2994 let unrelated = dir.path().join("corrupt-transition.replacement.db");
2995 let old_identity = create_database(&database, b"old");
2996 publish_generation(&database, old_identity);
2997 let replacement_identity = create_database(&unrelated, b"replacement");
2998 let use_path = sidecar_path(&database, USE_SUFFIX);
2999 let mut corrupt_transition =
3000 encode_transition_record(1, old_identity, replacement_identity);
3001 corrupt_transition[TRANSITION_CHECKSUM_OFFSET] ^= 0xff;
3002 let mut use_file = OpenOptions::new()
3003 .append(true)
3004 .open(&use_path)
3005 .expect("open namespace record");
3006 use_file
3007 .write_all(&corrupt_transition)
3008 .expect("write corrupt complete transition");
3009 use_file.sync_data().expect("sync corrupt transition");
3010 drop(use_file);
3011 let corrupt_before = fs::read(&use_path).expect("snapshot corrupt transition");
3012
3013 assert!(matches!(
3014 begin_database_namespace_generation_transition(&database, old_identity),
3015 Err(FrankenError::CannotOpen { .. })
3016 ));
3017 assert_eq!(
3018 fs::read(&use_path).expect("read refused corrupt transition"),
3019 corrupt_before
3020 );
3021
3022 let unprepared_database = dir.path().join("unprepared-transition.db");
3023 let unprepared_replacement = dir.path().join("unprepared-transition.replacement.db");
3024 let unprepared_old_identity = create_database(&unprepared_database, b"old");
3025 publish_generation(&unprepared_database, unprepared_old_identity);
3026 let unprepared_replacement_identity =
3027 create_database(&unprepared_replacement, b"replacement");
3028 let unprepared_use_path = sidecar_path(&unprepared_database, USE_SUFFIX);
3029 let mut unprepared_use_file = OpenOptions::new()
3030 .append(true)
3031 .open(&unprepared_use_path)
3032 .expect("open unprepared namespace ledger");
3033 unprepared_use_file
3034 .write_all(&encode_transition_record(
3035 1,
3036 unprepared_old_identity,
3037 unprepared_replacement_identity,
3038 ))
3039 .expect("write valid checksummed transition without prepare");
3040 unprepared_use_file
3041 .sync_data()
3042 .expect("sync unprepared transition");
3043 drop(unprepared_use_file);
3044
3045 assert!(matches!(
3046 begin_database_namespace_generation_transition(
3047 &unprepared_database,
3048 unprepared_old_identity
3049 ),
3050 Err(FrankenError::CannotOpen { .. })
3051 ));
3052 }
3053
3054 #[cfg(any(unix, windows))]
3055 #[test]
3056 fn generation_transition_rejects_hard_linked_replacement() {
3057 let dir = tempdir().expect("tempdir");
3058 let database = dir.path().join("hardlink.db");
3059 let quarantine = dir.path().join("hardlink.db.corrupt");
3060 let replacement_source = dir.path().join("hardlink-replacement.db");
3061 let old_identity = create_database(&database, b"old");
3062 publish_generation(&database, old_identity);
3063 let mut transition =
3064 begin_database_namespace_generation_transition(&database, old_identity)
3065 .expect("guard old generation");
3066 fs::rename(&database, &quarantine).expect("quarantine old generation");
3067 let replacement_identity = create_database(&replacement_source, b"replacement");
3068 fs::hard_link(&replacement_source, &database).expect("hard-link replacement into place");
3069
3070 assert!(matches!(
3071 transition.publish_replacement(replacement_identity),
3072 Err(FrankenError::CannotOpen { .. })
3073 ));
3074 }
3075
3076 #[cfg(unix)]
3077 #[test]
3078 fn generation_transition_rejects_final_component_symlink() {
3079 use std::os::unix::fs::symlink;
3080
3081 let dir = tempdir().expect("tempdir");
3082 let database = dir.path().join("symlink.db");
3083 let quarantine = dir.path().join("symlink.db.corrupt");
3084 let replacement_source = dir.path().join("symlink-replacement.db");
3085 let old_identity = create_database(&database, b"old");
3086 publish_generation(&database, old_identity);
3087 let mut transition =
3088 begin_database_namespace_generation_transition(&database, old_identity)
3089 .expect("guard old generation");
3090 fs::rename(&database, &quarantine).expect("quarantine old generation");
3091 let replacement_identity = create_database(&replacement_source, b"replacement");
3092 symlink(&replacement_source, &database).expect("symlink replacement into place");
3093
3094 assert!(matches!(
3095 transition.publish_replacement(replacement_identity),
3096 Err(FrankenError::CannotOpen { .. })
3097 ));
3098 }
3099
3100 #[test]
3101 fn generation_transition_cross_process_exclusion_then_finish_releases_locks() {
3102 const CHILD_DATABASE: &str = "FSQLITE_NS_TRANSITION_CHILD_DATABASE";
3103 const CHILD_EXPECT_OPEN: &str = "FSQLITE_NS_TRANSITION_CHILD_EXPECT_OPEN";
3104
3105 if let Some(database) = std::env::var_os(CHILD_DATABASE) {
3106 let database = PathBuf::from(database);
3107 let admission =
3108 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReadOnlyExisting);
3109 if std::env::var_os(CHILD_EXPECT_OPEN).is_some() {
3110 admission.expect("successful finish releases both locks cross-process");
3111 } else {
3112 assert!(matches!(admission, Err(FrankenError::Busy)));
3113 }
3114 return;
3115 }
3116
3117 let dir = tempdir().expect("tempdir");
3118 let database = dir.path().join("cross-process.db");
3119 let quarantine = dir.path().join("cross-process.db.corrupt");
3120 let replacement_staging = dir.path().join("cross-process.db.replacement");
3121 let old_identity = create_database(&database, b"old");
3122 publish_generation(&database, old_identity);
3123 let replacement_identity = create_database(&replacement_staging, b"replacement");
3124 let mut transition =
3125 begin_database_namespace_generation_transition(&database, old_identity)
3126 .expect("guard old generation before mutation");
3127
3128 let assert_child_admission = |expect_open: bool| {
3129 let mut command =
3130 Command::new(std::env::current_exe().expect("resolve test executable"));
3131 command
3132 .arg("--exact")
3133 .arg(
3134 "namespace::tests::generation_transition_cross_process_exclusion_then_finish_releases_locks",
3135 )
3136 .arg("--nocapture")
3137 .env(CHILD_DATABASE, &database);
3138 if expect_open {
3139 command.env(CHILD_EXPECT_OPEN, "1");
3140 }
3141 let output = command.output().expect("run namespace transition child");
3142 assert!(
3143 output.status.success(),
3144 "child failed:\nstdout:\n{}\nstderr:\n{}",
3145 String::from_utf8_lossy(&output.stdout),
3146 String::from_utf8_lossy(&output.stderr)
3147 );
3148 };
3149
3150 assert_child_admission(false);
3151 fs::rename(&database, &quarantine).expect("quarantine old generation");
3152 assert_child_admission(false);
3153 fs::rename(&replacement_staging, &database).expect("activate replacement");
3154 assert_child_admission(false);
3155 assert_eq!(
3156 transition
3157 .publish_replacement(replacement_identity)
3158 .expect("publish while cross-process admissions remain excluded"),
3159 NamespaceGenerationTransitionOutcome::Published
3160 );
3161 assert_child_admission(false);
3162 transition.finish().expect("finish exact replacement");
3163 assert_child_admission(true);
3164 }
3165
3166 #[test]
3167 fn reserved_bootstrap_and_pending_drop_are_raii_exclusive() {
3168 let dir = tempdir().expect("tempdir");
3169 let database = dir.path().join("reserved.db");
3170 let identity = create_database(&database, b"");
3171
3172 let abandoned =
3173 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReservedExclusive)
3174 .expect("reserve namespace");
3175 drop(abandoned);
3176
3177 let reserved =
3178 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReservedExclusive)
3179 .expect("reserve after unwind")
3180 .bind(identity)
3181 .expect("bind reservation");
3182 assert!(matches!(
3183 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
3184 Err(FrankenError::Busy)
3185 ));
3186 reserved.finish_bootstrap().expect("finish reservation");
3187 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared)
3188 .expect("shared admission after reservation")
3189 .bind(identity)
3190 .expect("join reserved generation");
3191
3192 assert!(sidecar_path(&database, GATE_SUFFIX).exists());
3193 assert!(sidecar_path(&database, USE_SUFFIX).exists());
3194 }
3195
3196 #[test]
3197 fn abandoned_private_cleanup_requires_exclusive_namespace_and_removes_exact_artifacts() {
3198 let dir = tempdir().expect("tempdir");
3199 let database = dir.path().join("transient.db");
3200 let identity = create_database(&database, b"candidate");
3201 let binding =
3202 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReservedExclusive)
3203 .expect("reserve namespace")
3204 .bind(identity)
3205 .expect("bind reservation");
3206 binding.finish_bootstrap().expect("finish bootstrap");
3207 for suffix in [
3208 "-journal",
3209 "-wal",
3210 "-wal-fec",
3211 "-shm",
3212 "-lock-shared",
3213 "-lock-reserved",
3214 "-lock-pending",
3215 ] {
3216 fs::write(sidecar_path(&database, suffix), b"candidate artifact")
3217 .expect("seed exact candidate companion");
3218 }
3219 let wal_fec_temp = sidecar_path(&database, "-wal-fec").with_extension("wal-fec.tmp");
3220 fs::write(&wal_fec_temp, b"candidate rewrite artifact")
3221 .expect("seed exact WAL-FEC rewrite companion");
3222
3223 assert!(
3224 !cleanup_abandoned_private_database(&database, identity)
3225 .expect("contention must fail closed"),
3226 "a live namespace binding must prevent transient cleanup"
3227 );
3228 assert!(database.exists());
3229 drop(binding);
3230
3231 assert!(
3232 cleanup_abandoned_private_database(&database, identity)
3233 .expect("exclusive abandoned-candidate cleanup")
3234 );
3235 assert!(!database.exists());
3236 for suffix in [
3237 "-journal",
3238 "-wal",
3239 "-wal-fec",
3240 "-shm",
3241 "-lock-shared",
3242 "-lock-reserved",
3243 "-lock-pending",
3244 GATE_SUFFIX,
3245 USE_SUFFIX,
3246 ] {
3247 assert!(
3248 !sidecar_path(&database, suffix).exists(),
3249 "cleanup left exact companion {suffix}"
3250 );
3251 }
3252 assert!(!wal_fec_temp.exists());
3253 }
3254
3255 #[test]
3256 fn abandoned_private_cleanup_preserves_replacement_and_namespace_on_identity_drift() {
3257 let dir = tempdir().expect("tempdir");
3258 let database = dir.path().join("drift.db");
3259 let displaced = dir.path().join("drift-owned.db");
3260 let identity = create_database(&database, b"owned candidate");
3261 let binding =
3262 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::ReservedExclusive)
3263 .expect("reserve namespace")
3264 .bind(identity)
3265 .expect("bind reservation");
3266 binding.finish_bootstrap().expect("finish bootstrap");
3267 drop(binding);
3268
3269 fs::rename(&database, &displaced).expect("displace owned candidate");
3270 fs::write(&database, b"replacement").expect("seed replacement");
3271 assert!(
3272 !cleanup_abandoned_private_database(&database, identity)
3273 .expect("identity drift must fail closed")
3274 );
3275 assert_eq!(
3276 fs::read(&database).expect("read replacement"),
3277 b"replacement"
3278 );
3279 assert_eq!(
3280 fs::read(&displaced).expect("read owned candidate"),
3281 b"owned candidate"
3282 );
3283 assert!(sidecar_path(&database, GATE_SUFFIX).exists());
3284 assert!(sidecar_path(&database, USE_SUFFIX).exists());
3285 }
3286
3287 #[test]
3288 fn artifact_validation_rejects_segments_and_wal_fec_rewrite_temp() {
3289 let dir = tempdir().expect("tempdir");
3290 let database = dir.path().join("artifacts.db");
3291 create_database(&database, b"");
3292 validate_reserved_database_artifacts(&database, WindowsLockSidecarPolicy::RejectAll)
3293 .expect("artifact-free reservation");
3294
3295 fs::write(
3296 dir.path().join("artifacts.db-wal-seg-not-an-epoch"),
3297 b"segment",
3298 )
3299 .expect("seed segment");
3300 assert!(matches!(
3301 validate_reserved_database_artifacts(&database, WindowsLockSidecarPolicy::RejectAll),
3302 Err(FrankenError::CannotOpen { .. })
3303 ));
3304
3305 let second = dir.path().join("rewrite.db");
3306 create_database(&second, b"");
3307 let temp = sidecar_path(&second, "-wal-fec").with_extension("wal-fec.tmp");
3308 fs::write(temp, b"partial rewrite").expect("seed WAL-FEC rewrite temp");
3309 assert!(matches!(
3310 validate_reserved_database_artifacts(&second, WindowsLockSidecarPolicy::RejectAll),
3311 Err(FrankenError::CannotOpen { .. })
3312 ));
3313 }
3314
3315 #[cfg(unix)]
3316 #[test]
3317 fn namespace_lockfile_symlink_is_rejected_without_following_it() {
3318 use std::os::unix::fs::symlink;
3319
3320 let dir = tempdir().expect("tempdir");
3321 let database = dir.path().join("nofollow.db");
3322 create_database(&database, b"");
3323 let target = dir.path().join("attacker-target");
3324 fs::write(&target, b"unchanged").expect("seed target");
3325 symlink(&target, sidecar_path(&database, GATE_SUFFIX)).expect("seed malicious symlink");
3326
3327 assert!(matches!(
3328 PendingNamespaceOpen::begin(&database, NamespaceOpenIntent::Shared),
3329 Err(FrankenError::CannotOpen { .. })
3330 ));
3331 assert_eq!(fs::read(target).expect("read target"), b"unchanged");
3332 }
3333}