Skip to main content

uqa_storage/sqlite/
detect.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! On-disk database format detection.
8//!
9//! `usql` and embedding applications need to decide which `Engine`
10//! open variant fits an existing file before they have a key in hand:
11//! plaintext `SQLite` and UQA compressed containers carry cleartext
12//! magic bytes, while `SQLCipher` encrypts the whole file (including the
13//! `SQLite` header), so an encrypted catalog is indistinguishable from a
14//! non-database file without attempting a keyed open.
15
16use std::fs::File;
17use std::io::Read;
18use std::path::Path;
19
20use crate::sqlite::compressed_vfs::{FLAG_ENCRYPTED, HEADER_FLAGS_OFFSET, LEGACY_MAGIC, MAGIC};
21
22/// First 16 bytes of every plaintext `SQLite` database file.
23const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
24
25/// Number of leading bytes required to classify a file: the `SQLite`
26/// magic is 16 bytes and the compressed-container flags word ends at
27/// byte 16 as well.
28const DETECT_PREFIX_LEN: usize = 16;
29
30/// On-disk format of a database file, detected from its header bytes.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum DatabaseFileFormat {
33    /// The file does not exist or is empty. Opening it creates a new
34    /// database.
35    Missing,
36    /// A plaintext `SQLite` database (`SQLite format 3\0` magic).
37    PlainSQLite,
38    /// A UQA compressed container (`UQACDB2\0`, or legacy `UQACDB1\0`,
39    /// magic). `encrypted` reflects the header flag: when set, opening
40    /// requires a key.
41    CompressedContainer { encrypted: bool },
42    /// No recognizable magic. Either a `SQLCipher`-encrypted database or
43    /// not a database at all; the two cannot be told apart without
44    /// attempting an open with a key.
45    Unrecognized,
46}
47
48impl DatabaseFileFormat {
49    /// Whether opening this file is known to require an encryption
50    /// key. `Unrecognized` returns `true` because the dominant cause
51    /// for an unrecognized header on a database path is `SQLCipher`
52    /// encryption.
53    #[must_use]
54    pub fn requires_key(self) -> bool {
55        matches!(
56            self,
57            Self::CompressedContainer { encrypted: true } | Self::Unrecognized
58        )
59    }
60}
61
62/// Classify the on-disk format of `path` by reading its first bytes.
63///
64/// Only returns `Err` for I/O failures other than "file not found";
65/// a missing or empty file is reported as [`DatabaseFileFormat::Missing`].
66pub fn detect_database_file_format(path: &Path) -> std::io::Result<DatabaseFileFormat> {
67    let mut file = match File::open(path) {
68        Ok(file) => file,
69        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
70            return Ok(DatabaseFileFormat::Missing);
71        }
72        Err(err) => return Err(err),
73    };
74    let mut prefix = [0_u8; DETECT_PREFIX_LEN];
75    let mut read = 0;
76    while read < prefix.len() {
77        let n = file.read(&mut prefix[read..])?;
78        if n == 0 {
79            break;
80        }
81        read += n;
82    }
83    if read == 0 {
84        return Ok(DatabaseFileFormat::Missing);
85    }
86    if read < DETECT_PREFIX_LEN {
87        // Too short for any valid database header.
88        return Ok(DatabaseFileFormat::Unrecognized);
89    }
90    if &prefix == SQLITE_MAGIC {
91        return Ok(DatabaseFileFormat::PlainSQLite);
92    }
93    if &prefix[..MAGIC.len()] == MAGIC || &prefix[..LEGACY_MAGIC.len()] == LEGACY_MAGIC {
94        let flags = u32::from_le_bytes([
95            prefix[HEADER_FLAGS_OFFSET],
96            prefix[HEADER_FLAGS_OFFSET + 1],
97            prefix[HEADER_FLAGS_OFFSET + 2],
98            prefix[HEADER_FLAGS_OFFSET + 3],
99        ]);
100        return Ok(DatabaseFileFormat::CompressedContainer {
101            encrypted: flags & FLAG_ENCRYPTED != 0,
102        });
103    }
104    Ok(DatabaseFileFormat::Unrecognized)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    use crate::sqlite::compressed_vfs::SQLiteCompressionOptions;
112    use crate::sqlite::connection::ManagedConnection;
113
114    fn temp_dir() -> tempfile::TempDir {
115        tempfile::tempdir().expect("tempdir")
116    }
117
118    #[test]
119    fn missing_file_detects_as_missing() {
120        let dir = temp_dir();
121        let path = dir.path().join("absent.db");
122        assert_eq!(
123            detect_database_file_format(&path).unwrap(),
124            DatabaseFileFormat::Missing
125        );
126    }
127
128    #[test]
129    fn empty_file_detects_as_missing() {
130        let dir = temp_dir();
131        let path = dir.path().join("empty.db");
132        std::fs::write(&path, b"").unwrap();
133        assert_eq!(
134            detect_database_file_format(&path).unwrap(),
135            DatabaseFileFormat::Missing
136        );
137    }
138
139    #[test]
140    fn plaintext_sqlite_detects_as_plain() {
141        let dir = temp_dir();
142        let path = dir.path().join("plain.db");
143        {
144            let conn = ManagedConnection::open(&path).unwrap();
145            conn.with(|c| Ok(c.execute_batch("CREATE TABLE t (id INTEGER)")?))
146                .unwrap();
147        }
148        assert_eq!(
149            detect_database_file_format(&path).unwrap(),
150            DatabaseFileFormat::PlainSQLite
151        );
152    }
153
154    #[test]
155    fn sqlcipher_database_detects_as_unrecognized() {
156        let dir = temp_dir();
157        let path = dir.path().join("cipher.db");
158        {
159            let conn = ManagedConnection::open_encrypted(&path, "secret").unwrap();
160            conn.with(|c| Ok(c.execute_batch("CREATE TABLE t (id INTEGER)")?))
161                .unwrap();
162        }
163        assert_eq!(
164            detect_database_file_format(&path).unwrap(),
165            DatabaseFileFormat::Unrecognized
166        );
167    }
168
169    #[test]
170    fn compressed_container_detects_with_encryption_flag() {
171        let dir = temp_dir();
172        let plain = dir.path().join("container.db");
173        {
174            let conn =
175                ManagedConnection::open_compressed(&plain, SQLiteCompressionOptions::default())
176                    .unwrap();
177            conn.with(|c| Ok(c.execute_batch("CREATE TABLE t (id INTEGER)")?))
178                .unwrap();
179        }
180        assert_eq!(
181            detect_database_file_format(&plain).unwrap(),
182            DatabaseFileFormat::CompressedContainer { encrypted: false }
183        );
184
185        let encrypted = dir.path().join("container-enc.db");
186        {
187            let conn = ManagedConnection::open_compressed_encrypted(
188                &encrypted,
189                "secret",
190                SQLiteCompressionOptions::default(),
191            )
192            .unwrap();
193            conn.with(|c| Ok(c.execute_batch("CREATE TABLE t (id INTEGER)")?))
194                .unwrap();
195        }
196        assert_eq!(
197            detect_database_file_format(&encrypted).unwrap(),
198            DatabaseFileFormat::CompressedContainer { encrypted: true }
199        );
200    }
201
202    #[test]
203    fn legacy_compressed_header_is_classified_for_an_explicit_migration_error() {
204        let dir = temp_dir();
205        let path = dir.path().join("legacy-container.db");
206        let mut prefix = [0_u8; DETECT_PREFIX_LEN];
207        prefix[..LEGACY_MAGIC.len()].copy_from_slice(LEGACY_MAGIC);
208        prefix[8..12].copy_from_slice(&1_u32.to_le_bytes());
209        prefix[HEADER_FLAGS_OFFSET..HEADER_FLAGS_OFFSET + 4]
210            .copy_from_slice(&FLAG_ENCRYPTED.to_le_bytes());
211        std::fs::write(&path, prefix).unwrap();
212        assert_eq!(
213            detect_database_file_format(&path).unwrap(),
214            DatabaseFileFormat::CompressedContainer { encrypted: true }
215        );
216    }
217
218    #[test]
219    fn short_or_foreign_files_detect_as_unrecognized() {
220        let dir = temp_dir();
221        let short = dir.path().join("short.bin");
222        std::fs::write(&short, b"abc").unwrap();
223        assert_eq!(
224            detect_database_file_format(&short).unwrap(),
225            DatabaseFileFormat::Unrecognized
226        );
227
228        let foreign = dir.path().join("foreign.bin");
229        std::fs::write(&foreign, vec![0xAB_u8; 64]).unwrap();
230        assert_eq!(
231            detect_database_file_format(&foreign).unwrap(),
232            DatabaseFileFormat::Unrecognized
233        );
234    }
235
236    #[test]
237    fn requires_key_reflects_format() {
238        assert!(!DatabaseFileFormat::Missing.requires_key());
239        assert!(!DatabaseFileFormat::PlainSQLite.requires_key());
240        assert!(!DatabaseFileFormat::CompressedContainer { encrypted: false }.requires_key());
241        assert!(DatabaseFileFormat::CompressedContainer { encrypted: true }.requires_key());
242        assert!(DatabaseFileFormat::Unrecognized.requires_key());
243    }
244}