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 {
23 Self::new_in(std::env::temp_dir(), scope)
24 }
25
26 pub fn new_in(root: impl AsRef<Path>, scope: &str) -> Self {
31 assert_valid_scope(scope);
32 let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
33 let nanos = SystemTime::now()
34 .duration_since(UNIX_EPOCH)
35 .expect("system clock should be after unix epoch")
36 .as_nanos();
37 let path = root.as_ref().join(format!(
38 "aster-test-{scope}-{}-{id}-{nanos}",
39 std::process::id()
40 ));
41 std::fs::create_dir_all(&path).unwrap_or_else(|error| {
42 panic!(
43 "failed to create isolated test directory {}: {error}",
44 path.display()
45 )
46 });
47 Self {
48 guard: TempDirGuard::new(path, "isolated test directory"),
49 }
50 }
51
52 pub fn path(&self) -> &Path {
54 self.guard.path()
55 }
56
57 pub fn join(&self, path: impl AsRef<Path>) -> PathBuf {
59 let path = path.as_ref();
60 assert!(
61 path.components()
62 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)),
63 "test fixture path must stay relative to its temporary directory: {path:?}"
64 );
65 self.path().join(path)
66 }
67}
68
69impl std::fmt::Debug for TestTempDir {
70 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 formatter
72 .debug_struct("TestTempDir")
73 .field("path", &self.path())
74 .finish()
75 }
76}
77
78#[derive(Debug)]
84#[must_use = "keep the fixture alive until all SQLite connections have been closed"]
85pub struct SqliteTestDatabase {
86 directory: TestTempDir,
87 path: PathBuf,
88 url: String,
89}
90
91impl SqliteTestDatabase {
92 pub fn new(scope: &str) -> Self {
94 let directory = TestTempDir::new(&format!("sqlite-{scope}"));
95 let path = directory.join("database.sqlite3");
96 let url = sqlite_database_url(&path);
97 Self {
98 directory,
99 path,
100 url,
101 }
102 }
103
104 pub fn path(&self) -> &Path {
106 &self.path
107 }
108
109 pub fn url(&self) -> &str {
111 &self.url
112 }
113
114 pub fn directory(&self) -> &Path {
116 self.directory.path()
117 }
118}
119
120pub fn sqlite_database_url(path: impl AsRef<Path>) -> String {
126 let path = path.as_ref();
127 let path = path.to_str().unwrap_or_else(|| {
128 panic!(
129 "SQLite test database path must be valid UTF-8: {}",
130 path.display()
131 )
132 });
133 let encoded = percent_encode_sqlite_path(path);
134 format!("sqlite:{encoded}?mode=rwc")
135}
136
137fn percent_encode_sqlite_path(path: &str) -> String {
138 const HEX: &[u8; 16] = b"0123456789ABCDEF";
139
140 let mut encoded = String::with_capacity(path.len());
141 for byte in path.bytes() {
142 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
143 encoded.push(char::from(byte));
144 } else {
145 encoded.push('%');
146 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
147 encoded.push(char::from(HEX[usize::from(byte & 0x0F)]));
148 }
149 }
150 encoded
151}
152
153fn assert_valid_scope(scope: &str) {
154 assert!(
155 !scope.is_empty()
156 && scope
157 .bytes()
158 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')),
159 "test temp scope must be non-empty ASCII alphanumeric, '-' or '_': {scope:?}"
160 );
161}
162
163#[cfg(test)]
164mod tests {
165 use super::{SqliteTestDatabase, TestTempDir, sqlite_database_url};
166 use std::path::Path;
167
168 #[test]
169 fn test_temp_dir_creates_and_removes_an_isolated_tree() {
170 let path;
171 {
172 let directory = TestTempDir::new("temp-dir-cleanup");
173 path = directory.path().to_path_buf();
174 std::fs::write(directory.join("payload.txt"), b"fixture")
175 .expect("fixture file should be written");
176 assert!(path.is_dir());
177 }
178 assert!(!path.exists());
179 }
180
181 #[test]
182 fn test_temp_dir_rejects_path_components_in_scope() {
183 for scope in ["", "has space", "../escape", "nested/path", "nested\\path"] {
184 let result = std::panic::catch_unwind(|| TestTempDir::new(scope));
185 assert!(result.is_err(), "scope {scope:?} should be rejected");
186 }
187 }
188
189 #[test]
190 fn test_temp_dir_join_rejects_paths_outside_fixture() {
191 let directory = TestTempDir::new("join-boundary");
192 for path in [Path::new("../escape"), Path::new("nested/../../escape")] {
193 let result = std::panic::catch_unwind(|| directory.join(path));
194 assert!(result.is_err(), "path {path:?} should be rejected");
195 }
196 }
197
198 #[test]
199 fn sqlite_url_percent_encodes_windows_and_reserved_path_characters() {
200 assert_eq!(
201 sqlite_database_url(Path::new(r"C:\Temp Folder\db?#.sqlite3")),
202 "sqlite:C%3A%5CTemp%20Folder%5Cdb%3F%23.sqlite3?mode=rwc"
203 );
204 }
205
206 #[test]
207 fn sqlite_fixture_owns_database_parent_and_parseable_url() {
208 let database = SqliteTestDatabase::new("database-fixture");
209 assert_eq!(database.path().parent(), Some(database.directory()));
210 assert!(database.url().ends_with("?mode=rwc"));
211 url::Url::parse(database.url()).expect("SQLite fixture URL should pass URL validation");
212 }
213}