1use 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
16pub(crate) const REQUIRED_JOURNAL_MODE: &str = "wal";
18const REQUIRED_SYNCHRONOUS_MODE: i64 = 2;
20pub(crate) const REQUIRED_SYNCHRONOUS_NAME: &str = "FULL";
22const JOURNAL_MODE_RETRY_INTERVAL: Duration = Duration::from_millis(10);
24pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
26const MAX_REASON_CHARS: usize = 512;
28#[cfg(any(target_os = "linux", test))]
30const WHICH_DISK_NO_MOUNT_FOR_DEVICE: &str = "no mount point found for device";
31
32#[cfg(any(windows, test))]
34const WINDOWS_LOCAL_FILESYSTEM_TYPES: &[&str] = &["exfat", "fat", "fat32", "ntfs", "refs"];
35#[cfg(any(target_os = "linux", test))]
37const LINUX_LOCAL_FILESYSTEM_TYPES: &[&str] = &[
38 "btrfs", "ext2", "ext3", "ext4", "f2fs", "overlay", "xfs", "zfs",
39];
40#[cfg(any(target_os = "macos", test))]
42const MACOS_LOCAL_FILESYSTEM_TYPES: &[&str] =
43 &["apfs", "exfat", "fat", "fat32", "hfs", "hfs+", "msdos"];
44
45const 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
64const 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76enum FilesystemSupport {
77 SupportedLocal,
79 UnsupportedNetwork,
81 Uncertain,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
87pub(crate) struct DatabaseLocation {
88 pub(crate) database_exists: bool,
90 canonical_probe: PathBuf,
92 mount_point: PathBuf,
94 device: OsString,
96 filesystem_type: String,
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub(crate) enum JournalModePolicy {
103 EnsureWal,
105 RequireWal,
107}
108
109pub fn validate_database_location(path: &Path) -> DbResult<()> {
115 inspect_database_location(path).map(drop)
116}
117
118pub(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
159fn 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#[cfg(any(target_os = "linux", test))]
186#[derive(Clone, Copy, Debug, Eq, PartialEq)]
187struct MountInventoryCandidate<'a> {
188 mount_point: &'a Path,
190 device: &'a OsStr,
192 filesystem_type: &'a str,
194}
195
196#[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#[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#[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
275pub(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
300fn 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
323pub(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
337pub(crate) fn verify_current_read_profile(connection: &Connection) -> DbResult<()> {
339 verify_journal_mode(connection)
340}
341
342pub(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
353fn 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
367fn 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
387fn 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
433fn classify_filesystem_type(filesystem_type: &str) -> FilesystemSupport {
435 classify_filesystem_type_with_local(filesystem_type, supported_local_filesystem_types())
436}
437
438fn 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#[cfg(windows)]
460fn supported_local_filesystem_types() -> &'static [&'static str] {
461 WINDOWS_LOCAL_FILESYSTEM_TYPES
462}
463
464#[cfg(target_os = "linux")]
466fn supported_local_filesystem_types() -> &'static [&'static str] {
467 LINUX_LOCAL_FILESYSTEM_TYPES
468}
469
470#[cfg(target_os = "macos")]
472fn supported_local_filesystem_types() -> &'static [&'static str] {
473 MACOS_LOCAL_FILESYSTEM_TYPES
474}
475
476#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
478fn supported_local_filesystem_types() -> &'static [&'static str] {
479 &[]
480}
481
482#[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#[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
554fn 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
569fn 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
583fn 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
593fn 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
609fn 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
618fn 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
633fn nonempty_filesystem_type(filesystem_type: &str) -> Option<String> {
635 (!filesystem_type.is_empty()).then(|| filesystem_type.to_string())
636}
637
638fn 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}