1use crate::{DbError, DbResult};
4use rusqlite::{Connection, ErrorCode, OpenFlags};
5use std::ffi::OsString;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11#[cfg(windows)]
12use std::path::{Component, Prefix};
13
14pub(crate) const REQUIRED_JOURNAL_MODE: &str = "wal";
16const REQUIRED_SYNCHRONOUS_MODE: i64 = 2;
18pub(crate) const REQUIRED_SYNCHRONOUS_NAME: &str = "FULL";
20const JOURNAL_MODE_RETRY_INTERVAL: Duration = Duration::from_millis(10);
22pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
24const MAX_REASON_CHARS: usize = 512;
26
27#[cfg(any(windows, test))]
29const WINDOWS_LOCAL_FILESYSTEM_TYPES: &[&str] = &["exfat", "fat", "fat32", "ntfs", "refs"];
30#[cfg(any(target_os = "linux", test))]
32const LINUX_LOCAL_FILESYSTEM_TYPES: &[&str] = &[
33 "btrfs", "ext2", "ext3", "ext4", "f2fs", "overlay", "xfs", "zfs",
34];
35#[cfg(any(target_os = "macos", test))]
37const MACOS_LOCAL_FILESYSTEM_TYPES: &[&str] =
38 &["apfs", "exfat", "fat", "fat32", "hfs", "hfs+", "msdos"];
39
40const UNSUPPORTED_FILESYSTEM_TYPES: &[&str] = &[
42 "9p",
43 "afs",
44 "ceph",
45 "cifs",
46 "davfs",
47 "glusterfs",
48 "lustre",
49 "nfs",
50 "nfs4",
51 "smb",
52 "smb2",
53 "smb3",
54 "smbfs",
55 "sshfs",
56 "webdav",
57];
58
59const UNSUPPORTED_FILESYSTEM_PREFIXES: &[&str] = &[
61 "fuse.ceph",
62 "fuse.davfs",
63 "fuse.glusterfs",
64 "fuse.lustre",
65 "fuse.sshfs",
66 "fuse.webdav",
67];
68
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71enum FilesystemSupport {
72 SupportedLocal,
74 UnsupportedNetwork,
76 Uncertain,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub(crate) struct DatabaseLocation {
83 pub(crate) database_exists: bool,
85 canonical_probe: PathBuf,
87 mount_point: PathBuf,
89 device: OsString,
91 filesystem_type: String,
93}
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub(crate) enum JournalModePolicy {
98 EnsureWal,
100 RequireWal,
102}
103
104pub fn validate_database_location(path: &Path) -> DbResult<()> {
110 inspect_database_location(path).map(drop)
111}
112
113pub(crate) fn inspect_database_location(path: &Path) -> DbResult<DatabaseLocation> {
115 let absolute = absolute_database_path(path)?;
116 #[cfg(windows)]
117 reject_unsupported_windows_prefix(&absolute)?;
118 let (database_exists, probe) = database_probe_path(&absolute)?;
119 let resolved = whichdisk::resolve(&probe).map_err(|source| {
120 filesystem_uncertain(
121 &absolute,
122 None,
123 None,
124 format!("filesystem resolution failed: {}", bounded_reason(&source)),
125 )
126 })?;
127 let mount_point = resolved.mount_point().to_path_buf();
128 let device = resolved.device().to_os_string();
129 let filesystem_type = resolved.fs_type().trim().to_ascii_lowercase();
130
131 #[cfg(windows)]
132 if device.as_os_str() == mount_point.as_os_str() {
133 return Err(filesystem_uncertain(
134 &absolute,
135 Some(&mount_point),
136 nonempty_filesystem_type(&filesystem_type),
137 "Windows did not resolve a local volume identity".to_string(),
138 ));
139 }
140
141 match classify_filesystem_type(&filesystem_type) {
142 FilesystemSupport::SupportedLocal => Ok(DatabaseLocation {
143 database_exists,
144 canonical_probe: resolved.canonical_path().to_path_buf(),
145 mount_point,
146 device,
147 filesystem_type,
148 }),
149 FilesystemSupport::UnsupportedNetwork => Err(DbError::DatabaseFilesystemUnsupported {
150 path: absolute,
151 mount_point: Some(mount_point),
152 filesystem_type: nonempty_filesystem_type(&filesystem_type),
153 }),
154 FilesystemSupport::Uncertain => Err(filesystem_uncertain(
155 &absolute,
156 Some(&mount_point),
157 nonempty_filesystem_type(&filesystem_type),
158 "filesystem type is not in the supported local profile".to_string(),
159 )),
160 }
161}
162
163pub(crate) fn open_writable_connection(
165 path: &Path,
166 flags: OpenFlags,
167 expected_location: &DatabaseLocation,
168 busy_timeout: Duration,
169 journal_policy: JournalModePolicy,
170) -> DbResult<Connection> {
171 revalidate_database_location(path, expected_location)?;
172 let connection = Connection::open_with_flags(path, flags)?;
173 connection.busy_timeout(busy_timeout)?;
174 configure_writable_connection(&connection)?;
175 match journal_policy {
176 JournalModePolicy::EnsureWal => {
177 establish_wal_with_bounded_retry(&connection, busy_timeout)?;
178 }
179 JournalModePolicy::RequireWal => {}
180 }
181 verify_journal_mode(&connection)?;
182 connection.pragma_update(None, "synchronous", REQUIRED_SYNCHRONOUS_NAME)?;
183 verify_synchronous_mode(&connection)?;
184 verify_busy_timeout(&connection, busy_timeout)?;
185 Ok(connection)
186}
187
188fn establish_wal_with_bounded_retry(
190 connection: &Connection,
191 busy_timeout: Duration,
192) -> DbResult<()> {
193 let deadline = Instant::now() + busy_timeout;
194 loop {
195 match connection.pragma_update(None, "journal_mode", REQUIRED_JOURNAL_MODE) {
196 Ok(()) => return Ok(()),
197 Err(error)
198 if matches!(
199 error.sqlite_error_code(),
200 Some(ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
201 ) && Instant::now() < deadline =>
202 {
203 let remaining = deadline.saturating_duration_since(Instant::now());
204 std::thread::sleep(JOURNAL_MODE_RETRY_INTERVAL.min(remaining));
205 }
206 Err(error) => return Err(error.into()),
207 }
208 }
209}
210
211pub(crate) fn open_read_only_connection(
213 path: &Path,
214 expected_location: &DatabaseLocation,
215) -> DbResult<Connection> {
216 revalidate_database_location(path, expected_location)?;
217 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
218 connection.busy_timeout(SQLITE_BUSY_TIMEOUT)?;
219 connection.execute_batch("PRAGMA query_only = ON")?;
220 verify_query_only(&connection)?;
221 verify_busy_timeout(&connection, SQLITE_BUSY_TIMEOUT)?;
222 Ok(connection)
223}
224
225pub(crate) fn verify_current_read_profile(connection: &Connection) -> DbResult<()> {
227 verify_journal_mode(connection)
228}
229
230pub(crate) fn configure_writable_connection(connection: &Connection) -> DbResult<()> {
232 connection.pragma_update(None, "foreign_keys", true)?;
233 let enabled =
234 connection.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))?;
235 if enabled != 1 {
236 return Err(operating_profile_error("foreign_keys", "ON", enabled));
237 }
238 Ok(())
239}
240
241fn revalidate_database_location(path: &Path, expected: &DatabaseLocation) -> DbResult<()> {
243 let found = inspect_database_location(path)?;
244 if &found == expected {
245 return Ok(());
246 }
247 Err(filesystem_uncertain(
248 &absolute_database_path(path)?,
249 Some(&found.mount_point),
250 nonempty_filesystem_type(&found.filesystem_type),
251 "filesystem location changed between preflight and connection open".to_string(),
252 ))
253}
254
255fn absolute_database_path(path: &Path) -> DbResult<PathBuf> {
257 if path.is_absolute() {
258 return Ok(path.to_path_buf());
259 }
260 std::env::current_dir()
261 .map(|current| current.join(path))
262 .map_err(|source| {
263 filesystem_uncertain(
264 path,
265 None,
266 None,
267 format!(
268 "current directory could not be resolved: {}",
269 bounded_reason(&source)
270 ),
271 )
272 })
273}
274
275fn database_probe_path(path: &Path) -> DbResult<(bool, PathBuf)> {
277 match fs::symlink_metadata(path) {
278 Ok(_) => return Ok((true, path.to_path_buf())),
279 Err(source) if source.kind() == io::ErrorKind::NotFound => {}
280 Err(source) => {
281 return Err(filesystem_uncertain(
282 path,
283 None,
284 None,
285 format!(
286 "database metadata could not be inspected: {}",
287 bounded_reason(&source)
288 ),
289 ));
290 }
291 }
292
293 let mut candidate = path.parent();
294 while let Some(ancestor) = candidate {
295 match fs::symlink_metadata(ancestor) {
296 Ok(_) => return Ok((false, ancestor.to_path_buf())),
297 Err(source) if source.kind() == io::ErrorKind::NotFound => {
298 candidate = ancestor.parent();
299 }
300 Err(source) => {
301 return Err(filesystem_uncertain(
302 path,
303 None,
304 None,
305 format!(
306 "parent metadata could not be inspected: {}",
307 bounded_reason(&source)
308 ),
309 ));
310 }
311 }
312 }
313 Err(filesystem_uncertain(
314 path,
315 None,
316 None,
317 "no existing parent is available for filesystem resolution".to_string(),
318 ))
319}
320
321fn classify_filesystem_type(filesystem_type: &str) -> FilesystemSupport {
323 classify_filesystem_type_with_local(filesystem_type, supported_local_filesystem_types())
324}
325
326fn classify_filesystem_type_with_local(
328 filesystem_type: &str,
329 supported_local: &[&str],
330) -> FilesystemSupport {
331 let normalized = filesystem_type.trim().to_ascii_lowercase();
332 if UNSUPPORTED_FILESYSTEM_TYPES.contains(&normalized.as_str())
333 || UNSUPPORTED_FILESYSTEM_PREFIXES
334 .iter()
335 .any(|prefix| normalized.starts_with(prefix))
336 {
337 return FilesystemSupport::UnsupportedNetwork;
338 }
339 if supported_local.contains(&normalized.as_str()) {
340 FilesystemSupport::SupportedLocal
341 } else {
342 FilesystemSupport::Uncertain
343 }
344}
345
346#[cfg(windows)]
348fn supported_local_filesystem_types() -> &'static [&'static str] {
349 WINDOWS_LOCAL_FILESYSTEM_TYPES
350}
351
352#[cfg(target_os = "linux")]
354fn supported_local_filesystem_types() -> &'static [&'static str] {
355 LINUX_LOCAL_FILESYSTEM_TYPES
356}
357
358#[cfg(target_os = "macos")]
360fn supported_local_filesystem_types() -> &'static [&'static str] {
361 MACOS_LOCAL_FILESYSTEM_TYPES
362}
363
364#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
366fn supported_local_filesystem_types() -> &'static [&'static str] {
367 &[]
368}
369
370#[cfg(windows)]
372fn reject_unsupported_windows_prefix(path: &Path) -> DbResult<()> {
373 let Some(Component::Prefix(prefix)) = path.components().next() else {
374 return Err(filesystem_uncertain(
375 path,
376 None,
377 None,
378 "absolute Windows database path has no volume prefix".to_string(),
379 ));
380 };
381 let drive = match prefix.kind() {
382 Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _) => {
383 return Err(DbError::DatabaseFilesystemUnsupported {
384 path: path.to_path_buf(),
385 mount_point: None,
386 filesystem_type: None,
387 });
388 }
389 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => letter,
390 Prefix::DeviceNS(_) | Prefix::Verbatim(_) => {
391 return Err(filesystem_uncertain(
392 path,
393 None,
394 None,
395 "Windows device path is not a supported database location".to_string(),
396 ));
397 }
398 };
399 let volumes = whichdisk::list().map_err(|source| {
400 filesystem_uncertain(
401 path,
402 None,
403 None,
404 format!(
405 "local Windows volume inventory failed: {}",
406 bounded_reason(&source)
407 ),
408 )
409 })?;
410 let local = volumes.iter().any(|volume| {
411 windows_drive_letter(volume.mount_point())
412 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(&drive))
413 });
414 if local {
415 Ok(())
416 } else {
417 Err(filesystem_uncertain(
418 path,
419 None,
420 None,
421 "Windows drive is not present in the local fixed/removable volume inventory"
422 .to_string(),
423 ))
424 }
425}
426
427#[cfg(windows)]
429fn windows_drive_letter(path: &Path) -> Option<u8> {
430 let Component::Prefix(prefix) = path.components().next()? else {
431 return None;
432 };
433 match prefix.kind() {
434 Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => Some(letter),
435 Prefix::UNC(_, _)
436 | Prefix::VerbatimUNC(_, _)
437 | Prefix::DeviceNS(_)
438 | Prefix::Verbatim(_) => None,
439 }
440}
441
442fn verify_journal_mode(connection: &Connection) -> DbResult<()> {
444 let found =
445 connection.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))?;
446 if found.eq_ignore_ascii_case(REQUIRED_JOURNAL_MODE) {
447 Ok(())
448 } else {
449 Err(DbError::DatabaseOperatingProfile {
450 setting: "journal_mode",
451 expected: REQUIRED_JOURNAL_MODE.to_string(),
452 found,
453 })
454 }
455}
456
457fn verify_synchronous_mode(connection: &Connection) -> DbResult<()> {
459 let found = connection.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0))?;
460 if found == REQUIRED_SYNCHRONOUS_MODE {
461 Ok(())
462 } else {
463 Err(operating_profile_error(
464 "synchronous",
465 REQUIRED_SYNCHRONOUS_NAME,
466 found,
467 ))
468 }
469}
470
471fn verify_query_only(connection: &Connection) -> DbResult<()> {
473 let found = connection.pragma_query_value(None, "query_only", |row| row.get::<_, i64>(0))?;
474 if found == 1 {
475 Ok(())
476 } else {
477 Err(operating_profile_error("query_only", "ON", found))
478 }
479}
480
481fn verify_busy_timeout(connection: &Connection, expected: Duration) -> DbResult<()> {
483 let found = connection.pragma_query_value(None, "busy_timeout", |row| row.get::<_, u64>(0))?;
484 let expected_millis = expected.as_millis();
485 if u128::from(found) == expected_millis {
486 Ok(())
487 } else {
488 Err(DbError::DatabaseOperatingProfile {
489 setting: "busy_timeout",
490 expected: expected_millis.to_string(),
491 found: found.to_string(),
492 })
493 }
494}
495
496fn operating_profile_error(setting: &'static str, expected: &'static str, found: i64) -> DbError {
498 DbError::DatabaseOperatingProfile {
499 setting,
500 expected: expected.to_string(),
501 found: found.to_string(),
502 }
503}
504
505fn filesystem_uncertain(
507 path: &Path,
508 mount_point: Option<&Path>,
509 filesystem_type: Option<String>,
510 reason: String,
511) -> DbError {
512 DbError::DatabaseFilesystemUncertain {
513 path: path.to_path_buf(),
514 mount_point: mount_point.map(Path::to_path_buf),
515 filesystem_type,
516 reason,
517 }
518}
519
520fn nonempty_filesystem_type(filesystem_type: &str) -> Option<String> {
522 (!filesystem_type.is_empty()).then(|| filesystem_type.to_string())
523}
524
525fn bounded_reason(source: &io::Error) -> String {
527 source.to_string().chars().take(MAX_REASON_CHARS).collect()
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use std::error::Error;
534
535 #[test]
536 fn filesystem_classification_distinguishes_local_remote_and_unknown() {
537 let local = ["apfs", "ext4", "ntfs", "overlay"];
538 assert_eq!(
539 classify_filesystem_type_with_local("EXT4", &local),
540 FilesystemSupport::SupportedLocal
541 );
542 assert_eq!(
543 classify_filesystem_type_with_local("fuse.sshfs", &local),
544 FilesystemSupport::UnsupportedNetwork
545 );
546 assert_eq!(
547 classify_filesystem_type_with_local("", &local),
548 FilesystemSupport::Uncertain
549 );
550 assert_eq!(
551 classify_filesystem_type_with_local("unknown-local", &local),
552 FilesystemSupport::Uncertain
553 );
554 }
555
556 #[test]
557 fn platform_profiles_keep_ephemeral_memory_filesystems_out_of_durable_storage() {
558 assert!(!LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"ramfs"));
559 assert!(!LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"tmpfs"));
560 assert!(LINUX_LOCAL_FILESYSTEM_TYPES.contains(&"overlay"));
561 assert!(WINDOWS_LOCAL_FILESYSTEM_TYPES.contains(&"ntfs"));
562 assert!(MACOS_LOCAL_FILESYSTEM_TYPES.contains(&"apfs"));
563 }
564
565 #[test]
566 fn missing_database_uses_nearest_existing_parent() -> Result<(), Box<dyn Error>> {
567 let temp = tempfile::tempdir()?;
568 let database = temp
569 .path()
570 .join("nested")
571 .join("atlas")
572 .join("projectatlas.db");
573 let location = inspect_database_location(&database)?;
574 if location.database_exists {
575 return Err(io::Error::other("missing database was reported as existing").into());
576 }
577 if location.canonical_probe != temp.path().canonicalize()? {
578 return Err(io::Error::other("nearest existing parent was not resolved").into());
579 }
580 if database.exists() {
581 return Err(io::Error::other("location inspection created the database").into());
582 }
583 Ok(())
584 }
585
586 #[cfg(windows)]
587 #[test]
588 fn unc_database_path_is_rejected_before_resolution() {
589 let database = PathBuf::from(r"\\server\share\projectatlas.db");
590 assert!(matches!(
591 reject_unsupported_windows_prefix(&database),
592 Err(DbError::DatabaseFilesystemUnsupported { .. })
593 ));
594 }
595}