database_replicator/sqlite/
mod.rs

1// ABOUTME: SQLite database reading utilities for migration to PostgreSQL
2// ABOUTME: Provides secure file path validation and read-only database connections
3
4pub mod converter;
5pub mod reader;
6
7use anyhow::{bail, Context, Result};
8use std::path::PathBuf;
9
10/// Validate a SQLite file path to prevent path traversal attacks
11///
12/// Security checks:
13/// - Canonicalizes path to resolve symlinks and relative paths
14/// - Verifies file exists and is a regular file (not directory)
15/// - Checks file extension is .db, .sqlite, or .sqlite3
16/// - Does NOT follow symlinks outside allowed directories
17///
18/// # Arguments
19///
20/// * `path` - Path to SQLite file (can be relative or absolute)
21///
22/// # Returns
23///
24/// Canonicalized absolute path if valid, error otherwise
25///
26/// # Security
27///
28/// CRITICAL: This function prevents path traversal attacks like:
29/// - ../../../etc/passwd
30/// - /etc/shadow
31/// - Symlink attacks
32///
33/// # Examples
34///
35/// ```no_run
36/// # use database_replicator::sqlite::validate_sqlite_path;
37/// // Valid paths (when files exist)
38/// assert!(validate_sqlite_path("database.db").is_ok());
39/// assert!(validate_sqlite_path("/tmp/test.sqlite").is_ok());
40///
41/// // Invalid paths
42/// assert!(validate_sqlite_path("../../../etc/passwd").is_err());
43/// assert!(validate_sqlite_path("/nonexistent.db").is_err());
44/// ```
45pub fn validate_sqlite_path(path: &str) -> Result<PathBuf> {
46    if path.is_empty() {
47        bail!("SQLite file path cannot be empty");
48    }
49
50    let path_buf = PathBuf::from(path);
51
52    // Canonicalize to resolve symlinks and relative paths
53    // This also validates that the file exists
54    let canonical = path_buf.canonicalize().with_context(|| {
55        format!(
56            "Failed to resolve SQLite file path '{}'. \
57             File may not exist or may not be readable.",
58            path
59        )
60    })?;
61
62    // Verify it's a file, not a directory
63    if !canonical.is_file() {
64        bail!("Path '{}' is not a regular file (may be a directory)", path);
65    }
66
67    // Verify file extension
68    if let Some(ext) = canonical.extension() {
69        let ext_str = ext.to_str().unwrap_or("");
70        if !["db", "sqlite", "sqlite3"].contains(&ext_str) {
71            bail!(
72                "Invalid SQLite file extension '{}'. \
73                 Must be .db, .sqlite, or .sqlite3",
74                ext_str
75            );
76        }
77    } else {
78        bail!(
79            "SQLite file '{}' has no extension. \
80             Must be .db, .sqlite, or .sqlite3",
81            path
82        );
83    }
84
85    tracing::debug!("Validated SQLite path: {}", canonical.display());
86
87    Ok(canonical)
88}
89
90/// Open a SQLite database in read-only mode
91///
92/// Opens the database with read-only flag for safety.
93/// The database file must exist and be readable.
94///
95/// # Arguments
96///
97/// * `path` - Path to SQLite file (will be validated)
98///
99/// # Returns
100///
101/// Read-only rusqlite::Connection if successful
102///
103/// # Security
104///
105/// - Path is validated before opening
106/// - Database opened in read-only mode (SQLITE_OPEN_READ_ONLY)
107/// - No modifications possible
108///
109/// # Examples
110///
111/// ```no_run
112/// # use database_replicator::sqlite::open_sqlite;
113/// # fn example() -> anyhow::Result<()> {
114/// let conn = open_sqlite("database.db")?;
115/// // Use connection to read data
116/// # Ok(())
117/// # }
118/// ```
119pub fn open_sqlite(path: &str) -> Result<rusqlite::Connection> {
120    // Validate path first
121    let canonical = validate_sqlite_path(path)?;
122
123    tracing::info!("Opening SQLite database: {}", canonical.display());
124
125    // Open in read-only mode for safety
126    let conn = rusqlite::Connection::open_with_flags(
127        &canonical,
128        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
129    )
130    .with_context(|| format!("Failed to open SQLite database: {}", canonical.display()))?;
131
132    // Verify we can query the database
133    let _version: String = conn
134        .query_row("SELECT sqlite_version()", [], |row| row.get(0))
135        .context("Failed to query SQLite version (database may be corrupted)")?;
136
137    tracing::debug!("Successfully opened SQLite database");
138
139    Ok(conn)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::fs::File;
146
147    #[test]
148    fn test_validate_empty_path() {
149        let result = validate_sqlite_path("");
150        assert!(result.is_err());
151        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
152    }
153
154    #[test]
155    fn test_validate_nonexistent_file() {
156        let result = validate_sqlite_path("/nonexistent/database.db");
157        assert!(result.is_err());
158    }
159
160    #[test]
161    fn test_validate_no_extension() {
162        // Create a temp file with no extension
163        let temp_dir = std::env::temp_dir();
164        let no_ext_path = temp_dir.join("test_file_no_ext");
165        File::create(&no_ext_path).unwrap();
166
167        let result = validate_sqlite_path(no_ext_path.to_str().unwrap());
168        assert!(result.is_err());
169        assert!(result.unwrap_err().to_string().contains("no extension"));
170
171        // Cleanup
172        std::fs::remove_file(no_ext_path).ok();
173    }
174
175    #[test]
176    fn test_validate_wrong_extension() {
177        // Create a temp file with wrong extension
178        let temp_dir = std::env::temp_dir();
179        let wrong_ext_path = temp_dir.join("test_file.txt");
180        File::create(&wrong_ext_path).unwrap();
181
182        let result = validate_sqlite_path(wrong_ext_path.to_str().unwrap());
183        assert!(result.is_err());
184        assert!(result
185            .unwrap_err()
186            .to_string()
187            .contains("Invalid SQLite file extension"));
188
189        // Cleanup
190        std::fs::remove_file(wrong_ext_path).ok();
191    }
192
193    #[test]
194    fn test_validate_directory() {
195        let temp_dir = std::env::temp_dir();
196        let result = validate_sqlite_path(temp_dir.to_str().unwrap());
197        assert!(result.is_err());
198        // Different error message depending on whether dir has extension
199    }
200
201    #[test]
202    fn test_validate_valid_extensions() {
203        // Create temp files with valid extensions
204        let temp_dir = std::env::temp_dir();
205
206        for ext in &["db", "sqlite", "sqlite3"] {
207            let file_path = temp_dir.join(format!("test_file.{}", ext));
208            File::create(&file_path).unwrap();
209
210            let result = validate_sqlite_path(file_path.to_str().unwrap());
211            assert!(
212                result.is_ok(),
213                "Extension .{} should be valid, but got error: {:?}",
214                ext,
215                result.err()
216            );
217
218            // Cleanup
219            std::fs::remove_file(file_path).ok();
220        }
221    }
222
223    #[test]
224    fn test_path_traversal_prevention() {
225        // These should fail because files don't exist, not because of traversal per se
226        // But canonicalize will prevent traversal if file existed
227        let traversal_attempts = vec!["../../../etc/passwd", "../../..", "/etc/shadow"];
228
229        for attempt in traversal_attempts {
230            let result = validate_sqlite_path(attempt);
231            assert!(
232                result.is_err(),
233                "Path traversal attempt '{}' should be rejected",
234                attempt
235            );
236        }
237    }
238
239    #[test]
240    fn test_open_sqlite_invalid_path() {
241        let result = open_sqlite("/nonexistent/database.db");
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn test_open_sqlite_creates_readonly_connection() {
247        // Create a temporary SQLite database
248        let temp_dir = std::env::temp_dir();
249        let db_path = temp_dir.join("test_readonly.db");
250
251        // Create database with a table
252        {
253            let conn = rusqlite::Connection::open(&db_path).unwrap();
254            conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap();
255        }
256
257        // Open in read-only mode
258        let conn = open_sqlite(db_path.to_str().unwrap()).unwrap();
259
260        // Verify we can read
261        let result: Result<i32, _> =
262            conn.query_row("SELECT COUNT(*) FROM test", [], |row| row.get(0));
263        assert!(result.is_ok());
264
265        // Verify we CANNOT write (should fail because read-only)
266        let write_result = conn.execute("INSERT INTO test VALUES (1)", []);
267        assert!(write_result.is_err());
268        assert!(write_result
269            .unwrap_err()
270            .to_string()
271            .to_lowercase()
272            .contains("read"));
273
274        // Cleanup
275        std::fs::remove_file(db_path).ok();
276    }
277}