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};
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
14/// Required journal mode for live project databases.
15pub(crate) const REQUIRED_JOURNAL_MODE: &str = "wal";
16/// Required synchronous mode for authored and derived project state.
17const REQUIRED_SYNCHRONOUS_MODE: i64 = 2;
18/// Human-readable name for the required synchronous mode.
19pub(crate) const REQUIRED_SYNCHRONOUS_NAME: &str = "FULL";
20/// Maximum pause between bounded retries while another opener establishes WAL.
21const JOURNAL_MODE_RETRY_INTERVAL: Duration = Duration::from_millis(10);
22/// Maximum time ordinary read and write connections wait for database contention.
23pub(crate) const SQLITE_BUSY_TIMEOUT: Duration = Duration::from_secs(5);
24/// Maximum diagnostic length retained from an operating-system error.
25const MAX_REASON_CHARS: usize = 512;
26
27/// Known local filesystems for supported Windows hosts.
28#[cfg(any(windows, test))]
29const WINDOWS_LOCAL_FILESYSTEM_TYPES: &[&str] = &["exfat", "fat", "fat32", "ntfs", "refs"];
30/// Known local filesystems for supported Linux hosts and containers.
31#[cfg(any(target_os = "linux", test))]
32const LINUX_LOCAL_FILESYSTEM_TYPES: &[&str] = &[
33    "btrfs", "ext2", "ext3", "ext4", "f2fs", "overlay", "xfs", "zfs",
34];
35/// Known local filesystems for supported macOS hosts.
36#[cfg(any(target_os = "macos", test))]
37const MACOS_LOCAL_FILESYSTEM_TYPES: &[&str] =
38    &["apfs", "exfat", "fat", "fat32", "hfs", "hfs+", "msdos"];
39
40/// Known network or distributed filesystems that cannot host live WAL state.
41const 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
59/// Network-backed FUSE families that are rejected without accepting all FUSE mounts.
60const 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/// Closed filesystem classification used by the connection gate.
70#[derive(Clone, Copy, Debug, Eq, PartialEq)]
71enum FilesystemSupport {
72    /// The filesystem is a known local implementation with WAL-compatible primitives.
73    SupportedLocal,
74    /// The filesystem is known to be networked or distributed.
75    UnsupportedNetwork,
76    /// The filesystem could not be classified safely.
77    Uncertain,
78}
79
80/// Exact content-free location state captured before a database connection opens.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub(crate) struct DatabaseLocation {
83    /// Whether the database path itself existed at inspection time.
84    pub(crate) database_exists: bool,
85    /// Canonical file or nearest-existing-parent path used for resolution.
86    canonical_probe: PathBuf,
87    /// Owning mount point.
88    mount_point: PathBuf,
89    /// Owning device identity.
90    device: OsString,
91    /// Normalized filesystem type.
92    filesystem_type: String,
93}
94
95/// Whether an already-open connection may establish WAL or must already use it.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub(crate) enum JournalModePolicy {
98    /// Establish and verify WAL for a validated create/migration/open path.
99    EnsureWal,
100    /// Require the existing live database to already use WAL.
101    RequireWal,
102}
103
104/// Validate the database location without creating its parent or opening `SQLite`.
105///
106/// # Errors
107///
108/// Returns a typed error if local WAL-safe filesystem placement cannot be proven.
109pub fn validate_database_location(path: &Path) -> DbResult<()> {
110    inspect_database_location(path).map(drop)
111}
112
113/// Inspect the exact database path or its nearest existing parent.
114pub(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
163/// Open one writable connection after revalidating the captured location.
164pub(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
188/// Establish WAL while another validated opener may be migrating the same database.
189fn 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
211/// Open one read-only connection after revalidating the captured location.
212pub(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
225/// Verify that a validated current read snapshot observes the live WAL profile.
226pub(crate) fn verify_current_read_profile(connection: &Connection) -> DbResult<()> {
227    verify_journal_mode(connection)
228}
229
230/// Enable and verify connection-local foreign-key enforcement.
231pub(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
241/// Re-resolve a database path immediately before opening it.
242fn 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
255/// Resolve a relative database path without assuming a platform-specific root.
256fn 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
275/// Return the existing database path or nearest existing parent for volume resolution.
276fn 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
321/// Classify one filesystem type against the supported host profile.
322fn classify_filesystem_type(filesystem_type: &str) -> FilesystemSupport {
323    classify_filesystem_type_with_local(filesystem_type, supported_local_filesystem_types())
324}
325
326/// Classify one filesystem type against explicit local values for deterministic tests.
327fn 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/// Known local filesystems for supported Windows hosts.
347#[cfg(windows)]
348fn supported_local_filesystem_types() -> &'static [&'static str] {
349    WINDOWS_LOCAL_FILESYSTEM_TYPES
350}
351
352/// Known local filesystems for supported Linux hosts and containers.
353#[cfg(target_os = "linux")]
354fn supported_local_filesystem_types() -> &'static [&'static str] {
355    LINUX_LOCAL_FILESYSTEM_TYPES
356}
357
358/// Known local filesystems for supported macOS hosts.
359#[cfg(target_os = "macos")]
360fn supported_local_filesystem_types() -> &'static [&'static str] {
361    MACOS_LOCAL_FILESYSTEM_TYPES
362}
363
364/// Conservatively reject unclassified filesystems on other targets.
365#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
366fn supported_local_filesystem_types() -> &'static [&'static str] {
367    &[]
368}
369
370/// Reject Windows UNC and mapped-drive state before resolving a remote path.
371#[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/// Return the Windows drive letter from a mounted local volume path.
428#[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
442/// Verify the durable database journal mode.
443fn 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
457/// Verify the connection-local durability mode.
458fn 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
471/// Verify that a read connection cannot issue database mutations.
472fn 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
481/// Verify the bounded ordinary-writer wait configured on the connection.
482fn 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
496/// Build a typed connection-profile postcondition error.
497fn 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
505/// Build a typed uncertain-filesystem error.
506fn 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
520/// Return a non-empty filesystem type for diagnostics.
521fn nonempty_filesystem_type(filesystem_type: &str) -> Option<String> {
522    (!filesystem_type.is_empty()).then(|| filesystem_type.to_string())
523}
524
525/// Bound operating-system diagnostics without dropping their root cause.
526fn 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}