use std::path::Path;
#[derive(Debug, Clone)]
pub struct AssetValidationResult {
pub asset_type: AssetType,
pub name: String,
pub status: AssetStatus,
pub message: String,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetType {
Readme,
Dockerfile,
Svg,
Changelog,
Badges,
MdBook,
Forjar,
}
impl AssetType {
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn cb_id(&self) -> &'static str {
match self {
Self::Readme => "CB-1320",
Self::Dockerfile => "CB-1321",
Self::Svg => "CB-1322",
Self::Forjar => "CB-1323",
Self::MdBook => "CB-1324",
Self::Changelog => "CB-1325",
Self::Badges => "CB-1326",
}
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn all() -> &'static [AssetType] {
&[
Self::Readme,
Self::Dockerfile,
Self::Svg,
Self::Forjar,
Self::MdBook,
Self::Changelog,
Self::Badges,
]
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"readme" => Some(Self::Readme),
"dockerfile" => Some(Self::Dockerfile),
"svg" => Some(Self::Svg),
"changelog" => Some(Self::Changelog),
"badges" => Some(Self::Badges),
"book" | "mdbook" => Some(Self::MdBook),
"forjar" => Some(Self::Forjar),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetStatus {
Pass,
Warn,
Skip,
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn validate_all_assets(project_path: &Path) -> Vec<AssetValidationResult> {
AssetType::all()
.iter()
.map(|t| validate_asset(project_path, *t))
.collect()
}
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub fn validate_asset(project_path: &Path, asset_type: AssetType) -> AssetValidationResult {
match asset_type {
AssetType::Readme => validate_readme(project_path),
AssetType::Dockerfile => validate_dockerfile(project_path),
AssetType::Svg => validate_svg(project_path),
AssetType::Changelog => validate_changelog(project_path),
AssetType::Badges => validate_badges(project_path),
AssetType::MdBook => validate_mdbook(project_path),
AssetType::Forjar => validate_forjar(project_path),
}
}
fn validate_readme(project_path: &Path) -> AssetValidationResult {
let readme = project_path.join("README.md");
if !readme.exists() {
return AssetValidationResult {
asset_type: AssetType::Readme,
name: "README.md".into(),
status: AssetStatus::Warn,
message: "No README.md found".into(),
issues: vec!["missing".into()],
};
}
let content = std::fs::read_to_string(&readme).unwrap_or_default();
let mut issues = Vec::new();
if !content
.lines()
.any(|l| l.starts_with("# ") || l.contains("<h1"))
{
issues.push("missing title (# heading or <h1>)".into());
}
let lower = content.to_lowercase();
for (name, patterns) in &[
("install", &["install", "getting started", "setup"][..]),
("usage", &["usage", "examples"][..]),
("license", &["license"][..]),
] {
if !patterns
.iter()
.any(|p| lower.contains(&format!("## {}", p)))
{
issues.push(format!("missing section: {}", name));
}
}
if issues.is_empty() {
AssetValidationResult {
asset_type: AssetType::Readme,
name: "README.md".into(),
status: AssetStatus::Pass,
message: "README.md has required sections".into(),
issues: vec![],
}
} else {
AssetValidationResult {
asset_type: AssetType::Readme,
name: "README.md".into(),
status: AssetStatus::Warn,
message: format!("{} issue(s)", issues.len()),
issues,
}
}
}
fn validate_dockerfile(project_path: &Path) -> AssetValidationResult {
let dockerfile = project_path.join("Dockerfile");
if !dockerfile.exists() {
return AssetValidationResult {
asset_type: AssetType::Dockerfile,
name: "Dockerfile".into(),
status: AssetStatus::Skip,
message: "No Dockerfile found".into(),
issues: vec![],
};
}
let content = std::fs::read_to_string(&dockerfile).unwrap_or_default();
let mut issues = Vec::new();
if content.contains(":latest") {
issues.push(":latest tag used".into());
}
if content.contains("curl") && content.contains("bash") {
issues.push("curl|bash pattern detected".into());
}
AssetValidationResult {
asset_type: AssetType::Dockerfile,
name: "Dockerfile".into(),
status: if issues.is_empty() {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: if issues.is_empty() {
"Dockerfile passes security checks".into()
} else {
format!("{} issue(s)", issues.len())
},
issues,
}
}
fn validate_svg(project_path: &Path) -> AssetValidationResult {
let search_dirs = ["assets", "docs", "static", "."];
let mut count = 0usize;
let mut issues = Vec::new();
for dir_name in &search_dirs {
let dir = project_path.join(dir_name);
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(true, |e| e != "svg") || !path.is_file() {
continue;
}
count += 1;
if let Ok(content) = std::fs::read_to_string(&path) {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if !content.contains("viewBox") {
issues.push(format!("{}: missing viewBox", name));
}
if !content.contains("<title") && !content.contains("aria-label") {
issues.push(format!("{}: no accessibility", name));
}
}
}
}
}
if count == 0 {
AssetValidationResult {
asset_type: AssetType::Svg,
name: "SVG".into(),
status: AssetStatus::Skip,
message: "No SVG files found".into(),
issues: vec![],
}
} else {
AssetValidationResult {
asset_type: AssetType::Svg,
name: "SVG".into(),
status: if issues.is_empty() {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: format!("{} file(s), {} issue(s)", count, issues.len()),
issues,
}
}
}
fn validate_changelog(project_path: &Path) -> AssetValidationResult {
let path = project_path.join("CHANGELOG.md");
if !path.exists() {
return AssetValidationResult {
asset_type: AssetType::Changelog,
name: "CHANGELOG.md".into(),
status: AssetStatus::Skip,
message: "No CHANGELOG.md".into(),
issues: vec![],
};
}
let content = std::fs::read_to_string(&path).unwrap_or_default();
let lower = content.to_lowercase();
let has_format = lower.contains("## [") || lower.contains("# changelog");
AssetValidationResult {
asset_type: AssetType::Changelog,
name: "CHANGELOG.md".into(),
status: if has_format {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: if has_format {
"Keep-a-Changelog format".into()
} else {
"Missing version headings".into()
},
issues: if has_format {
vec![]
} else {
vec!["no version headings".into()]
},
}
}
fn validate_badges(project_path: &Path) -> AssetValidationResult {
let readme = project_path.join("README.md");
if !readme.exists() {
return AssetValidationResult {
asset_type: AssetType::Badges,
name: "Badges".into(),
status: AssetStatus::Skip,
message: "No README.md".into(),
issues: vec![],
};
}
let content = std::fs::read_to_string(&readme).unwrap_or_default();
let mut missing = Vec::new();
let ci_patterns = [
"actions/workflows",
"github.com/",
"ci.svg",
"build.svg",
"passing",
"ci.yml",
];
if !ci_patterns.iter().any(|p| content.contains(p)) {
missing.push("CI status".into());
}
let version_patterns = ["crates.io", "version", "crate-", "crate"];
if !version_patterns.iter().any(|p| content.contains(p)) {
missing.push("version/crate".into());
}
if !content.to_lowercase().contains("license") {
missing.push("license".into());
}
AssetValidationResult {
asset_type: AssetType::Badges,
name: "Badges".into(),
status: if missing.is_empty() {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: if missing.is_empty() {
"Required badges present".into()
} else {
format!("Missing: {}", missing.join(", "))
},
issues: missing,
}
}
fn validate_mdbook(project_path: &Path) -> AssetValidationResult {
let summary = project_path.join("book/src/SUMMARY.md");
if !summary.exists() {
return AssetValidationResult {
asset_type: AssetType::MdBook,
name: "mdBook".into(),
status: AssetStatus::Skip,
message: "No book/src/SUMMARY.md".into(),
issues: vec![],
};
}
let content = std::fs::read_to_string(&summary).unwrap_or_default();
let book_src = project_path.join("book/src");
let mut broken = Vec::new();
for line in content.lines() {
if let Some(start) = line.find("](") {
if let Some(end) = line[start + 2..].find(')') {
let link = &line[start + 2..start + 2 + end];
if link.starts_with("http") || link.starts_with('#') {
continue;
}
let link_path = book_src.join(link.split('#').next().unwrap_or(link));
if !link_path.exists() {
broken.push(link.to_string());
}
}
}
}
AssetValidationResult {
asset_type: AssetType::MdBook,
name: "mdBook".into(),
status: if broken.is_empty() {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: if broken.is_empty() {
"SUMMARY.md links valid".into()
} else {
format!("{} broken link(s)", broken.len())
},
issues: broken,
}
}
fn validate_forjar(project_path: &Path) -> AssetValidationResult {
let yaml = project_path.join("forjar.yaml");
let toml = project_path.join("forjar.toml");
let path = if yaml.exists() {
yaml
} else if toml.exists() {
toml
} else {
return AssetValidationResult {
asset_type: AssetType::Forjar,
name: "forjar".into(),
status: AssetStatus::Skip,
message: "No forjar.yaml or forjar.toml".into(),
issues: vec![],
};
};
let content = std::fs::read_to_string(&path).unwrap_or_default();
let mut issues = Vec::new();
let secret_patterns = ["password:", "secret:", "api_key:", "token:"];
for pattern in &secret_patterns {
if content.contains(pattern) && !content.contains("${") && !content.contains("env(") {
issues.push(format!(
"possible plaintext {}",
pattern.trim_end_matches(':')
));
}
}
AssetValidationResult {
asset_type: AssetType::Forjar,
name: "forjar".into(),
status: if issues.is_empty() {
AssetStatus::Pass
} else {
AssetStatus::Warn
},
message: if issues.is_empty() {
"Forjar config passes checks".into()
} else {
format!("{} issue(s)", issues.len())
},
issues,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_validate_all_empty_project() {
let dir = tempdir().unwrap();
let results = validate_all_assets(dir.path());
assert_eq!(results.len(), 7);
let skips = results
.iter()
.filter(|r| r.status == AssetStatus::Skip)
.count();
assert!(skips >= 4); }
#[test]
fn test_validate_readme_with_sections() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("README.md"),
"# Project\n\n## Installation\n\n## Usage\n\n## License\nMIT\n",
)
.unwrap();
let result = validate_asset(dir.path(), AssetType::Readme);
assert_eq!(result.status, AssetStatus::Pass);
}
#[test]
fn test_validate_dockerfile_latest() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("Dockerfile"), "FROM ubuntu:latest\n").unwrap();
let result = validate_asset(dir.path(), AssetType::Dockerfile);
assert_eq!(result.status, AssetStatus::Warn);
assert!(result.issues.iter().any(|i| i.contains(":latest")));
}
#[test]
fn test_asset_type_from_str() {
assert_eq!(AssetType::parse("readme"), Some(AssetType::Readme));
assert_eq!(AssetType::parse("DOCKERFILE"), Some(AssetType::Dockerfile));
assert_eq!(AssetType::parse("mdbook"), Some(AssetType::MdBook));
assert_eq!(AssetType::parse("unknown"), None);
}
#[test]
fn test_asset_type_cb_ids() {
assert_eq!(AssetType::Readme.cb_id(), "CB-1320");
assert_eq!(AssetType::Badges.cb_id(), "CB-1326");
}
}