1use std::path::{Path, PathBuf};
10
11#[derive(Debug, thiserror::Error)]
12pub enum PolicyError {
13 #[error("refuse to open database: {reason}")]
14 RefuseToOpenDb { reason: String },
15
16 #[error("invalid path: {0}")]
17 InvalidPath(String),
18
19 #[error("io error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("database error: {0}")]
23 Database(#[from] rusqlite::Error),
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct Violation {
28 pub kind: ViolationKind,
29 pub message: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub enum ViolationKind {
34 ForbiddenPath,
35 CapExceeded,
36 EmptyPatch,
37 UselessEdit,
38 DegenerateRange,
39 InvalidOccurrence,
40 DuplicatePath,
41}
42
43#[derive(Debug, Clone)]
44pub struct DbIdentitySpec<'a> {
45 pub min_user_version: u32,
46 pub max_user_version: u32,
47 pub required_tables: &'a [&'a str],
48 pub schema_table: &'a str,
49 pub schema_key: &'a str,
50 pub expected_schema_hash: &'a str,
51}
52
53const ALLOWED_ENV_PREFIXES: &[&str] = &[
54 "PATH",
55 "HOME",
56 "USER",
57 "LOGNAME",
58 "LANG",
59 "LC_",
60 "LANGUAGE",
61 "TERM",
62 "COLORTERM",
63 "SHELL",
64 "CARGO",
65 "RUSTUP",
66 "RUST",
67 "XDG_",
68 "TMPDIR",
69 "TMP",
70 "TEMP",
71 "CC",
72 "CXX",
73 "LD_",
74 "PKG_CONFIG",
75 "SCCACHE",
76 "CCACHE",
77];
78
79pub fn verify_sqlite_db_identity(
80 path: &Path,
81 spec: &DbIdentitySpec<'_>,
82) -> Result<(), PolicyError> {
83 if !path.exists() {
84 return Ok(());
85 }
86
87 use std::io::Read;
88
89 let mut file = std::fs::File::open(path).map_err(|error| PolicyError::RefuseToOpenDb {
90 reason: format!("cannot read file: {error}"),
91 })?;
92 let mut magic = [0_u8; 16];
93 let count = file
94 .read(&mut magic)
95 .map_err(|error| PolicyError::RefuseToOpenDb {
96 reason: format!("cannot read magic bytes: {error}"),
97 })?;
98 if count < 16 || magic != *b"SQLite format 3\0" {
99 return Err(PolicyError::RefuseToOpenDb {
100 reason: "file is not a valid SQLite database".to_string(),
101 });
102 }
103
104 let connection = rusqlite::Connection::open_with_flags(
105 path,
106 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
107 )
108 .map_err(|error| PolicyError::RefuseToOpenDb {
109 reason: format!("cannot open database: {error}"),
110 })?;
111
112 let user_version: u32 = connection
113 .pragma_query_value(None, "user_version", |row| row.get(0))
114 .map_err(|error| PolicyError::RefuseToOpenDb {
115 reason: format!("cannot read user_version: {error}"),
116 })?;
117 if !(spec.min_user_version..=spec.max_user_version).contains(&user_version) {
118 return Err(PolicyError::RefuseToOpenDb {
119 reason: format!(
120 "user_version {user_version} is outside valid range [{}, {}]",
121 spec.min_user_version, spec.max_user_version
122 ),
123 });
124 }
125
126 let has_schema_table: bool = connection
127 .query_row(
128 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
129 [spec.schema_table],
130 |row| row.get(0),
131 )
132 .map_err(|error| PolicyError::RefuseToOpenDb {
133 reason: format!("cannot query sqlite_master: {error}"),
134 })?;
135 if !has_schema_table {
136 return Err(PolicyError::RefuseToOpenDb {
137 reason: format!("table '{}' does not exist", spec.schema_table),
138 });
139 }
140
141 let query = format!("SELECT value FROM {} WHERE key = ?1", spec.schema_table);
142 let stored_hash: String = connection
143 .query_row(query.as_str(), [spec.schema_key], |row| row.get(0))
144 .map_err(|_| PolicyError::RefuseToOpenDb {
145 reason: format!("{} row '{}' not found", spec.schema_table, spec.schema_key),
146 })?;
147 if stored_hash != spec.expected_schema_hash {
148 return Err(PolicyError::RefuseToOpenDb {
149 reason: format!(
150 "schema_hash mismatch: stored={stored_hash}, expected={}",
151 spec.expected_schema_hash
152 ),
153 });
154 }
155
156 let existing_tables: Vec<String> = connection
157 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
158 .map_err(|error| PolicyError::RefuseToOpenDb {
159 reason: format!("cannot query sqlite_master for tables: {error}"),
160 })?
161 .query_map([], |row| row.get(0))
162 .map_err(|error| PolicyError::RefuseToOpenDb {
163 reason: format!("cannot iterate tables: {error}"),
164 })?
165 .filter_map(|row| row.ok())
166 .collect();
167
168 for table in spec.required_tables {
169 if !existing_tables.iter().any(|existing| existing == table) {
170 return Err(PolicyError::RefuseToOpenDb {
171 reason: format!("required table '{}' missing from database", table),
172 });
173 }
174 }
175
176 Ok(())
177}
178
179pub fn ensure_relative_path(path: &Path) -> Result<(), PolicyError> {
180 if path.is_absolute() {
181 return Err(PolicyError::InvalidPath(format!(
182 "path '{}' is absolute",
183 path.display()
184 )));
185 }
186
187 for component in path.components() {
188 if component == std::path::Component::ParentDir {
189 return Err(PolicyError::InvalidPath(format!(
190 "path '{}' contains '..'",
191 path.display()
192 )));
193 }
194 }
195
196 Ok(())
197}
198
199pub fn reject_symlinks(root: &Path, relative_path: &Path) -> Result<(), PolicyError> {
200 let mut current = root.to_path_buf();
201 for component in relative_path.components() {
202 current.push(component);
203 if current.exists() {
204 let metadata = std::fs::symlink_metadata(¤t)?;
205 if metadata.file_type().is_symlink() {
206 return Err(PolicyError::InvalidPath(format!(
207 "symlink detected at {}",
208 current.display()
209 )));
210 }
211 }
212 }
213 Ok(())
214}
215
216pub fn resolve_workspace_path(root: &Path, relative_path: &Path) -> Result<PathBuf, PolicyError> {
217 ensure_relative_path(relative_path)?;
218 reject_symlinks(root, relative_path)?;
219
220 let full_path = root.join(relative_path);
221 if let Ok(root_canonical) = root.canonicalize() {
222 if let Some(parent) = full_path.parent() {
223 if parent.exists() {
224 let parent_canonical = parent.canonicalize()?;
225 if !parent_canonical.starts_with(&root_canonical) {
226 return Err(PolicyError::InvalidPath(format!(
227 "resolved path '{}' escapes workspace '{}'",
228 parent_canonical.display(),
229 root_canonical.display()
230 )));
231 }
232 }
233 }
234 }
235
236 Ok(full_path)
237}
238
239pub fn validate_forbidden_paths(
240 paths: &[String],
241 forbidden_patterns: &[String],
242 allow_test_modifications: bool,
243) -> Vec<Violation> {
244 let hardcoded_denies: &[&str] = &[
245 "tests/**",
246 "**/*_test.rs",
247 "**/fixtures/**",
248 "**/*.snap",
249 "Cargo.lock",
250 ];
251
252 let mut violations = Vec::new();
253 for path in paths {
254 if !allow_test_modifications {
255 for pattern in hardcoded_denies {
256 if glob_matches(pattern, path) {
257 violations.push(Violation {
258 kind: ViolationKind::ForbiddenPath,
259 message: format!(
260 "path '{}' matches hardcoded forbidden pattern '{}'",
261 path, pattern
262 ),
263 });
264 break;
265 }
266 }
267 }
268
269 for pattern in forbidden_patterns {
270 if !allow_test_modifications && hardcoded_denies.contains(&pattern.as_str()) {
271 continue;
272 }
273 if glob_matches(pattern, path) {
274 violations.push(Violation {
275 kind: ViolationKind::ForbiddenPath,
276 message: format!("path '{}' matches forbidden pattern '{}'", path, pattern),
277 });
278 break;
279 }
280 }
281 }
282
283 violations
284}
285
286pub fn validate_patch_caps(
287 files_changed: usize,
288 total_lines_changed: usize,
289 per_file_lines: &[usize],
290 max_files: usize,
291 max_total_lines: usize,
292 max_per_file: usize,
293) -> Vec<Violation> {
294 let mut violations = Vec::new();
295
296 if files_changed > max_files {
297 violations.push(Violation {
298 kind: ViolationKind::CapExceeded,
299 message: format!("files changed ({files_changed}) exceeds cap ({max_files})"),
300 });
301 }
302
303 if total_lines_changed > max_total_lines {
304 violations.push(Violation {
305 kind: ViolationKind::CapExceeded,
306 message: format!(
307 "total lines changed ({total_lines_changed}) exceeds cap ({max_total_lines})"
308 ),
309 });
310 }
311
312 for (index, lines) in per_file_lines.iter().enumerate() {
313 if *lines > max_per_file {
314 violations.push(Violation {
315 kind: ViolationKind::CapExceeded,
316 message: format!(
317 "file {index}: lines changed ({lines}) exceeds per-file cap ({max_per_file})"
318 ),
319 });
320 }
321 }
322
323 violations
324}
325
326pub fn is_env_allowed(key: &str) -> bool {
327 ALLOWED_ENV_PREFIXES
328 .iter()
329 .any(|prefix| key.starts_with(prefix))
330}
331
332fn glob_matches(pattern: &str, path: &str) -> bool {
333 match glob::Pattern::new(pattern) {
334 Ok(glob) => glob.matches(path),
335 Err(error) => {
336 tracing::warn!("invalid glob pattern '{}': {}", pattern, error);
337 false
338 }
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use proptest::prelude::*;
346 use std::path::Path;
347
348 #[test]
349 fn path_guard_rejects_absolute_and_parent_components() {
350 assert!(ensure_relative_path(Path::new("src/lib.rs")).is_ok());
351 assert!(ensure_relative_path(Path::new("/tmp/nope")).is_err());
352 assert!(ensure_relative_path(Path::new("../nope")).is_err());
353 }
354
355 #[test]
356 fn path_guard_rejects_multiple_escape_shapes() {
357 for candidate in [
358 "/tmp/escape",
359 "../escape",
360 "nested/../../escape",
361 "./../escape",
362 ] {
363 assert!(
364 ensure_relative_path(Path::new(candidate)).is_err(),
365 "expected '{candidate}' to be rejected"
366 );
367 }
368 }
369
370 #[cfg(unix)]
371 #[test]
372 fn reject_symlinks_blocks_existing_symlink_segments() {
373 let root = tempfile::tempdir().unwrap();
374 let outside = tempfile::tempdir().unwrap();
375 std::os::unix::fs::symlink(outside.path(), root.path().join("linked")).unwrap();
376
377 let error = reject_symlinks(root.path(), Path::new("linked/escape.txt")).unwrap_err();
378 assert!(matches!(error, PolicyError::InvalidPath(_)));
379 assert!(error.to_string().contains("symlink detected"));
380 }
381
382 #[cfg(unix)]
383 #[test]
384 fn resolve_workspace_path_rejects_symlinked_parent_escape() {
385 let root = tempfile::tempdir().unwrap();
386 let outside = tempfile::tempdir().unwrap();
387 std::fs::create_dir_all(root.path().join("dir")).unwrap();
388 std::os::unix::fs::symlink(outside.path(), root.path().join("dir/link")).unwrap();
389
390 let error =
391 resolve_workspace_path(root.path(), Path::new("dir/link/outside.txt")).unwrap_err();
392 assert!(matches!(error, PolicyError::InvalidPath(_)));
393 }
394
395 #[test]
396 fn env_allowlist_matches_expected_prefixes() {
397 assert!(is_env_allowed("CARGO_HOME"));
398 assert!(is_env_allowed("PATH"));
399 assert!(!is_env_allowed("AWS_SECRET_ACCESS_KEY"));
400 }
401
402 proptest! {
403 #[test]
404 fn relative_path_guard_rejects_parent_escape_shapes(path_tail in "[A-Za-z0-9_./-]{1,24}") {
405 for candidate in [
406 format!("../{path_tail}"),
407 format!("nested/../../{path_tail}"),
408 format!("./../{path_tail}"),
409 ] {
410 prop_assert!(ensure_relative_path(Path::new(&candidate)).is_err());
411 }
412 }
413
414 #[test]
415 fn relative_path_guard_accepts_safe_relative_shapes(path_tail in "[A-Za-z0-9_./-]{1,24}") {
416 prop_assume!(!path_tail.starts_with('/'));
417 prop_assume!(!path_tail.contains(".."));
418 let path = format!("safe/{path_tail}");
419 prop_assert!(ensure_relative_path(Path::new(&path)).is_ok());
420 }
421 }
422}