// Archive audit engine.
//
// Where the validator reports hard specification violations, the
// audit engine reports advisory findings: conditions that are legal
// WARC but usually indicate a capturing, indexing or curation
// problem. Findings never fail a parse; they inform a human or a
// downstream policy.
///|
/// The severity of an audit finding.
pub enum FindingSeverity {
Info
Warning
} derive(Eq, @debug.Debug)
///|
/// Every severity code in declaration order.
pub fn all_severity_codes() -> Array[String] {
["info", "warning"]
}
///|
/// Resolve a severity code back to a severity.
pub fn severity_of_code(code : String) -> FindingSeverity? {
match code {
"info" => Some(Info)
"warning" => Some(Warning)
_ => None
}
}
///|
/// The lowercase machine-readable severity name.
pub fn FindingSeverity::severity_name(self : FindingSeverity) -> String {
match self {
Info => "info"
Warning => "warning"
}
}
///|
/// An advisory audit finding anchored to a record.
pub struct WarcFinding {
severity : FindingSeverity
code : String
record_index : Int64
context : String
}
///|
/// Construct a finding.
pub fn WarcFinding::new(
severity : FindingSeverity,
code : String,
record_index : Int64,
context : String,
) -> WarcFinding {
{ severity, code, record_index, context }
}
///|
/// The finding's severity.
pub fn WarcFinding::severity(self : WarcFinding) -> FindingSeverity {
self.severity
}
///|
/// The machine-readable finding code.
pub fn WarcFinding::code(self : WarcFinding) -> String {
self.code
}
///|
/// The record the finding is anchored to.
pub fn WarcFinding::record_index(self : WarcFinding) -> Int64 {
self.record_index
}
///|
/// A human-readable description of the finding.
pub fn WarcFinding::context(self : WarcFinding) -> String {
self.context
}
///|
/// A one-line rendering of the finding.
pub fn WarcFinding::to_string(self : WarcFinding) -> String {
"[\{self.severity.severity_name()}] \{self.code} (record \{self.record_index}): \{self.context}"
}
///|
/// Run the audit over a whole archive and return every finding in
/// file order.
pub fn audit_archive(a : WarcArchive) -> Array[WarcFinding] {
let findings : Array[WarcFinding] = []
let n = a.record_count()
// Collect record ids first so dangling references can be checked.
let ids : Array[String] = []
for i = 0; i < n; i = i + 1 {
let id = field_first_of(a.record(i).unwrap().fields, "WARC-Record-ID")
match id {
Some(v) =>
match parse_uri_ref(v, i.to_int64()) {
Ok(interior) => ids.push(interior)
Err(_) => ()
}
None => ()
}
}
for i = 0; i < n; i = i + 1 {
let rec = a.record(i).unwrap()
audit_record(rec, i, ids, findings)
}
findings
}
///|
/// Audit one record, appending findings to `out`.
fn audit_record(
rec : WarcRecord,
index : Int,
ids : Array[String],
out : Array[WarcFinding],
) -> Unit {
// Unknown WARC-Type: readers must skip the record per clause 5.5,
// so a capture that produced one is worth a warning.
let type_value = field_first_of(rec.fields, "WARC-Type")
match type_value {
Some(t) =>
match rec.record_type() {
None =>
out.push(
WarcFinding::new(
Warning,
"unknown-record-type",
index.to_int64(),
"unknown WARC-Type value \{t}; readers must skip this record",
),
)
Some(_) => ()
}
None => ()
}
// Empty blocks on payload-bearing records usually indicate a
// failed capture.
if rec.block_length() == 0 {
match rec.record_type() {
Some(t) =>
if t == Response || t == Resource || t == Conversion {
out.push(
WarcFinding::new(
Info,
"empty-block",
index.to_int64(),
"record type \{t.type_name()} carries an empty content block",
),
)
}
None => ()
}
}
// Digests are the standard integrity check; their absence is
// advisory.
if field_first_of(rec.fields, "WARC-Block-Digest") is None {
out.push(
WarcFinding::new(
Info,
"missing-block-digest",
index.to_int64(),
"record has no WARC-Block-Digest",
),
)
}
// Clause 5.6 recommends Content-Type on non-empty blocks.
if rec.block_length() > 0 &&
field_first_of(rec.fields, "Content-Type") is None {
out.push(
WarcFinding::new(
Info,
"missing-content-type",
index.to_int64(),
"non-empty block has no Content-Type (recommended by clause 5.6)",
),
)
}
// A WARC-Refers-To that matches no record id in the archive points
// outside it, which is often a curation mistake.
let refers = field_first_of(rec.fields, "WARC-Refers-To")
match refers {
Some(v) =>
match parse_uri_ref(v, index.to_int64()) {
Ok(interior) =>
if !contains_string(ids, interior) {
out.push(
WarcFinding::new(
Warning,
"dangling-refers-to",
index.to_int64(),
"WARC-Refers-To \{interior} matches no WARC-Record-ID in this archive",
),
)
}
Err(_) => ()
}
None => ()
}
}