use super::types::{CbPatternViolation, Severity};
use std::path::Path;
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn detect_cb533_stale_path_references(project_path: &Path) -> Vec<CbPatternViolation> {
let mut violations = Vec::new();
check_makefile(project_path, &mut violations);
check_ci_workflows(project_path, &mut violations);
violations
}
fn check_makefile(project_path: &Path, violations: &mut Vec<CbPatternViolation>) {
let makefile = project_path.join("Makefile");
if !makefile.exists() {
return;
}
let content = match std::fs::read_to_string(&makefile) {
Ok(c) => c,
Err(_) => return,
};
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
if let Some(pos) = trimmed.find("cd ") {
let rest = &trimmed[pos + 3..];
let dir = rest.split([';', '&', ' ', '/']).next().unwrap_or("");
if !dir.is_empty()
&& !dir.starts_with('$')
&& !dir.starts_with('-')
&& dir != "."
&& dir != ".."
{
let target = project_path.join(dir);
if !target.exists() {
violations.push(CbPatternViolation {
pattern_id: "CB-533".to_string(),
file: "Makefile".to_string(),
line: i + 1,
description: format!(
"Stale path: `cd {dir}` references non-existent directory"
),
severity: Severity::Warning,
});
}
}
}
}
}
fn check_ci_workflows(project_path: &Path, violations: &mut Vec<CbPatternViolation>) {
let workflows_dir = project_path.join(".github/workflows");
if !workflows_dir.exists() {
return;
}
let entries = match std::fs::read_dir(&workflows_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "yml" && e != "yaml") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if let Some(pos) = trimmed.find("working-directory:") {
let dir = trimmed[pos + 18..]
.trim()
.trim_matches(|c| c == '\'' || c == '"');
if !dir.starts_with('$') && !dir.starts_with('.') {
let target = project_path.join(dir);
if !target.exists() {
violations.push(CbPatternViolation {
pattern_id: "CB-533".to_string(),
file: format!(".github/workflows/{filename}"),
line: i + 1,
description: format!(
"Stale path: `working-directory: {dir}` references non-existent directory"
),
severity: Severity::Warning,
});
}
}
}
}
}
}
#[cfg(test)]
mod stale_paths_tests {
use super::*;
#[test]
fn test_detect_empty_project_no_violations() {
let tmp = tempfile::tempdir().unwrap();
let v = detect_cb533_stale_path_references(tmp.path());
assert!(v.is_empty());
}
#[test]
fn test_check_makefile_no_cd_no_violations() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Makefile"), "all:\n\techo hello\n").unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_makefile_cd_to_missing_dir_flagged() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"build:\n\tcd nonexistent && cargo build\n",
)
.unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].pattern_id, "CB-533");
assert_eq!(v[0].file, "Makefile");
assert!(v[0].description.contains("nonexistent"));
}
#[test]
fn test_check_makefile_cd_to_existing_dir_not_flagged() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"build:\n\tcd src && cargo build\n",
)
.unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_makefile_cd_dot_and_dotdot_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"t:\n\tcd . && echo ok\n\tcd .. && echo ok\n",
)
.unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty(), "'.' and '..' must be skipped");
}
#[test]
fn test_check_makefile_cd_variable_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"t:\n\tcd $(PROJECT) && echo ok\n",
)
.unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty(), "`$` prefix must be skipped");
}
#[test]
fn test_check_makefile_cd_flag_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Makefile"), "t:\n\tcd -p some/dir\n").unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_makefile_comment_lines_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"# cd nonexistent\n\ntarget:\n\techo ok\n",
)
.unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_makefile_missing_makefile_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let mut v = Vec::new();
check_makefile(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_ci_workflows_missing_dir_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let mut v = Vec::new();
check_ci_workflows(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_ci_workflows_working_directory_to_missing_flagged() {
let tmp = tempfile::tempdir().unwrap();
let wf = tmp.path().join(".github/workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::write(
wf.join("ci.yml"),
"jobs:\n build:\n steps:\n - run: cargo test\n working-directory: nonexistent_dir\n",
)
.unwrap();
let mut v = Vec::new();
check_ci_workflows(tmp.path(), &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].pattern_id, "CB-533");
assert!(v[0].file.contains("ci.yml"));
}
#[test]
fn test_check_ci_workflows_working_directory_to_existing_not_flagged() {
let tmp = tempfile::tempdir().unwrap();
let wf = tmp.path().join(".github/workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::create_dir(tmp.path().join("sub")).unwrap();
std::fs::write(
wf.join("ci.yml"),
"jobs:\n build:\n working-directory: sub\n",
)
.unwrap();
let mut v = Vec::new();
check_ci_workflows(tmp.path(), &mut v);
assert!(v.is_empty());
}
#[test]
fn test_check_ci_workflows_non_yaml_files_ignored() {
let tmp = tempfile::tempdir().unwrap();
let wf = tmp.path().join(".github/workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::write(wf.join("README.md"), "working-directory: missing_dir\n").unwrap();
let mut v = Vec::new();
check_ci_workflows(tmp.path(), &mut v);
assert!(v.is_empty(), "non-yaml workflow files must be skipped");
}
#[test]
fn test_check_ci_workflows_working_directory_dot_prefix_skipped() {
let tmp = tempfile::tempdir().unwrap();
let wf = tmp.path().join(".github/workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::write(wf.join("ci.yaml"), "working-directory: ./relative\n").unwrap();
let mut v = Vec::new();
check_ci_workflows(tmp.path(), &mut v);
assert!(v.is_empty(), "'./' prefix must be skipped");
}
#[test]
fn test_detect_cb533_combines_makefile_and_ci_violations() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Makefile"),
"t:\n\tcd missing_makefile_dir\n",
)
.unwrap();
let wf = tmp.path().join(".github/workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::write(wf.join("ci.yml"), "working-directory: missing_ci_dir\n").unwrap();
let violations = detect_cb533_stale_path_references(tmp.path());
assert_eq!(violations.len(), 2);
assert!(violations
.iter()
.any(|v| v.description.contains("missing_makefile_dir")));
assert!(violations
.iter()
.any(|v| v.description.contains("missing_ci_dir")));
}
}