Skip to main content

forge_policy/
lib.rs

1//! # forge-policy
2//!
3//! Filesystem, environment, and SQLite guardrails for internal forge runtimes.
4//!
5//! This Tier 3 crate centralizes low-level path safety, patch-cap checks,
6//! allowlisting, and database identity validation. It is intentionally policy
7//! infrastructure rather than an execution or storage layer.
8
9use 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(&current)?;
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}