///|
/// Severity assigned to a manifest audit finding.
pub(all) enum AuditSeverity {
Info
Warning
Error
} derive(Debug, Eq)
///|
/// A deterministic, machine-readable audit finding.
pub(all) struct AuditFinding {
code : String
severity : AuditSeverity
path : String
message : String
remediation : String
}
///|
/// Result of validating a manifest against structural and supply-chain rules.
pub(all) struct AuditReport {
findings : Array[AuditFinding]
files_checked : Int
total_bytes : Int64
}
///|
/// Return true when the report contains a blocking finding.
pub fn AuditReport::has_errors(self : AuditReport) -> Bool {
for finding in self.findings {
if finding.severity == AuditSeverity::Error {
return true
}
}
false
}
///|
/// Count findings at a given severity.
pub fn AuditReport::count(self : AuditReport, severity : AuditSeverity) -> Int {
let mut result = 0
for finding in self.findings {
if finding.severity == severity {
result = result + 1
}
}
result
}
///|
/// Format the report as stable text suitable for CI logs.
pub fn AuditReport::to_text(self : AuditReport) -> String {
let out = StringBuilder::new()
out.write_string("files_checked=" + self.files_checked.to_string() + "\n")
out.write_string("total_bytes=" + self.total_bytes.to_string() + "\n")
out.write_string(
"errors=" + self.count(AuditSeverity::Error).to_string() + "\n",
)
out.write_string(
"warnings=" + self.count(AuditSeverity::Warning).to_string() + "\n",
)
for finding in self.findings {
out.write_string(
"[" +
severity_name(finding.severity) +
"] " +
finding.code +
" " +
finding.path +
": " +
finding.message +
" -> " +
finding.remediation +
"\n",
)
}
out.to_string()
}
///|
/// Return the findings in insertion order for integrations that need structured data.
pub fn AuditReport::findings(self : AuditReport) -> Array[AuditFinding] {
self.findings
}
///|
fn severity_name(severity : AuditSeverity) -> String {
match severity {
AuditSeverity::Info => "INFO"
AuditSeverity::Warning => "WARNING"
AuditSeverity::Error => "ERROR"
}
}
///|
/// Validate a manifest using the default conservative supply-chain policy.
///
/// The policy rejects ambiguous paths, duplicate paths, malformed hashes, and
/// a stale Merkle root. It also warns about empty manifests and suspicious
/// generated/build files that should normally be excluded from a release.
pub fn Manifest::audit(self : Manifest) -> AuditReport {
let findings : Array[AuditFinding] = []
let mut total_bytes : Int64 = 0L
let seen : Array[String] = []
if self.files.length() == 0 {
findings.push({
code: "MANIFEST_EMPTY",
severity: AuditSeverity::Warning,
path: "",
message: "manifest contains no file snapshots",
remediation: "include the release files that must be verified",
})
}
for file in self.files {
total_bytes = total_bytes + file.size
audit_path(file, seen, findings)
audit_hashes(file, findings)
if has_generated_suffix(file.path) {
findings.push({
code: "GENERATED_ARTIFACT",
severity: AuditSeverity::Warning,
path: file.path,
message: "path looks like a generated or build artifact",
remediation: "record source inputs or exclude the artifact from the policy",
})
}
seen.push(file.path)
}
if !self.verify_integrity() {
findings.push({
code: "MERKLE_ROOT_MISMATCH",
severity: AuditSeverity::Error,
path: "",
message: "stored Merkle root does not match the current file snapshots",
remediation: "recompute the root after reviewing every changed snapshot",
})
}
{ findings, files_checked: self.files.length(), total_bytes }
}
///|
/// Apply a project-specific allow-list to file extensions and paths.
pub fn Manifest::audit_with_policy(
self : Manifest,
allowed_extensions : Array[String],
max_file_size : Int64,
) -> AuditReport {
let report = self.audit()
let findings = report.findings
for file in self.files {
if file.size > max_file_size {
findings.push({
code: "FILE_TOO_LARGE",
severity: AuditSeverity::Error,
path: file.path,
message: "file exceeds the configured size limit of " +
max_file_size.to_string(),
remediation: "split the artifact or raise the limit with an explicit review",
})
}
if !extension_allowed(file.path, allowed_extensions) {
findings.push({
code: "EXTENSION_DENIED",
severity: AuditSeverity::Warning,
path: file.path,
message: "file extension is outside the configured release policy",
remediation: "review the file or add its extension to the allow-list",
})
}
}
{
findings,
files_checked: report.files_checked,
total_bytes: report.total_bytes,
}
}
///|
fn audit_path(
file : FileSnapshot,
seen : Array[String],
findings : Array[AuditFinding],
) -> Unit {
if file.path.length() == 0 {
findings.push({
code: "PATH_EMPTY",
severity: AuditSeverity::Error,
path: "",
message: "snapshot path is empty",
remediation: "provide a normalized repository-relative path",
})
}
if is_absolute_path(file.path) || contains_parent_segment(file.path) {
findings.push({
code: "PATH_TRAVERSAL",
severity: AuditSeverity::Error,
path: file.path,
message: "snapshot path is absolute or escapes the repository root",
remediation: "use a normalized relative path without '..' segments",
})
}
if contains_backslash(file.path) {
findings.push({
code: "PATH_NOT_PORTABLE",
severity: AuditSeverity::Warning,
path: file.path,
message: "backslash makes the manifest platform-dependent",
remediation: "store paths with '/' regardless of the host operating system",
})
}
for old_path in seen {
if old_path == file.path {
findings.push({
code: "PATH_DUPLICATE",
severity: AuditSeverity::Error,
path: file.path,
message: "more than one snapshot uses the same path",
remediation: "deduplicate the manifest before publishing it",
})
return
}
}
}
///|
fn audit_hashes(file : FileSnapshot, findings : Array[AuditFinding]) -> Unit {
if file.size < 0L {
findings.push({
code: "SIZE_NEGATIVE",
severity: AuditSeverity::Error,
path: file.path,
message: "snapshot size cannot be negative",
remediation: "recreate the snapshot from the original bytes",
})
}
if file.hash_sha256.length() != 64 || !is_lower_hex(file.hash_sha256) {
findings.push({
code: "SHA256_MALFORMED",
severity: AuditSeverity::Error,
path: file.path,
message: "SHA-256 must be exactly 64 lowercase hexadecimal characters",
remediation: "recompute the SHA-256 digest using the canonical encoder",
})
}
}
///|
fn is_absolute_path(path : String) -> Bool {
if path.length() == 0 {
false
} else {
path[0] == '/'.to_int().to_uint16() ||
path[0] == '\\'.to_int().to_uint16() ||
(path.length() > 1 && path[1] == ':'.to_int().to_uint16())
}
}
///|
fn contains_backslash(path : String) -> Bool {
for c in path {
if c == '\\' {
return true
}
}
false
}
///|
fn contains_parent_segment(path : String) -> Bool {
let normalized = replace_backslash(path)
if normalized == ".." ||
starts_with(normalized, "../") ||
ends_with(normalized, "/..") {
return true
}
for i = 0; i + 3 < normalized.length(); i = i + 1 {
if normalized[i] == '/'.to_int().to_uint16() &&
normalized[i + 1] == '.'.to_int().to_uint16() &&
normalized[i + 2] == '.'.to_int().to_uint16() &&
normalized[i + 3] == '/'.to_int().to_uint16() {
return true
}
}
false
}
///|
fn replace_backslash(path : String) -> String {
let out = StringBuilder::new()
for c in path {
if c == '\\' {
out.write_char('/')
} else {
out.write_char(c)
}
}
out.to_string()
}
///|
fn starts_with(value : String, prefix : String) -> Bool {
if value.length() < prefix.length() {
return false
}
for i = 0; i < prefix.length(); i = i + 1 {
if value[i] != prefix[i] {
return false
}
}
true
}
///|
fn ends_with(value : String, suffix : String) -> Bool {
if value.length() < suffix.length() {
return false
}
let offset = value.length() - suffix.length()
for i = 0; i < suffix.length(); i = i + 1 {
if value[offset + i] != suffix[i] {
return false
}
}
true
}
///|
fn is_lower_hex(value : String) -> Bool {
for c in value {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
return false
}
}
true
}
///|
fn has_generated_suffix(path : String) -> Bool {
ends_with(path, ".wasm") ||
ends_with(path, ".map") ||
ends_with(path, ".mbti") ||
starts_with(path, "_build/") ||
starts_with(path, "target/")
}
///|
fn extension_allowed(path : String, allowed : Array[String]) -> Bool {
for extension in allowed {
if ends_with(path, extension) {
return true
}
}
allowed.length() == 0
}