1use aster_forge_utils::raii::TempDirGuard;
4use std::path::{Component, Path, PathBuf};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
9
10#[must_use = "keep the fixture alive for as long as its temporary files are in use"]
16pub struct TestTempDir {
17 guard: TempDirGuard,
18}
19
20impl TestTempDir {
21 pub fn new(scope: &str) -> Self {
28 Self::new_in(std::env::temp_dir(), scope)
29 }
30
31 pub fn new_in(root: impl AsRef<Path>, scope: &str) -> Self {
41 assert_valid_scope(scope);
42 let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
43 let nanos = SystemTime::now()
44 .duration_since(UNIX_EPOCH)
45 .expect("system clock should be after unix epoch")
46 .as_nanos();
47 let path = root.as_ref().join(format!(
48 "aster-test-{scope}-{}-{id}-{nanos}",
49 std::process::id()
50 ));
51 std::fs::create_dir_all(&path).unwrap_or_else(|error| {
52 panic!(
53 "failed to create isolated test directory {}: {error}",
54 path.display()
55 )
56 });
57 Self {
58 guard: TempDirGuard::new(path, "isolated test directory"),
59 }
60 }
61
62 #[must_use]
64 pub fn path(&self) -> &Path {
65 self.guard.path()
66 }
67
68 pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
74 let path = path.as_ref();
75 assert!(
76 path.components()
77 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)),
78 "test fixture path must stay relative to its temporary directory: {}",
79 path.display()
80 );
81 self.path().join(path)
82 }
83}
84
85impl std::fmt::Debug for TestTempDir {
86 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 formatter
88 .debug_struct("TestTempDir")
89 .field("path", &self.path())
90 .finish()
91 }
92}
93
94#[derive(Debug)]
100#[must_use = "keep the fixture alive until all SQLite connections have been closed"]
101pub struct SqliteTestDatabase {
102 directory: TestTempDir,
103 path: PathBuf,
104 url: String,
105}
106
107impl SqliteTestDatabase {
108 pub fn new(scope: &str) -> Self {
115 let directory = TestTempDir::new(&format!("sqlite-{scope}"));
116 let path = directory.join("database.sqlite3");
117 let url = sqlite_database_url(&path);
118 Self {
119 directory,
120 path,
121 url,
122 }
123 }
124
125 #[must_use]
127 pub fn path(&self) -> &Path {
128 &self.path
129 }
130
131 #[must_use]
133 pub fn url(&self) -> &str {
134 &self.url
135 }
136
137 #[must_use]
139 pub fn directory(&self) -> &Path {
140 self.directory.path()
141 }
142}
143
144pub fn sqlite_database_url(path: impl AsRef<Path>) -> String {
154 let path = path.as_ref();
155 let path = path.to_str().unwrap_or_else(|| {
156 panic!(
157 "SQLite test database path must be valid UTF-8: {}",
158 path.display()
159 )
160 });
161 let encoded = percent_encode_sqlite_path(path);
162 format!("sqlite:{encoded}?mode=rwc")
163}
164
165fn percent_encode_sqlite_path(path: &str) -> String {
166 const HEX: &[u8; 16] = b"0123456789ABCDEF";
167
168 let mut encoded = String::with_capacity(path.len());
169 for byte in path.bytes() {
170 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
171 encoded.push(char::from(byte));
172 } else {
173 encoded.push('%');
174 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
175 encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
176 }
177 }
178 encoded
179}
180
181fn assert_valid_scope(scope: &str) {
182 assert!(
183 !scope.is_empty()
184 && scope
185 .bytes()
186 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
187 "test temp scope must be non-empty ASCII alphanumeric, '-' or '_': {scope:?}"
188 );
189}
190
191#[cfg(test)]
192mod tests {
193 use super::{SqliteTestDatabase, TestTempDir, sqlite_database_url};
194 use std::path::Path;
195
196 #[test]
197 fn test_temp_dir_creates_and_removes_an_isolated_tree() {
198 let path;
199 {
200 let directory = TestTempDir::new("temp-dir-cleanup");
201 path = directory.path().to_path_buf();
202 std::fs::write(directory.join("payload.txt"), b"fixture")
203 .expect("fixture file should be written");
204 assert!(path.is_dir());
205 }
206 assert!(!path.exists());
207 }
208
209 #[test]
210 fn test_temp_dir_rejects_path_components_in_scope() {
211 for scope in ["", "has space", "../escape", "nested/path", "nested\\path"] {
212 let result = std::panic::catch_unwind(|| TestTempDir::new(scope));
213 assert!(result.is_err(), "scope {scope:?} should be rejected");
214 }
215 }
216
217 #[test]
218 fn test_temp_dir_join_rejects_paths_outside_fixture() {
219 let directory = TestTempDir::new("join-boundary");
220 for path in [Path::new("../escape"), Path::new("nested/../../escape")] {
221 let result = std::panic::catch_unwind(|| directory.join(path));
222 assert!(result.is_err(), "path {path:?} should be rejected");
223 }
224 }
225
226 #[test]
227 fn sqlite_url_percent_encodes_windows_and_reserved_path_characters() {
228 assert_eq!(
229 sqlite_database_url(Path::new(r"C:\Temp Folder\db?#.sqlite3")),
230 "sqlite:C%3A%5CTemp%20Folder%5Cdb%3F%23.sqlite3?mode=rwc"
231 );
232 }
233
234 #[test]
235 fn sqlite_fixture_owns_database_parent_and_parseable_url() {
236 let database = SqliteTestDatabase::new("database-fixture");
237 assert_eq!(database.path().parent(), Some(database.directory()));
238 assert!(database.url().ends_with("?mode=rwc"));
239 url::Url::parse(database.url()).expect("SQLite fixture URL should pass URL validation");
240 }
241}