11use std:: fs:: create_dir_all;
2- use std:: path:: PathBuf ;
2+ use std:: path:: { Component , Path , PathBuf } ;
33
44use sqlx_sqlite_conn_mgr:: SqliteDatabaseConfig ;
55use sqlx_sqlite_toolkit:: DatabaseWrapper ;
@@ -23,8 +23,11 @@ pub async fn connect<R: Runtime>(
2323
2424/// Resolve database file path relative to app config directory.
2525///
26- /// Paths are joined to `app_config_dir()` (e.g., `Library/Application Support/${bundleIdentifier}` on iOS).
27- /// Special paths like `:memory:` are passed through unchanged.
26+ /// Paths are joined to `app_config_dir()` (e.g., `Library/Application Support/${bundleIdentifier}`
27+ /// on iOS). Special paths like `:memory:` are passed through unchanged.
28+ ///
29+ /// Returns `Err(Error::PathTraversal)` if the path attempts to escape the app config directory
30+ /// via absolute paths, `..` segments, or null bytes.
2831pub fn resolve_database_path < R : Runtime > ( path : & str , app : & AppHandle < R > ) -> Result < PathBuf , Error > {
2932 let app_path = app
3033 . path ( )
@@ -33,6 +36,172 @@ pub fn resolve_database_path<R: Runtime>(path: &str, app: &AppHandle<R>) -> Resu
3336
3437 create_dir_all ( & app_path) ?;
3538
36- // Join the relative path to the app config directory
37- Ok ( app_path. join ( path) )
39+ validate_and_resolve ( path, & app_path)
40+ }
41+
42+ /// Validate a user-supplied path and resolve it against a base directory.
43+ ///
44+ /// In-memory database paths are passed through unchanged. All other paths are validated
45+ /// to ensure they cannot escape the base directory.
46+ fn validate_and_resolve ( path : & str , base : & Path ) -> Result < PathBuf , Error > {
47+ // Pass through in-memory database paths unchanged — they don't touch the filesystem.
48+ // Matches the same patterns as `is_memory_database` in sqlx-sqlite-conn-mgr.
49+ if is_memory_path ( path) {
50+ return Ok ( PathBuf :: from ( path) ) ;
51+ }
52+
53+ // Reject null bytes — these can truncate paths in C-level filesystem calls
54+ if path. contains ( '\0' ) {
55+ return Err ( Error :: PathTraversal ( "path contains null byte" . to_string ( ) ) ) ;
56+ }
57+
58+ let rel = Path :: new ( path) ;
59+
60+ // Reject absolute paths — PathBuf::join replaces the base when given an absolute path
61+ if rel. is_absolute ( ) {
62+ return Err ( Error :: PathTraversal (
63+ "absolute paths are not allowed" . to_string ( ) ,
64+ ) ) ;
65+ }
66+
67+ // Reject parent directory components — prevents escaping the base via `../`
68+ for component in rel. components ( ) {
69+ if matches ! ( component, Component :: ParentDir ) {
70+ return Err ( Error :: PathTraversal (
71+ "parent directory references are not allowed" . to_string ( ) ,
72+ ) ) ;
73+ }
74+ }
75+
76+ // Join and canonicalize to verify containment. The parent directory is canonicalized
77+ // because the file may not exist yet.
78+ let joined = base. join ( rel) ;
79+ let canonical_base = base
80+ . canonicalize ( )
81+ . map_err ( |e| Error :: InvalidPath ( format ! ( "cannot canonicalize base path: {e}" ) ) ) ?;
82+
83+ let canonical_resolved = if joined. exists ( ) {
84+ joined. canonicalize ( )
85+ } else {
86+ // Canonicalize the parent and re-append the file name
87+ joined
88+ . parent ( )
89+ . ok_or_else ( || Error :: InvalidPath ( "path has no parent" . to_string ( ) ) ) ?
90+ . canonicalize ( )
91+ . map ( |parent| parent. join ( joined. file_name ( ) . unwrap_or_default ( ) ) )
92+ }
93+ . map_err ( |e| Error :: InvalidPath ( format ! ( "cannot canonicalize path: {e}" ) ) ) ?;
94+
95+ if !canonical_resolved. starts_with ( & canonical_base) {
96+ return Err ( Error :: PathTraversal (
97+ "resolved path escapes the base directory" . to_string ( ) ,
98+ ) ) ;
99+ }
100+
101+ // Return the original (non-canonicalized) joined path for consistency with how the
102+ // rest of the codebase references database paths.
103+ Ok ( joined)
104+ }
105+
106+ /// Check if a path string represents an in-memory SQLite database.
107+ ///
108+ /// Matches the same patterns as `is_memory_database` in `sqlx-sqlite-conn-mgr`:
109+ /// `:memory:`, `file::memory:*` URIs, and `mode=memory` query parameters.
110+ fn is_memory_path ( path : & str ) -> bool {
111+ path == ":memory:"
112+ || path. starts_with ( "file::memory:" )
113+ || ( path. starts_with ( "file:" ) && path. contains ( "mode=memory" ) )
114+ }
115+
116+ #[ cfg( test) ]
117+ mod tests {
118+ use super :: * ;
119+ use std:: fs;
120+
121+ /// Helper that creates a temporary base directory for testing.
122+ fn make_temp_base ( ) -> PathBuf {
123+ let dir = std:: env:: temp_dir ( ) . join ( format ! ( "tauri_sqlite_test_{}" , std:: process:: id( ) ) ) ;
124+ fs:: create_dir_all ( & dir) . unwrap ( ) ;
125+ dir
126+ }
127+
128+ #[ test]
129+ fn test_simple_filename ( ) {
130+ let base = make_temp_base ( ) ;
131+ let result = validate_and_resolve ( "mydb.db" , & base) . unwrap ( ) ;
132+ assert_eq ! ( result, base. join( "mydb.db" ) ) ;
133+ }
134+
135+ #[ test]
136+ fn test_subdirectory_path ( ) {
137+ let base = make_temp_base ( ) ;
138+ // Create the subdirectory so canonicalize can resolve it
139+ fs:: create_dir_all ( base. join ( "subdir" ) ) . unwrap ( ) ;
140+ let result = validate_and_resolve ( "subdir/mydb.db" , & base) . unwrap ( ) ;
141+ assert_eq ! ( result, base. join( "subdir/mydb.db" ) ) ;
142+ }
143+
144+ #[ test]
145+ fn test_memory_passthrough ( ) {
146+ let base = make_temp_base ( ) ;
147+ assert_eq ! (
148+ validate_and_resolve( ":memory:" , & base) . unwrap( ) ,
149+ PathBuf :: from( ":memory:" ) ,
150+ ) ;
151+ }
152+
153+ #[ test]
154+ fn test_file_memory_uri_passthrough ( ) {
155+ let base = make_temp_base ( ) ;
156+ assert_eq ! (
157+ validate_and_resolve( "file::memory:?cache=shared" , & base) . unwrap( ) ,
158+ PathBuf :: from( "file::memory:?cache=shared" ) ,
159+ ) ;
160+ }
161+
162+ #[ test]
163+ fn test_mode_memory_passthrough ( ) {
164+ let base = make_temp_base ( ) ;
165+ assert_eq ! (
166+ validate_and_resolve( "file:test?mode=memory" , & base) . unwrap( ) ,
167+ PathBuf :: from( "file:test?mode=memory" ) ,
168+ ) ;
169+ }
170+
171+ #[ test]
172+ fn test_rejects_parent_traversal ( ) {
173+ let base = make_temp_base ( ) ;
174+ let err = validate_and_resolve ( "../../../etc/passwd" , & base) . unwrap_err ( ) ;
175+ assert ! ( matches!( err, Error :: PathTraversal ( _) ) ) ;
176+ }
177+
178+ #[ test]
179+ fn test_rejects_absolute_path ( ) {
180+ let base = make_temp_base ( ) ;
181+ let err = validate_and_resolve ( "/etc/passwd" , & base) . unwrap_err ( ) ;
182+ assert ! ( matches!( err, Error :: PathTraversal ( _) ) ) ;
183+ }
184+
185+ #[ test]
186+ fn test_rejects_embedded_traversal ( ) {
187+ let base = make_temp_base ( ) ;
188+ let err = validate_and_resolve ( "foo/../../bar" , & base) . unwrap_err ( ) ;
189+ assert ! ( matches!( err, Error :: PathTraversal ( _) ) ) ;
190+ }
191+
192+ #[ test]
193+ fn test_rejects_null_byte ( ) {
194+ let base = make_temp_base ( ) ;
195+ let err = validate_and_resolve ( "path\0 evil" , & base) . unwrap_err ( ) ;
196+ assert ! ( matches!( err, Error :: PathTraversal ( _) ) ) ;
197+ }
198+
199+ #[ test]
200+ fn test_rejects_non_uri_mode_memory ( ) {
201+ let base = make_temp_base ( ) ;
202+ // A bare filename containing "mode=memory" is not a valid SQLite URI —
203+ // it should go through normal path validation, not be passed through.
204+ let result = validate_and_resolve ( "evil.db?mode=memory" , & base) . unwrap ( ) ;
205+ assert_eq ! ( result, base. join( "evil.db?mode=memory" ) ) ;
206+ }
38207}
0 commit comments