use crate::utils::error::{Error, Result};
use std::collections::{BTreeMap, BTreeSet};
use super::traits::{LockEntry, Lockfile};
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub metadata: ValidationMetadata,
}
impl ValidationResult {
pub fn ok() -> Self {
Self {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
metadata: ValidationMetadata::default(),
}
}
pub fn fail(errors: Vec<ValidationError>) -> Self {
Self {
valid: false,
errors,
warnings: Vec::new(),
metadata: ValidationMetadata::default(),
}
}
pub fn with_error(mut self, error: ValidationError) -> Self {
self.errors.push(error);
self.valid = false;
self
}
pub fn with_warning(mut self, warning: ValidationWarning) -> Self {
self.warnings.push(warning);
self
}
pub fn merge(mut self, other: ValidationResult) -> Self {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
self.valid = self.valid && other.valid;
self
}
pub fn to_result(self) -> Result<()> {
if self.valid {
Ok(())
} else {
let msg = self
.errors
.iter()
.map(|e| e.message.clone())
.collect::<Vec<_>>()
.join("; ");
Err(Error::new(&format!("Validation failed: {}", msg)))
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub code: ValidationErrorCode,
pub message: String,
pub entry_id: Option<String>,
pub severity: ErrorSeverity,
}
impl ValidationError {
pub fn new(code: ValidationErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
entry_id: None,
severity: ErrorSeverity::Error,
}
}
pub fn with_entry(mut self, id: impl Into<String>) -> Self {
self.entry_id = Some(id.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationErrorCode {
SchemaVersionMismatch,
MissingField,
InvalidIntegrity,
CircularDependency,
MissingDependency,
DuplicateEntry,
InvalidFormat,
PqcVerificationFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorSeverity {
Warning,
Error,
Critical,
}
#[derive(Debug, Clone)]
pub struct ValidationWarning {
pub code: WarningCode,
pub message: String,
pub entry_id: Option<String>,
}
impl ValidationWarning {
pub fn new(code: WarningCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
entry_id: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WarningCode {
DeprecatedSchemaVersion,
MissingOptionalField,
WeakIntegrity,
UnusedEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ValidationMetadata {
pub entries_validated: usize,
pub duration_ms: u64,
pub schema_version: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct IntegrityCheck {
pub id: String,
pub expected: String,
pub actual: String,
pub passed: bool,
}
impl IntegrityCheck {
pub fn new(
id: impl Into<String>, expected: impl Into<String>, actual: impl Into<String>,
) -> Self {
let expected = expected.into();
let actual = actual.into();
let passed = expected == actual;
Self {
id: id.into(),
expected,
actual,
passed,
}
}
pub fn passed(id: impl Into<String>, hash: impl Into<String>) -> Self {
let hash = hash.into();
Self {
id: id.into(),
expected: hash.clone(),
actual: hash,
passed: true,
}
}
}
pub fn validate_lockfile<L: Lockfile>(lockfile: &L) -> ValidationResult {
let mut result = ValidationResult::ok();
let start = std::time::Instant::now();
let schema_version = lockfile.schema_version();
if schema_version == 0 {
result = result.with_error(ValidationError::new(
ValidationErrorCode::SchemaVersionMismatch,
"Schema version must be > 0",
));
}
let generated_at = lockfile.generated_at();
if generated_at.is_empty() {
result = result.with_error(ValidationError::new(
ValidationErrorCode::MissingField,
"Missing generated_at timestamp",
));
}
let mut seen_ids = BTreeSet::new();
let mut entries_validated = 0;
for (id, entry) in lockfile.entries() {
entries_validated += 1;
if !seen_ids.insert(id.to_string()) {
result = result.with_error(
ValidationError::new(
ValidationErrorCode::DuplicateEntry,
format!("Duplicate entry: {}", id),
)
.with_entry(id),
);
}
if entry.version().is_empty() {
result = result.with_error(
ValidationError::new(
ValidationErrorCode::MissingField,
format!("Entry '{}' missing version", id),
)
.with_entry(id),
);
}
if let Some(integrity) = entry.integrity() {
if !integrity.is_empty() && !is_valid_sha256(integrity) {
result = result.with_warning(ValidationWarning {
code: WarningCode::WeakIntegrity,
message: format!(
"Entry '{}' has non-standard integrity format (expected 64 hex chars)",
id
),
entry_id: Some(id.to_string()),
});
}
}
for dep_id in entry.dependencies() {
if lockfile.get(dep_id).is_none() && !seen_ids.contains(dep_id) {
result = result.with_warning(ValidationWarning {
code: WarningCode::MissingOptionalField,
message: format!(
"Entry '{}' depends on '{}' which is not in lockfile",
id, dep_id
),
entry_id: Some(id.to_string()),
});
}
}
}
result.metadata = ValidationMetadata {
entries_validated,
duration_ms: start.elapsed().as_millis() as u64,
schema_version: Some(schema_version),
};
result
}
pub fn check_circular_dependencies<L: Lockfile>(lockfile: &L) -> Result<()> {
let mut graph: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (id, entry) in lockfile.entries() {
graph.insert(id.to_string(), entry.dependencies().to_vec());
}
let mut visited = BTreeSet::new();
let mut rec_stack = BTreeSet::new();
for id in graph.keys() {
if !visited.contains(id) {
if let Some(cycle) =
detect_cycle(&graph, id, &mut visited, &mut rec_stack, &mut Vec::new())
{
return Err(Error::new(&format!(
"Circular dependency detected: {}",
cycle.join(" -> ")
)));
}
}
}
Ok(())
}
fn detect_cycle(
graph: &BTreeMap<String, Vec<String>>, node: &str, visited: &mut BTreeSet<String>,
rec_stack: &mut BTreeSet<String>, path: &mut Vec<String>,
) -> Option<Vec<String>> {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(deps) = graph.get(node) {
for dep in deps {
if !visited.contains(dep) {
if let Some(cycle) = detect_cycle(graph, dep, visited, rec_stack, path) {
return Some(cycle);
}
} else if rec_stack.contains(dep) {
let cycle_start = path.iter().position(|x| x == dep).unwrap_or(0);
let mut cycle: Vec<String> = path[cycle_start..].to_vec();
cycle.push(dep.clone());
return Some(cycle);
}
}
}
path.pop();
rec_stack.remove(node);
None
}
pub fn verify_integrity<L, F>(lockfile: &L, compute_hash: F) -> Vec<IntegrityCheck>
where
L: Lockfile,
F: Fn(&str, &L::Entry) -> String,
{
let mut checks = Vec::new();
for (id, entry) in lockfile.entries() {
let expected = entry.integrity().unwrap_or("").to_string();
if !expected.is_empty() {
let actual = compute_hash(id, entry);
checks.push(IntegrityCheck::new(id, expected, actual));
}
}
checks
}
fn is_valid_sha256(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_result_operations() {
let result = ValidationResult::ok();
assert!(result.valid);
assert!(result.errors.is_empty());
let result = result.with_error(ValidationError::new(
ValidationErrorCode::MissingField,
"Test error",
));
assert!(!result.valid);
assert_eq!(result.errors.len(), 1);
}
#[test]
fn test_validation_result_merge() {
let result1 = ValidationResult::ok().with_warning(ValidationWarning::new(
WarningCode::UnusedEntry,
"Warning 1",
));
let result2 = ValidationResult::fail(vec![ValidationError::new(
ValidationErrorCode::InvalidIntegrity,
"Error 1",
)]);
let merged = result1.merge(result2);
assert!(!merged.valid);
assert_eq!(merged.errors.len(), 1);
assert_eq!(merged.warnings.len(), 1);
}
#[test]
fn test_integrity_check() {
let check = IntegrityCheck::new("test", "abc123", "abc123");
assert!(check.passed);
let check = IntegrityCheck::new("test", "abc123", "def456");
assert!(!check.passed);
}
#[test]
fn test_is_valid_sha256() {
assert!(is_valid_sha256(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
));
assert!(!is_valid_sha256("short"));
assert!(!is_valid_sha256(
"g3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
)); }
#[test]
fn test_circular_dependency_detection() {
let mut graph: BTreeMap<String, Vec<String>> = BTreeMap::new();
graph.insert("a".into(), vec!["b".into()]);
graph.insert("b".into(), vec!["c".into()]);
graph.insert("c".into(), vec![]);
let mut visited = BTreeSet::new();
let mut rec_stack = BTreeSet::new();
assert!(detect_cycle(&graph, "a", &mut visited, &mut rec_stack, &mut Vec::new()).is_none());
let mut graph_cycle: BTreeMap<String, Vec<String>> = BTreeMap::new();
graph_cycle.insert("a".into(), vec!["b".into()]);
graph_cycle.insert("b".into(), vec!["c".into()]);
graph_cycle.insert("c".into(), vec!["a".into()]);
visited.clear();
rec_stack.clear();
assert!(detect_cycle(
&graph_cycle,
"a",
&mut visited,
&mut rec_stack,
&mut Vec::new()
)
.is_some());
}
}