Skip to main content

fsqlite_vfs/
namespace.rs

1//! Lifetime binding between an opened database and its pathname namespace.
2//!
3//! Native file VFSes use two persistent sidecars.  The `gate` lock serializes
4//! admission, while the `use` lock is shared by every connection bound to the
5//! same file identity.  A new generation may replace the identity record only
6//! while it owns `use` exclusively.  Reserved-empty bootstrap retains both
7//! locks exclusively until [`DatabaseNamespaceBinding::finish_bootstrap`].
8//! Sidecars are deliberately never unlinked for ordinary database lifetimes:
9//! unlinking a locked file would split the advisory-lock domain on Unix. The
10//! sole exception is [`cleanup_abandoned_private_database`], which is limited
11//! to a caller-reserved transient candidate after all pager bindings have
12//! closed and requires exclusive ownership of both namespace locks.
13//!
14//! This is a cooperative, trusted-parent protocol.  Native processes that
15//! bypass FrankenSQLite can ignore advisory locks, and Unix permits a raw
16//! unlink/rename despite an open descriptor.  Callers must not mutate the
17//! database namespace or these sidecars outside the library while a binding
18//! is live, and must not open one database through multiple hard-link aliases.
19
20use 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/// Admission mode for a database namespace.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum NamespaceOpenIntent {
55    /// Join the live generation, or establish a new shared generation when no
56    /// connection currently owns the namespace.
57    Shared,
58    /// Join an existing generation without creating or rewriting namespace
59    /// records. Missing or malformed records fail closed.
60    ReadOnlyExisting,
61    /// Exclusively reserve the namespace through empty-database bootstrap.
62    ReservedExclusive,
63}
64
65/// Durable result of an exact namespace-generation transition.
66#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum NamespaceGenerationTransitionOutcome {
68    /// This call durably published the replacement identity.
69    Published,
70    /// The same exact old-to-replacement request was already published.
71    AlreadyPublished,
72}
73
74/// Exclusive namespace lease spanning caller-owned generation replacement.
75///
76/// The guard owns both persistent namespace locks and leaves a durable
77/// fail-closed prepare marker until [`Self::finish`] succeeds. It may publish
78/// more than one replacement while held, which permits an exact `A -> B`
79/// activation followed by an exact `B -> A` rollback before admissions resume.
80#[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    /// GH#140 / bd-daqmp: read-only admission of a database that no
110    /// FrankenSQLite ever admitted (no namespace sidecars exist, e.g. a stock
111    /// SQLite file). Nothing is created, opened, or locked — the reader
112    /// behaves like an external stock process. A namespace created by a peer
113    /// AFTER this admission cannot coordinate with it, which is identical to
114    /// the peer's exposure to any non-FrankenSQLite reader.
115    ReadOnlyUnadmitted,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119enum NamespaceBindMode {
120    PreserveRecord,
121    ReplaceQuiescentRecord,
122}
123
124/// Admission guard held while the caller opens and verifies the main file.
125///
126/// Dropping this value at any error point releases every acquired lock.
127#[derive(Debug)]
128pub struct PendingNamespaceOpen {
129    stable_path: PathBuf,
130    lease: Option<PendingLease>,
131}
132
133impl PendingNamespaceOpen {
134    /// Begin namespace admission for an already-resolved absolute database
135    /// path.  This operation is non-blocking; lock contention returns BUSY.
136    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            // GH#140 / bd-daqmp: a read-only open must be byte-neutral for the
140            // whole file family. When the namespace sidecars do not exist (a
141            // database never admitted by FrankenSQLite), creating them here
142            // would make a read-only open side-effecting, so admit
143            // sidecar-less instead. Absence is checked explicitly; any OTHER
144            // sidecar-open failure (malformed, permissions) still fails
145            // closed through `open_existing_secure_lock_file` below.
146            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    /// Identity of the live generation this admission must join.  When this
240    /// returns `Some`, callers must strip CREATE/EXCLUSIVE and open that exact
241    /// existing identity before calling [`Self::bind`].
242    #[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    /// Whether this admission exclusively owns a nonempty namespace record.
254    ///
255    /// `true` identifies the only state in which a caller may need
256    /// [`Self::bind_replacing_quiescent_record`]. New namespaces have an empty
257    /// record; joined/live namespaces are never reported as quiescent.
258    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    /// Bind admission to the identity obtained from the opened main-file
266    /// descriptor.  No recovery artifact may be inspected before this step.
267    pub fn bind(self, identity: FileIdentity) -> Result<Arc<DatabaseNamespaceBinding>> {
268        self.bind_with_gate_release(identity, release_gate)
269    }
270
271    /// Bind a newly opened generation after proving that a stale namespace
272    /// record has no live owner.
273    ///
274    /// This is deliberately narrower than [`Self::bind`]: it succeeds only
275    /// for a `Shared` admission that owns both namespace locks exclusively.
276    /// The caller must already have opened the current main-file descriptor;
277    /// its identity is revalidated against the pathname before the stale
278    /// record is replaced. A joined/live generation always fails closed.
279    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                // Keep a new generation exclusive through pager
336                // initialization.  Otherwise a peer could join a freshly
337                // created zero-length file before page 1 is durable.
338                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            // Sidecar-less admission holds no files and no locks.
407            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    /// GH#140 / bd-daqmp sidecar-less read-only binding: no files, no locks.
429    ReadOnlyUnadmitted,
430}
431
432/// Lifetime lease binding all path-derived companions to one main-file
433/// identity.  Keep this value alive for the full connection lifetime.
434#[derive(Debug)]
435pub struct DatabaseNamespaceBinding {
436    stable_path: PathBuf,
437    identity: FileIdentity,
438    lease: Mutex<BindingLease>,
439}
440
441impl DatabaseNamespaceBinding {
442    /// The single absolute path from which all companion names must derive.
443    #[must_use]
444    pub fn stable_path(&self) -> &Path {
445        &self.stable_path
446    }
447
448    /// The main-file identity to which this lease is bound.
449    #[must_use]
450    pub const fn identity(&self) -> FileIdentity {
451        self.identity
452    }
453
454    /// Side-effect-free identity validation for operation boundaries.  The
455    /// caller obtains the current pathname identity through its VFS first.
456    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    /// Verify that the stable main pathname (without following its final
465    /// symlink) still names this binding's file identity.  The probe is
466    /// read-only and never creates database or companion files.
467    ///
468    /// bd-qduu1: on Unix this must NOT open (and then close) a descriptor
469    /// for the main database file. POSIX record locks are per-process,
470    /// per-file: closing ANY descriptor of a file releases ALL of this
471    /// process's `fcntl` locks on it, including the RESERVED byte that
472    /// gates cross-process WAL appends. This probe runs on every WAL
473    /// backend operation, so the open+close variant silently destroyed the
474    /// append gate the group-commit flush had just acquired — two
475    /// processes then derived the same WAL append offset and overwrote
476    /// each other's committed frames (read-your-own-write returned zero
477    /// rows) or tripped the parallel-WAL certificate cross-check. A path
478    /// stat creates no descriptor, so no lock is disturbed;
479    /// `symlink_metadata` preserves the `O_NOFOLLOW` property by
480    /// identifying a final-component symlink itself (rejected as
481    /// not-a-file) rather than its target.
482    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        // Windows closes do not release byte-range locks held on other
499        // handles, and the robust 128-bit file identifier requires an open
500        // handle, so the handle-based probe remains correct there.
501        #[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    /// Complete reserved bootstrap by converting `use` to shared and then
509    /// releasing `gate`.  The transition is idempotent.
510    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    /// Whether bootstrap still owns the namespace exclusively.
554    #[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        // The last Arc is the exact end of this generation's lifetime lease.
568        // Unlock explicitly at that boundary before the descriptors close so
569        // a following generation transition cannot observe a stale shared
570        // lease, even on filesystems where close-driven flock handoff is not
571        // immediate.
572        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
590/// Begin an exact namespace-generation transition before mutating the path.
591///
592/// This opens the existing persistent sidecars without creating them, acquires
593/// both namespace locks exclusively, and verifies the durable namespace record.
594/// On a fresh transition it also requires the current main pathname to identify
595/// `expected_old_identity`, then writes a durable prepare marker before
596/// returning. Lock acquisition is non-blocking, so any live binding or
597/// concurrent admission returns [`FrankenError::Busy`].
598///
599/// On restart, an exact existing full or partial prepare marker changes the
600/// contract: `expected_old_identity` names the identity still recorded by the
601/// ledger, while the main pathname may be absent after quarantine or may
602/// already name a candidate replacement. The resumed guard retains any exact
603/// partial publication tail. `publish_replacement` accepts only the byte-exact
604/// continuation for the supplied replacement identity; `finish` accepts only
605/// the recorded identity. A foreign pathname or foreign ledger tail is never
606/// adopted.
607///
608/// The caller must acquire this guard before quarantining or renaming the old
609/// main file, retain it across every activation or rollback rename, call
610/// [`DatabaseNamespaceGenerationTransition::publish_replacement`] after each
611/// exact pathname replacement, and call
612/// [`DatabaseNamespaceGenerationTransition::finish`] only when the generation
613/// that should become visible is installed. The caller must also exclude
614/// non-library pathname mutation while the guard is live.
615///
616/// Dropping the guard before any finish attempt releases the advisory locks but
617/// deliberately retains the durable prepare marker. Ordinary admission then
618/// fails closed until recovery resumes this guard for the exact currently
619/// recorded identity and finishes or publishes another exact replacement. If a
620/// finish attempt mutates the ledger but cannot confirm durability, dropping
621/// the poisoned guard fail-stops by retaining both exclusive descriptors for
622/// the process lifetime. No namespace sidecar is ever renamed or unlinked.
623pub 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    /// Identity currently recorded by this exclusively leased namespace.
742    #[must_use]
743    pub const fn current_identity(&self) -> FileIdentity {
744        self.current_identity
745    }
746
747    /// Durably publish the exact identity currently installed at the path.
748    ///
749    /// Both namespace locks remain exclusive after publication. A fresh
750    /// prepare marker for `replacement_identity` is written in the same
751    /// durability unit, so the caller may replace it again (for example, an
752    /// exact rollback) before calling [`Self::finish`].
753    ///
754    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    /// Make the current exact generation visible.
835    ///
836    /// This method is deliberately non-consuming so the caller can retry after
837    /// an error; while such a retryable guard remains alive, both namespace
838    /// locks still exclude admissions. A successful finish releases both locks
839    /// before returning and subsequent calls return the same identity.
840    /// Publication uses an appended, checksummed finish record rather than
841    /// deleting the prepare marker, so a torn write remains fail-closed. If an
842    /// error occurs after ledger mutation, retry on this same guard. Abandoning
843    /// that poisoned guard deliberately retains both locks for the process
844    /// lifetime rather than admitting against an unconfirmed finish record.
845    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        // Close is the final authority for releasing these descriptor-owned
969        // locks.  Unlock explicitly first so a successful finish or ordinary
970        // abandonment has an immediate, platform-consistent handoff boundary.
971        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            // An I/O error after mutating the finish record makes durability
986            // unknowable. Releasing either descriptor could admit a peer that
987            // observes a complete-but-unconfirmed FINISH. Fail-stop instead:
988            // leak both descriptors so this process retains the exclusive
989            // locks. A successful retry clears `poisoned` and drops normally.
990            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
1092/// Open an existing namespace lock for transient-candidate cleanup.
1093///
1094/// Windows cleanup must be able to unlink the two namespace records while the
1095/// exclusive lock handles are retained. This special-purpose open therefore
1096/// includes `FILE_SHARE_DELETE`; ordinary namespace opens deliberately keep
1097/// their stronger no-delete sharing policy.
1098fn 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
1141/// Remove one abandoned caller-reserved transient database and its exact
1142/// namespace/recovery companions while holding the namespace exclusively.
1143///
1144/// This is **not** general database deletion. It exists only for private
1145/// `VACUUM` discard/rebuild candidates and failed caller-reserved outputs after
1146/// every pager/validation connection has closed. The parent directory is a
1147/// trusted cooperative namespace. Contention, a missing namespace record, a
1148/// generation-record mismatch, pathname identity drift, symlinks, or any
1149/// non-regular companion all fail closed without removing the main file.
1150///
1151/// The caller must retain the descriptor from which `expected_identity` was
1152/// derived until this function returns. `Ok(false)` means ownership could not
1153/// be proven and every entry was preserved.
1154pub 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    // Preflight the complete fixed companion set before removing anything.
1192    // Dynamic WAL segment cleanup is intentionally absent: transient VACUUM
1193    // candidates never enter WAL mode, and broad prefix deletion would violate
1194    // the exact-entry ownership boundary of this function.
1195    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    // Revalidate immediately before the first removal while both namespace
1211    // locks and the expected main descriptor are still live.
1212    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    // Win32 has no portable directory fsync. The caller still invokes the
1239    // platform VFS namespace-sync hook, whose Windows contract is an explicit
1240    // no-op matching SQLite's own Windows VFS durability boundary.
1241
1242    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        // SAFETY: `geteuid` has no preconditions and does not dereference data.
1326        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    // Preserve the exact transition ledger while this generation remains
1385    // current. Once a namespace record exists, ordinary admission may only
1386    // reopen that identity; every replacement must use the transition guard.
1387    // This also makes a guard dropped during caller-owned mutation fail closed
1388    // instead of silently rebinding the namespace to the pathname it finds.
1389    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    // `NewShared` holds both `gate` and `use` exclusively. Revalidate the
1408    // caller's already-open main generation against the stable pathname before
1409    // discarding copied/corrupt machine-local namespace state. A valid,
1410    // terminal transition ledger is safe to collapse in a copied namespace;
1411    // incomplete or malformed transition evidence must remain fail-closed.
1412    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(&current_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(&current_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    // `flock(LOCK_SH)` atomically converts this open file description's
1709    // exclusive lock to shared.
1710    try_lock(file, FileLockMode::Shared)
1711}
1712
1713#[cfg(windows)]
1714fn downgrade_to_shared(file: &File) -> Result<()> {
1715    // LockFileEx has no atomic conversion operation.  `gate` remains exclusive
1716    // around this call, so no cooperating opener can observe the short gap.
1717    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/// Windows advisory-lock sidecar policy for reserved-empty validation.
1737///
1738/// The post-main-open check permits the three sidecars that opening a Windows
1739/// VFS handle necessarily creates.
1740#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1741pub enum WindowsLockSidecarPolicy {
1742    /// Reject every advisory-lock sidecar before the main handle is opened.
1743    RejectAll,
1744    /// Allow only the sidecars created by the accepted main-file handle.
1745    AllowExpected,
1746}
1747
1748/// Validate that no recovery artifact belongs to a caller-reserved empty DB.
1749/// This function performs reads only and never creates or removes entries.
1750pub 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        // GH#140 / bd-daqmp: a read-only open of a database that no
2010        // FrankenSQLite ever admitted (e.g. a stock SQLite file) must be
2011        // byte-neutral for the whole family — no sidecar creation, no locks.
2012        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        // A later writable admission still creates the namespace normally.
2046        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        // GH#140 / bd-daqmp contract update: missing records no longer fail
2117        // closed — a database never admitted by FrankenSQLite admits
2118        // SIDECAR-LESS. The unchanged core of this keeper is the second half:
2119        // the directory must stay byte-for-byte pristine either way.
2120        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}