Skip to main content

projectatlas_db/
sqlite_profile.rs

1//! Enforce the supported local-filesystem and `SQLite` connection profile.
2
3use crate::{DbError, DbResult};
4use rusqlite::{Connection, ErrorCode, OpenFlags};
5#[cfg(any(target_os = "linux", test))]
6use std::ffi::OsStr;
7use std::ffi::OsString;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::time::{Duration, Instant};
12
13#[cfg(windows)]
14use std::path::{Component, Prefix};
15
16/// Required journal mode for live project databases.
17pub(crate) const REQUIRED_JOURNAL_MODE: &str = "wal";
18/// Required synchronous mode for authored and derived project state.
19const REQUIRED_SYNCHRONOUS_MODE: i64 = 2;
20/// Human-readable name for the required synchronous mode.
21pub(crate) const REQUIRED_SYNCHRONOUS_NAME: &str = "FULL";
22/// Maximum pause between bounded retries while another opener establishes WAL.
23const JOURNAL_MODE_RETRY_INTERVAL: Duration = Duration::from_millis(10);
24/// Maximum time ordinary read and write connections wait for database contention.
25pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
26/// Maximum diagnostic length retained from an operating-system error.
27const MAX_REASON_CHARS: usize = 512;
28/// Exact `whichdisk` Linux failure that permits decoded mount-inventory fallback.
29#[cfg(any(target_os = "linux", test))]
30const WHICH_DISK_NO_MOUNT_FOR_DEVICE: &str = "no mount point found for device";
31
32/// Known local filesystems for supported Windows hosts.
33#[cfg(any(windows, test))]
34const WINDOWS_LOCAL_FILESYSTEM_TYPES: &[&str] = &["exfat", "fat", "fat32", "ntfs", "refs"];
35/// Known local filesystems for supported Linux hosts and containers.
36#[cfg(any(target_os = "linux", test))]
37const LINUX_LOCAL_FILESYSTEM_TYPES: &[&str] = &[
38    "btrfs", "ext2", "ext3", "ext4", "f2fs", "overlay", "xfs", "zfs",
39];
40/// Known local filesystems for supported macOS hosts.
41#[cfg(any(target_os = "macos", test))]
42const MACOS_LOCAL_FILESYSTEM_TYPES: &[&str] =
43    &["apfs", "exfat", "fat", "fat32", "hfs", "hfs+", "msdos"];
44
45/// Known network or distributed filesystems that cannot host live WAL state.
46const UNSUPPORTED_FILESYSTEM_TYPES: &[&str] = &[
47    "9p",
48    "afs",
49    "ceph",
50    "cifs",
51    "davfs",
52    "glusterfs",
53    "lustre",
54    "nfs",
55    "nfs4",
56    "smb",
57    "smb2",
58    "smb3",
59    "smbfs",
60    "sshfs",
61    "webdav",
62];
63
64/// Network-backed FUSE families that are rejected without accepting all FUSE mounts.
65const UNSUPPORTED_FILESYSTEM_PREFIXES: &[&str] = &[
66    "fuse.ceph",
67    "fuse.davfs",
68    "fuse.glusterfs",
69    "fuse.lustre",
70    "fuse.sshfs",
71    "fuse.webdav",
72];
73
74/// Closed filesystem classification used by the connection gate.
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76enum FilesystemSupport {
77    /// The filesystem is a known local implementation with WAL-compatible primitives.
78    SupportedLocal,
79    /// The filesystem is known to be networked or distributed.
80    UnsupportedNetwork,
81    /// The filesystem could not be classified safely.
82    Uncertain,
83}
84
85/// Exact content-free location state captured before a database connection opens.
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub(crate) struct DatabaseLocation {
88    /// Whether the database path itself existed at inspection time.
89    pub(crate) database_exists: bool,
90    /// Canonical file or nearest-existing-parent path used for resolution.
91    canonical_probe: PathBuf,
92    /// Owning mount point.
93    mount_point: PathBuf,
94    /// Owning device identity.
95    device: OsString,
96    /// Normalized filesystem type.
97    filesystem_type: String,
98}
99
100/// Whether an already-open connection may establish WAL or must already use it.
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub(crate) enum JournalModePolicy {
103    /// Establish and verify WAL for a validated create/migration/open path.
104    EnsureWal,
105    /// Require the existing live database to already use WAL.
106    RequireWal,
107}
108
109/// Validate the database location without creating its parent or opening `SQLite`.
110///
111/// # Errors
112///
113/// Returns a typed error if local WAL-safe filesystem placement cannot be proven.
114pub fn validate_database_location(path: &Path) -> DbResult<()> {
115    inspect_database_location(path).map(drop)
116}
117
118/// Inspect the exact database path or its nearest existing parent.
119pub(crate) fn inspect_database_location(path: &Path) -> DbResult<DatabaseLocation> {
120    let absolute = absolute_database_path(path)?;
121    #[cfg(windows)]
122    reject_unsupported_windows_prefix(&absolute)?;
123    let (database_exists, probe) = database_probe_path(&absolute)?;
124    let (canonical_probe, mount_point, device, filesystem_type) =
125        resolve_filesystem_location(&absolute, &probe)?;
126
127    #[cfg(windows)]
128    if device.as_os_str() == mount_point.as_os_str() {
129        return Err(filesystem_uncertain(
130            &absolute,
131            Some(&mount_point),
132            nonempty_filesystem_type(&filesystem_type),
133            "Windows did not resolve a local volume identity".to_string(),
134        ));
135    }
136
137    match classify_filesystem_type(&filesystem_type) {
138        FilesystemSupport::SupportedLocal => Ok(DatabaseLocation {
139            database_exists,
140            canonical_probe,
141            mount_point,
142            device,
143            filesystem_type,
144        }),
145        FilesystemSupport::UnsupportedNetwork => Err(DbError::DatabaseFilesystemUnsupported {
146            path: absolute,
147            mount_point: Some(mount_point),
148            filesystem_type: nonempty_filesystem_type(&filesystem_type),
149        }),
150        FilesystemSupport::Uncertain => Err(filesystem_uncertain(
151            &absolute,
152            Some(&mount_point),
153            nonempty_filesystem_type(&filesystem_type),
154            "filesystem type is not in the supported local profile".to_string(),
155        )),
156    }
157}
158
159/// Resolve the canonical probe and its owning filesystem.
160fn resolve_filesystem_location(
161    absolute: &Path,
162    probe: &Path,
163) -> DbResult<(PathBuf, PathBuf, OsString, String)> {
164    match whichdisk::resolve(probe) {
165        Ok(resolved) => Ok((
166            resolved.canonical_path().to_path_buf(),
167            resolved.mount_point().to_path_buf(),
168            resolved.device().to_os_string(),
169            resolved.fs_type().trim().to_ascii_lowercase(),
170        )),
171        #[cfg(target_os = "linux")]
172        Err(source) if is_missing_device_mount(&source) => {
173            resolve_linux_mount_inventory(absolute, probe)
174        }
175        Err(source) => Err(filesystem_uncertain(
176            absolute,
177            None,
178            None,
179            format!("filesystem resolution failed: {}", bounded_reason(&source)),
180        )),
181    }
182}
183
184/// One decoded mount-inventory row used to select the canonical path owner.
185#[cfg(any(target_os = "linux", test))]
186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187struct MountInventoryCandidate<'a> {
188    /// Canonical mount path used for component-boundary ancestry.
189    mount_point: &'a Path,
190    /// Device identity reported by the mount inventory.
191    device: &'a OsStr,
192    /// Filesystem type reported by the mount inventory.
193    filesystem_type: &'a str,
194}
195
196/// Select the unique deepest component-boundary ancestor of a canonical probe.
197#[cfg(any(target_os = "linux", test))]
198fn select_mount_inventory_owner<'a>(
199    canonical_probe: &Path,
200    candidates: impl IntoIterator<Item = MountInventoryCandidate<'a>>,
201) -> Result<MountInventoryCandidate<'a>, &'static str> {
202    let mut best = None;
203    let mut best_depth = 0;
204    let mut conflict = false;
205
206    for candidate in candidates {
207        if !canonical_probe.starts_with(candidate.mount_point) {
208            continue;
209        }
210        let depth = candidate.mount_point.components().count();
211        if depth > best_depth {
212            best = Some(candidate);
213            best_depth = depth;
214            conflict = false;
215        } else if depth == best_depth && best.is_some_and(|current| current != candidate) {
216            conflict = true;
217        }
218    }
219
220    if conflict {
221        return Err("mount inventory has conflicting equally specific owners");
222    }
223    best.ok_or("mount inventory has no owner for the canonical probe")
224}
225
226/// Return whether `whichdisk` failed only because no device-number mount matched.
227#[cfg(any(target_os = "linux", test))]
228fn is_missing_device_mount(source: &io::Error) -> bool {
229    source.kind() == io::ErrorKind::NotFound && source.to_string() == WHICH_DISK_NO_MOUNT_FOR_DEVICE
230}
231
232/// Resolve a Linux mount from `whichdisk`'s decoded inventory after device mismatch.
233#[cfg(target_os = "linux")]
234fn resolve_linux_mount_inventory(
235    absolute: &Path,
236    probe: &Path,
237) -> DbResult<(PathBuf, PathBuf, OsString, String)> {
238    let canonical_probe = probe.canonicalize().map_err(|source| {
239        filesystem_uncertain(
240            absolute,
241            None,
242            None,
243            format!(
244                "fallback probe canonicalization failed: {}",
245                bounded_reason(&source)
246            ),
247        )
248    })?;
249    let mounts = whichdisk::list().map_err(|source| {
250        filesystem_uncertain(
251            absolute,
252            None,
253            None,
254            format!("mount inventory failed: {}", bounded_reason(&source)),
255        )
256    })?;
257    let selected = select_mount_inventory_owner(
258        &canonical_probe,
259        mounts.iter().map(|mount| MountInventoryCandidate {
260            mount_point: mount.mount_point(),
261            device: mount.device(),
262            filesystem_type: mount.fs_type(),
263        }),
264    )
265    .map_err(|reason| filesystem_uncertain(absolute, None, None, reason.to_string()))?;
266
267    Ok((
268        canonical_probe,
269        selected.mount_point.to_path_buf(),
270        selected.device.to_os_string(),
271        selected.filesystem_type.trim().to_ascii_lowercase(),
272    ))
273}
274
275/// Open one writable connection after revalidating the captured location.
276pub(crate) fn open_writable_connection(
277    path: &Path,
278    flags: OpenFlags,
279    expected_location: &DatabaseLocation,
280    busy_timeout: Duration,
281    journal_policy: JournalModePolicy,
282) -> DbResult<Connection> {
283    revalidate_database_location(path, expected_location)?;
284    let connection = Connection::open_with_flags(path, flags)?;
285    connection.busy_timeout(busy_timeout)?;
286    configure_writable_connection(&connection)?;
287    match journal_policy {
288        JournalModePolicy::EnsureWal => {
289            establish_wal_with_bounded_retry(&connection, busy_timeout)?;
290        }
291        JournalModePolicy::RequireWal => {}
292    }
293    verify_journal_mode(&connection)?;
294    connection.pragma_update(None, "synchronous", REQUIRED_SYNCHRONOUS_NAME)?;
295    verify_synchronous_mode(&connection)?;
296    verify_busy_timeout(&connection, busy_timeout)?;
297    Ok(connection)
298}
299
300/// Establish WAL while another validated opener may be migrating the same database.
301fn establish_wal_with_bounded_retry(
302    connection: &Connection,
303    busy_timeout: Duration,
304) -> DbResult<()> {
305    let deadline = Instant::now() + busy_timeout;
306    loop {
307        match connection.pragma_update(None, "journal_mode", REQUIRED_JOURNAL_MODE) {
308            Ok(()) => return Ok(()),
309            Err(error)
310                if matches!(
311                    error.sqlite_error_code(),
312                    Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
313                ) && Instant::now() < deadline =>
314            {
315                let remaining = deadline.saturating_duration_since(Instant::now());
316                std::thread::sleep(JOURNAL_MODE_RETRY_INTERVAL.min(remaining));
317            }
318            Err(error) => return Err(error.into()),
319        }
320    }
321}
322
323/// Open one read-only connection after revalidating the captured location.
324pub(crate) fn open_read_only_connection(
325    path: &Path,
326    expected_location: &DatabaseLocation,
327) -> DbResult<Connection> {
328    revalidate_database_location(path, expected_location)?;
329    let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
330    connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
331    connection.execute_batch("PRAGMA query_only = ON")?;
332    verify_query_only(&connection)?;
333    verify_busy_timeout(&connection, SQLITE_BUSY_TIMEOUT)?;
334    Ok(connection)
335}
336
337/// Verify that a validated current read snapshot observes the live WAL profile.
338pub(crate) fn verify_current_read_profile(connection: &Connection) -> DbResult<()> {
339    verify_journal_mode(connection)
340}
341
342/// Enable and verify connection-local foreign-key enforcement.
343pub(crate) fn configure_writable_connection(connection: &Connection) -> DbResult<()> {
344    connection.pragma_update(None, "foreign_keys", true)?;
345    let enabled =
346        connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
347    if enabled != 1 {
348        return Err(operating_profile_error("foreign_keys", "ON", enabled));
349    }
350    Ok(())
351}
352
353/// Re-resolve a database path immediately before opening it.
354fn revalidate_database_location(path: &Path, expected: &DatabaseLocation) -> DbResult<()> {
355    let found = inspect_database_location(path)?;
356    if &found == expected {
357        return Ok(());
358    }
359    Err(filesystem_uncertain(
360        &absolute_database_path(path)?,
361        Some(&found.mount_point),
362        nonempty_filesystem_type(&found.filesystem_type),
363        "filesystem location changed between preflight and connection open".to_string(),
364    ))
365}
366
367/// Resolve a relative database path without assuming a platform-specific root.
368fn absolute_database_path(path: &Path) -> DbResult<PathBuf> {
369    if path.is_absolute() {
370        return Ok(path.to_path_buf());
371    }
372    std::env::current_dir()
373        .map(|current| current.join(path))
374        .map_err(|source| {
375            filesystem_uncertain(
376                path,
377                None,
378                None,
379                format!(
380                    "current directory could not be resolved: {}",
381                    bounded_reason(&source)
382                ),
383            )
384        })
385}
386
387/// Return the existing database path or nearest existing parent for volume resolution.
388fn database_probe_path(path: &Path) -> DbResult<(bool, PathBuf)> {
389    match fs::symlink_metadata(path) {
390        Ok(_) => return Ok((true, path.to_path_buf())),
391        Err(source) if source.kind() == io::ErrorKind::NotFound => {}
392        Err(source) => {
393            return Err(filesystem_uncertain(
394                path,
395                None,
396                None,
397                format!(
398                    "database metadata could not be inspected: {}",
399                    bounded_reason(&source)
400                ),
401            ));
402        }
403    }
404
405    let mut candidate = path.parent();
406    while let Some(ancestor) = candidate {
407        match fs::symlink_metadata(ancestor) {
408            Ok(_) => return Ok((false, ancestor.to_path_buf())),
409            Err(source) if source.kind() == io::ErrorKind::NotFound => {
410                candidate = ancestor.parent();
411            }
412            Err(source) => {
413                return Err(filesystem_uncertain(
414                    path,
415                    None,
416                    None,
417                    format!(
418                        "parent metadata could not be inspected: {}",
419                        bounded_reason(&source)
420                    ),
421                ));
422            }
423        }
424    }
425    Err(filesystem_uncertain(
426        path,
427        None,
428        None,
429        "no existing parent is available for filesystem resolution".to_string(),
430    ))
431}
432
433/// Classify one filesystem type against the supported host profile.
434fn classify_filesystem_type(filesystem_type: &str) -> FilesystemSupport {
435    classify_filesystem_type_with_local(filesystem_type, supported_local_filesystem_types())
436}
437
438/// Classify one filesystem type against explicit local values for deterministic tests.
439fn classify_filesystem_type_with_local(
440    filesystem_type: &str,
441    supported_local: &[&str],
442) -> FilesystemSupport {
443    let normalized = filesystem_type.trim().to_ascii_lowercase();
444    if UNSUPPORTED_FILESYSTEM_TYPES.contains(&normalized.as_str())
445        || UNSUPPORTED_FILESYSTEM_PREFIXES
446            .iter()
447            .any(|prefix| normalized.starts_with(prefix))
448    {
449        return FilesystemSupport::UnsupportedNetwork;
450    }
451    if supported_local.contains(&normalized.as_str()) {
452        FilesystemSupport::SupportedLocal
453    } else {
454        FilesystemSupport::Uncertain
455    }
456}
457
458/// Known local filesystems for supported Windows hosts.
459#[cfg(windows)]
460fn supported_local_filesystem_types() -> &'static [&'static str] {
461    WINDOWS_LOCAL_FILESYSTEM_TYPES
462}
463
464/// Known local filesystems for supported Linux hosts and containers.
465#[cfg(target_os = "linux")]
466fn supported_local_filesystem_types() -> &'static [&'static str] {
467    LINUX_LOCAL_FILESYSTEM_TYPES
468}
469
470/// Known local filesystems for supported macOS hosts.
471#[cfg(target_os = "macos")]
472fn supported_local_filesystem_types() -> &'static [&'static str] {
473    MACOS_LOCAL_FILESYSTEM_TYPES
474}
475
476/// Conservatively reject unclassified filesystems on other targets.
477#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
478fn supported_local_filesystem_types() -> &'static [&'static str] {
479    &[]
480}
481
482/// Reject Windows UNC and mapped-drive state before resolving a remote path.
483#[cfg(windows)]
484fn reject_unsupported_windows_prefix(path: &Path) -> DbResult<()> {
485    let Some(Component::Prefix(prefix)) = path.components().next() else {
486        return Err(filesystem_uncertain(
487            path,
488            None,
489            None,
490            "absolute Windows database path has no volume prefix".to_string(),
491        ));
492    };
493    let drive = match prefix.kind() {
494        Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
495            return Err(DbError::DatabaseFilesystemUnsupported {
496                path: path.to_path_buf(),
497                mount_point: None,
498                filesystem_type: None,
499            });
500        }
501        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => letter,
502        Prefix::DeviceNS(_) | Prefix::Verbatim(_) => {
503            return Err(filesystem_uncertain(
504                path,
505                None,
506                None,
507                "Windows device path is not a supported database location".to_string(),
508            ));
509        }
510    };
511    let volumes = whichdisk::list().map_err(|source| {
512        filesystem_uncertain(
513            path,
514            None,
515            None,
516            format!(
517                "local Windows volume inventory failed: {}",
518                bounded_reason(&source)
519            ),
520        )
521    })?;
522    let local = volumes.iter().any(|volume| {
523        windows_drive_letter(volume.mount_point())
524            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(&drive))
525    });
526    if local {
527        Ok(())
528    } else {
529        Err(filesystem_uncertain(
530            path,
531            None,
532            None,
533            "Windows drive is not present in the local fixed/removable volume inventory"
534                .to_string(),
535        ))
536    }
537}
538
539/// Return the Windows drive letter from a mounted local volume path.
540#[cfg(windows)]
541fn windows_drive_letter(path: &Path) -> Option<u8> {
542    let Component::Prefix(prefix) = path.components().next()? else {
543        return None;
544    };
545    match prefix.kind() {
546        Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => Some(letter),
547        Prefix::UNC(_, _)
548        | Prefix::VerbatimUNC(_, _)
549        | Prefix::DeviceNS(_)
550        | Prefix::Verbatim(_) => None,
551    }
552}
553
554/// Verify the durable database journal mode.
555fn verify_journal_mode(connection: &Connection) -> DbResult<()> {
556    let found =
557        connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
558    if found.eq_ignore_ascii_case(REQUIRED_JOURNAL_MODE) {
559        Ok(())
560    } else {
561        Err(DbError::DatabaseOperatingProfile {
562            setting: "journal_mode",
563            expected: REQUIRED_JOURNAL_MODE.to_string(),
564            found,
565        })
566    }
567}
568
569/// Verify the connection-local durability mode.
570fn verify_synchronous_mode(connection: &Connection) -> DbResult<()> {
571    let found = connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
572    if found == REQUIRED_SYNCHRONOUS_MODE {
573        Ok(())
574    } else {
575        Err(operating_profile_error(
576            "synchronous",
577            REQUIRED_SYNCHRONOUS_NAME,
578            found,
579        ))
580    }
581}
582
583/// Verify that a read connection cannot issue database mutations.
584fn verify_query_only(connection: &Connection) -> DbResult<()> {
585    let found = connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
586    if found == 1 {
587        Ok(())
588    } else {
589        Err(operating_profile_error("query_only", "ON", found))
590    }
591}
592
593/// Verify the bounded ordinary-writer wait configured on the connection.
594fn verify_busy_timeout(connection: &Connection, expected: Duration) -> DbResult<()> {
595    let found = connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, i64>(0))?;
596    let expected_millis = expected.as_millis();
597    if let Ok(found_millis) = u128::try_from(found)
598        && found_millis == expected_millis
599    {
600        return Ok(());
601    }
602    Err(DbError::DatabaseOperatingProfile {
603        setting: "busy_timeout",
604        expected: expected_millis.to_string(),
605        found: found.to_string(),
606    })
607}
608
609/// Build a typed connection-profile postcondition error.
610fn operating_profile_error(setting: &'static str, expected: &'static str, found: i64) -> DbError {
611    DbError::DatabaseOperatingProfile {
612        setting,
613        expected: expected.to_string(),
614        found: found.to_string(),
615    }
616}
617
618/// Build a typed uncertain-filesystem error.
619fn filesystem_uncertain(
620    path: &Path,
621    mount_point: Option<&Path>,
622    filesystem_type: Option<String>,
623    reason: String,
624) -> DbError {
625    DbError::DatabaseFilesystemUncertain {
626        path: path.to_path_buf(),
627        mount_point: mount_point.map(Path::to_path_buf),
628        filesystem_type,
629        reason,
630    }
631}
632
633/// Return a non-empty filesystem type for diagnostics.
634fn nonempty_filesystem_type(filesystem_type: &str) -> Option<String> {
635    (!filesystem_type.is_empty()).then(|| filesystem_type.to_string())
636}
637
638/// Bound operating-system diagnostics without dropping their root cause.
639fn bounded_reason(source: &io::Error) -> String {
640    source.to_string().chars().take(MAX_REASON_CHARS).collect()
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use std::error::Error;
647
648    #[test]
649    fn filesystem_classification_distinguishes_local_remote_and_unknown() {
650        let local = ["apfs", "btrfs", "ext4", "ntfs", "overlay"];
651        assert_eq!(
652            classify_filesystem_type_with_local("EXT4", &local),
653            FilesystemSupport::SupportedLocal
654        );
655        assert_eq!(
656            classify_filesystem_type_with_local("btrfs", &local),
657            FilesystemSupport::SupportedLocal
658        );
659        assert_eq!(
660            classify_filesystem_type_with_local("fuse.sshfs", &local),
661            FilesystemSupport::UnsupportedNetwork
662        );
663        assert_eq!(
664            classify_filesystem_type_with_local("nfs4", &local),
665            FilesystemSupport::UnsupportedNetwork
666        );
667        assert_eq!(
668            classify_filesystem_type_with_local("", &local),
669            FilesystemSupport::Uncertain
670        );
671        assert_eq!(
672            classify_filesystem_type_with_local("unknown-local", &local),
673            FilesystemSupport::Uncertain
674        );
675    }
676
677    #[test]
678    fn btrfs_device_mismatch_uses_unique_path_owner() {
679        let root = MountInventoryCandidate {
680            mount_point: Path::new("/"),
681            device: OsStr::new("0:1"),
682            filesystem_type: "ext4",
683        };
684        let btrfs = MountInventoryCandidate {
685            mount_point: Path::new("/project"),
686            device: OsStr::new("0:34"),
687            filesystem_type: "btrfs",
688        };
689        let stat_device = OsStr::new("0:50");
690
691        assert_ne!(btrfs.device, stat_device);
692        assert_eq!(
693            select_mount_inventory_owner(Path::new("/project/repo/.projectatlas"), [root, btrfs]),
694            Ok(btrfs)
695        );
696    }
697
698    #[test]
699    fn mount_inventory_prefers_nested_component_ancestor() {
700        let root = MountInventoryCandidate {
701            mount_point: Path::new("/"),
702            device: OsStr::new("root"),
703            filesystem_type: "ext4",
704        };
705        let parent = MountInventoryCandidate {
706            mount_point: Path::new("/srv"),
707            device: OsStr::new("parent"),
708            filesystem_type: "btrfs",
709        };
710        let nested = MountInventoryCandidate {
711            mount_point: Path::new("/srv/data"),
712            device: OsStr::new("nested"),
713            filesystem_type: "xfs",
714        };
715
716        assert_eq!(
717            select_mount_inventory_owner(Path::new("/srv/data/project/db"), [root, parent, nested]),
718            Ok(nested)
719        );
720    }
721
722    #[test]
723    fn mount_inventory_rejects_string_prefix_and_equal_conflict() {
724        let root = MountInventoryCandidate {
725            mount_point: Path::new("/"),
726            device: OsStr::new("root"),
727            filesystem_type: "ext4",
728        };
729        let string_prefix = MountInventoryCandidate {
730            mount_point: Path::new("/project/app"),
731            device: OsStr::new("wrong"),
732            filesystem_type: "btrfs",
733        };
734        assert_eq!(
735            select_mount_inventory_owner(
736                Path::new("/project/application/db"),
737                [root, string_prefix]
738            ),
739            Ok(root)
740        );
741
742        let first = MountInventoryCandidate {
743            mount_point: Path::new("/project"),
744            device: OsStr::new("0:34"),
745            filesystem_type: "btrfs",
746        };
747        let conflicting = MountInventoryCandidate {
748            mount_point: Path::new("/project"),
749            device: OsStr::new("0:50"),
750            filesystem_type: "btrfs",
751        };
752        assert_eq!(
753            select_mount_inventory_owner(Path::new("/project/repo/db"), [first, conflicting]),
754            Err("mount inventory has conflicting equally specific owners")
755        );
756        assert_eq!(
757            select_mount_inventory_owner(Path::new("/project/repo/db"), [first, first]),
758            Ok(first)
759        );
760    }
761
762    #[test]
763    fn mount_inventory_handles_missing_and_multibyte_paths() {
764        assert_eq!(
765            select_mount_inventory_owner(
766                Path::new("/project/db"),
767                std::iter::empty::<MountInventoryCandidate<'static>>(),
768            ),
769            Err("mount inventory has no owner for the canonical probe")
770        );
771
772        let multibyte = MountInventoryCandidate {
773            mount_point: Path::new("/mnt/über"),
774            device: OsStr::new("0:77"),
775            filesystem_type: "btrfs",
776        };
777        assert_eq!(
778            select_mount_inventory_owner(Path::new("/mnt/über/projekt/db"), [multibyte]),
779            Ok(multibyte)
780        );
781    }
782
783    #[test]
784    fn fallback_is_limited_to_whichdisk_missing_device_mount() {
785        let missing_mount = io::Error::new(io::ErrorKind::NotFound, WHICH_DISK_NO_MOUNT_FOR_DEVICE);
786        let vanished_path = io::Error::new(io::ErrorKind::NotFound, "path vanished");
787        let permission = io::Error::new(io::ErrorKind::PermissionDenied, "permission denied");
788
789        assert!(is_missing_device_mount(&missing_mount));
790        assert!(!is_missing_device_mount(&vanished_path));
791        assert!(!is_missing_device_mount(&permission));
792    }
793
794    #[cfg(target_os = "linux")]
795    #[test]
796    fn fallback_canonicalization_failure_remains_uncertain() -> Result<(), Box<dyn Error>> {
797        let temp = tempfile::tempdir()?;
798        let missing = temp.path().join("missing");
799        let Err(error) = resolve_linux_mount_inventory(&missing, &missing) else {
800            return Err(io::Error::other("missing fallback probe was accepted").into());
801        };
802        let DbError::DatabaseFilesystemUncertain { reason, .. } = error else {
803            return Err(io::Error::other("unexpected fallback error classification").into());
804        };
805        if !reason.starts_with("fallback probe canonicalization failed:") {
806            return Err(io::Error::other("canonicalization cause was not preserved").into());
807        }
808        Ok(())
809    }
810
811    #[test]
812    fn platform_profiles_keep_ephemeral_memory_filesystems_out_of_durable_storage() {
813        assert!(!LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"ramfs"));
814        assert!(!LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"tmpfs"));
815        assert!(LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"overlay"));
816        assert!(WINDOWS_LOCAL_FILESYSTEM_TYPES.contains(&"ntfs"));
817        assert!(MACOS_LOCAL_FILESYSTEM_TYPES.contains(&"apfs"));
818    }
819
820    #[test]
821    fn missing_database_uses_nearest_existing_parent() -> Result<(), Box<dyn Error>> {
822        let temp = tempfile::tempdir()?;
823        let database = temp
824            .path()
825            .join("nested")
826            .join("atlas")
827            .join("projectatlas.db");
828        let location = inspect_database_location(&database)?;
829        if location.database_exists {
830            return Err(io::Error::other("missing database was reported as existing").into());
831        }
832        if location.canonical_probe != temp.path().canonicalize()? {
833            return Err(io::Error::other("nearest existing parent was not resolved").into());
834        }
835        if database.exists() {
836            return Err(io::Error::other("location inspection created the database").into());
837        }
838        Ok(())
839    }
840
841    #[test]
842    fn writable_wal_read_only_reopen_and_location_swap_are_enforced() -> Result<(), Box<dyn Error>>
843    {
844        let temp = tempfile::tempdir()?;
845        let database = temp.path().join("projectatlas.db");
846        let missing_location = inspect_database_location(&database)?;
847        let writer = open_writable_connection(
848            &database,
849            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
850            &missing_location,
851            SQLITE_BUSY_TIMEOUT,
852            JournalModePolicy::EnsureWal,
853        )?;
854        verify_current_read_profile(&writer)?;
855        drop(writer);
856
857        let existing_location = inspect_database_location(&database)?;
858        let reader = open_read_only_connection(&database, &existing_location)?;
859        verify_current_read_profile(&reader)?;
860        drop(reader);
861
862        let mut changed_location = existing_location;
863        changed_location.device = OsString::from("different-device");
864        if !matches!(
865            open_read_only_connection(&database, &changed_location),
866            Err(DbError::DatabaseFilesystemUncertain { .. })
867        ) {
868            return Err(io::Error::other("changed filesystem location was accepted").into());
869        }
870        Ok(())
871    }
872
873    #[cfg(windows)]
874    #[test]
875    fn unc_database_path_is_rejected_before_resolution() {
876        let database = PathBuf::from(r"\\server\share\projectatlas.db");
877        assert!(matches!(
878            reject_unsupported_windows_prefix(&database),
879            Err(DbError::DatabaseFilesystemUnsupported { .. })
880        ));
881    }
882}