mod expansion;
mod format_validation;
mod includes;
mod policy;
mod recipes;
mod resource_types;
pub(crate) mod unknown_fields;
mod validation;
#[cfg(test)]
mod tests_arch;
#[cfg(test)]
mod tests_core;
#[cfg(test)]
mod tests_expansion;
#[cfg(test)]
mod tests_format_validation;
#[cfg(test)]
mod tests_includes;
#[cfg(test)]
mod tests_misc;
#[cfg(test)]
mod tests_misc_2;
#[cfg(test)]
mod tests_misc_2b;
#[cfg(test)]
mod tests_misc_3;
#[cfg(test)]
mod tests_misc_4;
#[cfg(test)]
mod tests_policy;
#[cfg(test)]
mod tests_policy_b;
#[cfg(test)]
mod tests_resource_types_cov;
#[cfg(test)]
mod tests_sarif;
#[cfg(test)]
mod tests_sudo_inference;
#[cfg(test)]
mod tests_triggers;
#[cfg(test)]
mod tests_unknown_fields;
#[cfg(test)]
mod tests_validation;
use super::recipe;
use super::types::*;
use std::path::Path;
pub use expansion::expand_resources;
pub use policy::{
evaluate_policies, evaluate_policies_full, policy_check_to_json, policy_check_to_sarif,
};
pub use recipes::expand_recipes;
const KNOWN_ARCHITECTURES: &[&str] =
&["x86_64", "aarch64", "armv7l", "riscv64", "s390x", "ppc64le"];
#[derive(Debug, Clone)]
pub struct ValidationError {
pub message: String,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
pub fn parse_config_file(path: &Path) -> Result<ForjarConfig, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
parse_config(&content)
}
pub fn parse_config(yaml: &str) -> Result<ForjarConfig, String> {
serde_yaml_ng::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))
}
pub fn validate_config(config: &ForjarConfig) -> Vec<ValidationError> {
let mut errors = Vec::new();
if config.version != "1.0" {
errors.push(ValidationError {
message: format!("version must be \"1.0\", got \"{}\"", config.version),
});
}
if config.name.is_empty() {
errors.push(ValidationError {
message: "name must not be empty".to_string(),
});
}
for (id, resource) in &config.resources {
validation::validate_resource_refs(config, id, resource, &mut errors);
resource_types::validate_resource_type(id, resource, &mut errors);
check_sudo_inference(id, resource, config, &mut errors);
}
for (key, machine) in &config.machines {
validation::validate_machine(key, machine, &mut errors);
}
errors.extend(format_validation::validate_formats(config));
errors
}
const PRIVILEGED_PREFIXES: &[&str] = &[
"/etc/",
"/usr/lib/systemd/",
"/boot/",
"/var/lib/",
"/opt/",
"/usr/local/bin/",
"/usr/local/sbin/",
];
fn check_sudo_inference(
id: &str,
resource: &Resource,
config: &ForjarConfig,
errors: &mut Vec<ValidationError>,
) {
if resource.sudo {
return; }
if resource.resource_type != ResourceType::File {
return; }
let mut any_machine_resolved = false;
for machine_name in resource.machine.iter() {
if let Some(machine) = config.machines.get(machine_name) {
any_machine_resolved = true;
if machine.user == "root" {
return; }
}
}
if !any_machine_resolved {
return;
}
let needs_sudo = resource.owner.as_deref() == Some("root")
|| resource
.path
.as_deref()
.is_some_and(|p| PRIVILEGED_PREFIXES.iter().any(|pfx| p.starts_with(pfx)));
if needs_sudo {
let reason = if resource.owner.as_deref() == Some("root") {
"owner: root"
} else {
"privileged path"
};
errors.push(ValidationError {
message: format!(
"resource '{id}' has {reason} but no sudo: true — add sudo: true or the write will fail with permission denied"
),
});
}
}
pub fn check_unknown_fields(yaml: &str) -> Vec<ValidationError> {
match unknown_fields::detect_unknown_fields(yaml) {
Ok(unknowns) => unknown_fields::unknown_fields_to_errors(&unknowns),
Err(_) => Vec::new(), }
}
pub fn check_unknown_recipe_fields(yaml: &str) -> Vec<ValidationError> {
match unknown_fields::detect_unknown_recipe_fields(yaml) {
Ok(unknowns) => unknown_fields::unknown_fields_to_errors(&unknowns),
Err(_) => Vec::new(),
}
}
pub fn parse_and_validate(path: &Path) -> Result<ForjarConfig, String> {
parse_and_validate_opts(path, false)
}
pub fn parse_and_validate_opts(path: &Path, deny_unknown: bool) -> Result<ForjarConfig, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
let mut config = parse_config(&content)?;
let unknown_warnings = check_unknown_fields(&content);
if !unknown_warnings.is_empty() {
if deny_unknown {
return Err(format!(
"unknown field errors:\n{}",
unknown_warnings
.iter()
.map(|e| format!(" - {e}"))
.collect::<Vec<_>>()
.join("\n")
));
}
for w in &unknown_warnings {
eprintln!("warning: {w}");
}
}
if !config.includes.is_empty() {
let base_dir = path.parent().unwrap_or(Path::new("."));
config = includes::merge_includes(config, base_dir)?;
}
let errors = validate_config(&config);
if !errors.is_empty() {
return Err(format!(
"validation errors:\n{}",
errors
.iter()
.map(|e| format!(" - {e}"))
.collect::<Vec<_>>()
.join("\n")
));
}
expand_recipes(&mut config, path.parent())?;
expand_resources(&mut config);
Ok(config)
}