use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CheckSeverity {
Pass,
Warn,
Fail,
}
impl fmt::Display for CheckSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CheckSeverity::Pass => write!(f, "PASS"),
CheckSeverity::Warn => write!(f, "WARN"),
CheckSeverity::Fail => write!(f, "FAIL"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub name: String,
pub severity: CheckSeverity,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalToolError {
pub tool: String,
pub executable: String,
pub exit_status: Option<i32>,
pub stderr_summary: String,
pub output_empty: bool,
pub json_parse_failed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExternalExtraction {
pub tool: String,
pub version: Option<String>,
pub copyright: Option<String>,
pub creators: Vec<String>,
pub usage_terms: Option<String>,
pub rights_url: Option<String>,
pub credit_line: Option<String>,
pub copyright_owner: Option<String>,
pub licensor_name: Option<String>,
pub licensor_email: Option<String>,
pub licensor_url: Option<String>,
pub content_creation_date: Option<String>,
pub ai_constraints: Option<String>,
pub canonical_data_mining: Option<String>,
pub legacy_data_mining: Vec<String>,
pub tdm_reserved: Option<bool>,
#[serde(flatten)]
pub extra: HashMap<String, String>,
}
impl ExternalExtraction {
#[must_use]
pub fn has_notice_content(&self) -> bool {
if self.copyright.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.copyright_owner.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.usage_terms.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.rights_url.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.credit_line.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_name.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_email.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_url.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.ai_constraints.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.tdm_reserved == Some(true) {
return true;
}
if !self.creators.is_empty() {
return true;
}
if let Some(ref dmi) = self.canonical_data_mining {
let lower = dmi.to_lowercase();
if !lower.contains("empty") && !lower.contains("unspecified") {
return true;
}
}
for d in &self.legacy_data_mining {
let lower = d.to_lowercase();
if !lower.contains("empty") && !lower.contains("unspecified") {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InternalExtraction {
pub copyright_holder: Option<String>,
pub creators: Vec<String>,
pub copyright_owner: Option<String>,
pub usage_terms: Option<String>,
pub web_statement_of_rights: Option<String>,
pub credit_line: Option<String>,
pub licensor_name: Option<String>,
pub licensor_email: Option<String>,
pub licensor_url: Option<String>,
pub content_creation_date: Option<String>,
pub ai_constraints: Option<String>,
pub canonical_data_mining: Option<String>,
pub legacy_data_mining: Vec<String>,
pub tdm_reserved: Option<bool>,
pub seed: Option<u64>,
pub evidence_channels: Vec<String>,
pub evidence_strength: Option<String>,
}
impl InternalExtraction {
#[must_use]
pub fn has_notice_content(&self) -> bool {
if self
.copyright_holder
.as_ref()
.is_some_and(|s| !s.is_empty())
{
return true;
}
if self.copyright_owner.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.usage_terms.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self
.web_statement_of_rights
.as_ref()
.is_some_and(|s| !s.is_empty())
{
return true;
}
if self.credit_line.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_name.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_email.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.licensor_url.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.ai_constraints.as_ref().is_some_and(|s| !s.is_empty()) {
return true;
}
if self.tdm_reserved == Some(true) {
return true;
}
if !self.creators.is_empty() {
return true;
}
if let Some(ref dmi) = self.canonical_data_mining {
let lower = dmi.to_lowercase();
if !lower.contains("empty") && !lower.contains("unspecified") {
return true;
}
}
for d in &self.legacy_data_mining {
let lower = d.to_lowercase();
if !lower.contains("empty") && !lower.contains("unspecified") {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceReport {
pub fixture: String,
pub format: String,
pub generated_by: String,
pub decode_valid: bool,
pub xmp_valid: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fixture_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub internal: InternalExtraction,
pub external: Vec<ExternalExtraction>,
pub checks: Vec<CheckResult>,
pub conflicts: Vec<String>,
pub passed: bool,
}
impl ConformanceReport {
#[must_use]
pub fn new(fixture: &str, format: &str) -> Self {
Self {
fixture: fixture.to_string(),
format: format.to_string(),
generated_by: "stegoeggo-conformance".to_string(),
decode_valid: false,
xmp_valid: None,
fixture_id: None,
category: None,
source: None,
internal: InternalExtraction::default(),
external: Vec::new(),
checks: Vec::new(),
conflicts: Vec::new(),
passed: false,
}
}
pub fn add_check(&mut self, name: &str, severity: CheckSeverity, message: &str) {
self.checks.push(CheckResult {
name: name.to_string(),
severity,
message: message.to_string(),
details: None,
});
}
pub fn add_check_with_details(
&mut self,
name: &str,
severity: CheckSeverity,
message: &str,
details: &str,
) {
self.checks.push(CheckResult {
name: name.to_string(),
severity,
message: message.to_string(),
details: Some(details.to_string()),
});
}
pub fn add_conflict(&mut self, conflict: &str) {
self.conflicts.push(conflict.to_string());
}
pub fn evaluate(&mut self) {
self.passed = !self
.checks
.iter()
.any(|c| c.severity == CheckSeverity::Fail);
}
#[must_use]
pub fn summary(&self) -> String {
let mut lines = Vec::new();
let status = if self.passed { "PASS" } else { "FAIL" };
lines.push(format!(
"Fixture: {} ({}) — {}",
self.fixture, self.format, status
));
for check in &self.checks {
lines.push(format!(
" [{}] {}: {}",
check.severity, check.name, check.message
));
}
if !self.conflicts.is_empty() {
lines.push("Conflicts:".to_string());
for c in &self.conflicts {
lines.push(format!(" - {}", c));
}
}
lines.join("\n")
}
}
#[must_use]
pub fn detect_format(bytes: &[u8]) -> Option<String> {
if bytes.len() < 4 {
return None;
}
if bytes.starts_with(b"\x89PNG") {
Some("png".to_string())
} else if bytes.starts_with(b"\xFF\xD8\xFF") {
Some("jpeg".to_string())
} else if bytes.len() >= 12 && &bytes[8..12] == b"WEBP" {
Some("webp".to_string())
} else {
None
}
}
#[must_use]
pub fn normalize_dmi_value(s: &str) -> String {
match s {
"DMI-PROHIBITED-EXCEPTSEARCHENGINEINDEXING" => {
return "DMI-PROHIBITED-EXCEPTSEARCHENGINEINDEXING".to_string()
}
"DMI-PROHIBITED-GENAIMLTRAINING" => return "DMI-PROHIBITED-GENAIMLTRAINING".to_string(),
"DMI-PROHIBITED-AIMLTRAINING" => return "DMI-PROHIBITED-AIMLTRAINING".to_string(),
"DMI-PROHIBITED-SEECONSTRAINT" => return "DMI-PROHIBITED-SEECONSTRAINT".to_string(),
"DMI-PROHIBITED" => return "DMI-PROHIBITED".to_string(),
"DMI-ALLOWED" => return "DMI-ALLOWED".to_string(),
_ => {}
}
let lower = s.to_lowercase();
if lower.contains("prohibited") && lower.contains("search") {
"DMI-PROHIBITED-EXCEPTSEARCHENGINEINDEXING".to_string()
} else if lower.contains("prohibited") && lower.contains("gen") && lower.contains("ai") {
"DMI-PROHIBITED-GENAIMLTRAINING".to_string()
} else if lower.contains("prohibited") && (lower.contains("ai") || lower.contains("aiml")) {
"DMI-PROHIBITED-AIMLTRAINING".to_string()
} else if lower.contains("prohibited") && lower.contains("see") {
"DMI-PROHIBITED-SEECONSTRAINT".to_string()
} else if lower.contains("prohibited") {
"DMI-PROHIBITED".to_string()
} else if lower.contains("allowed") || lower.contains("permitted") {
"DMI-ALLOWED".to_string()
} else {
s.to_string()
}
}
#[must_use]
pub fn normalize_unicode(s: &str) -> String {
s.nfc().collect()
}
#[must_use]
pub fn normalize_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<&str>>().join(" ")
}
#[must_use]
pub fn normalize_url(s: &str) -> String {
let trimmed = s.trim_end_matches('/');
if let Some(pos) = trimmed.find("://") {
let scheme_end = pos + 3;
let rest = &trimmed[scheme_end..];
if let Some(slash_pos) = rest.find('/') {
let host = &rest[..slash_pos];
let path = &rest[slash_pos..];
let lower_host = host.to_lowercase();
format!("{}://{}{}", trimmed[..pos].to_lowercase(), lower_host, path)
} else {
format!(
"{}://{}",
trimmed[..pos].to_lowercase(),
rest.to_lowercase()
)
}
} else {
trimmed.to_string()
}
}
#[must_use]
pub fn normalize_creator_list(v: &[String]) -> Vec<String> {
v.iter()
.map(|s| normalize_whitespace(s))
.filter(|s| !s.is_empty())
.collect()
}
fn is_known_mojibake_exception(fixture: &str, field: &str, internal: &str, external: &str) -> bool {
fixture.contains("canonical_unicode")
&& (field == "copyright" || field == "usage_terms")
&& !internal.is_empty()
&& !external.is_empty()
&& internal.chars().count() != external.chars().count()
}
pub fn compare_extractions(
internal: &InternalExtraction,
external: &ExternalExtraction,
report: &mut ConformanceReport,
) {
let fixture_name = report.fixture.clone();
let text_check = |name: &str,
internal_val: &Option<String>,
external_val: &Option<String>,
report: &mut ConformanceReport| {
match (internal_val, external_val) {
(Some(i), Some(e)) => {
let ni = normalize_unicode(&normalize_whitespace(i));
let ne = normalize_unicode(&normalize_whitespace(e));
if ni == ne {
report.add_check(name, CheckSeverity::Pass, "Internal and external agree");
} else if is_known_mojibake_exception(&fixture_name, name, i, e) {
report.add_check_with_details(
name,
CheckSeverity::Warn,
"Known mojibake exception (tool transcodes through legacy codepage)",
&format!("internal={:?}, external={:?}", i, e),
);
} else {
report.add_check_with_details(
name,
CheckSeverity::Fail,
"Internal and external disagree",
&format!("internal={:?}, external={:?}", i, e),
);
}
}
(Some(i), None) => {
report.add_check_with_details(
name,
CheckSeverity::Warn,
"Found internally but not via external parser",
&format!("internal={:?}", i),
);
}
(None, Some(e)) => {
report.add_check_with_details(
name,
CheckSeverity::Warn,
"Found via external parser but not internally",
&format!("external={:?}", e),
);
}
(None, None) => {
report.add_check(name, CheckSeverity::Pass, "Both absent");
}
}
};
let url_check = |name: &str,
internal_val: &Option<String>,
external_val: &Option<String>,
report: &mut ConformanceReport| {
match (internal_val, external_val) {
(Some(i), Some(e)) => {
let ni = normalize_url(i);
let ne = normalize_url(e);
if ni == ne {
report.add_check(name, CheckSeverity::Pass, "Internal and external agree");
} else {
report.add_check_with_details(
name,
CheckSeverity::Fail,
"Internal and external disagree",
&format!("internal={:?}, external={:?}", i, e),
);
}
}
(Some(i), None) => {
report.add_check_with_details(
name,
CheckSeverity::Warn,
"Found internally but not via external parser",
&format!("internal={:?}", i),
);
}
(None, Some(e)) => {
report.add_check_with_details(
name,
CheckSeverity::Warn,
"Found via external parser but not internally",
&format!("external={:?}", e),
);
}
(None, None) => {
report.add_check(name, CheckSeverity::Pass, "Both absent");
}
}
};
let normalized_copyright_external = external
.copyright
.as_ref()
.map(|c| c.strip_prefix("Copyright (c) ").unwrap_or(c).to_string());
text_check(
"copyright",
&internal.copyright_holder,
&normalized_copyright_external,
report,
);
text_check(
"usage_terms",
&internal.usage_terms,
&external.usage_terms,
report,
);
url_check(
"rights_url",
&internal.web_statement_of_rights,
&external.rights_url,
report,
);
text_check(
"credit_line",
&internal.credit_line,
&external.credit_line,
report,
);
text_check(
"ai_constraints",
&internal.ai_constraints,
&external.ai_constraints,
report,
);
match (
&internal.canonical_data_mining,
&external.canonical_data_mining,
) {
(Some(i), Some(e)) => {
let ni = normalize_dmi_value(i);
let ne = normalize_dmi_value(e);
if ni == ne {
report.add_check(
"canonical_dmi",
CheckSeverity::Pass,
"DMI values agree (normalized)",
);
} else {
report.add_check_with_details(
"canonical_dmi",
CheckSeverity::Fail,
"DMI values disagree",
&format!(
"internal={:?} (normalized={:?}), external={:?} (normalized={:?})",
i, ni, e, ne
),
);
}
}
(Some(i), None) => {
report.add_check_with_details(
"canonical_dmi",
CheckSeverity::Warn,
"DMI found internally but not externally",
&format!("internal={:?}", i),
);
}
(None, Some(e)) => {
report.add_check_with_details(
"canonical_dmi",
CheckSeverity::Warn,
"DMI found externally but not internally",
&format!("external={:?}", e),
);
}
(None, None) => {
report.add_check("canonical_dmi", CheckSeverity::Pass, "Both absent");
}
}
let ni = normalize_creator_list(&internal.creators);
let ne = normalize_creator_list(&external.creators);
if ni == ne {
report.add_check("creators", CheckSeverity::Pass, "Creator lists match");
} else if !ni.is_empty() && ne.is_empty() {
report.add_check_with_details(
"creators",
CheckSeverity::Warn,
"Found internally but not via external parser",
&format!(
"internal={:?}, external={:?}",
internal.creators, external.creators
),
);
} else if ni.is_empty() && !ne.is_empty() {
report.add_check_with_details(
"creators",
CheckSeverity::Warn,
"Found via external parser but not internally",
&format!(
"internal={:?}, external={:?}",
internal.creators, external.creators
),
);
} else {
report.add_check_with_details(
"creators",
CheckSeverity::Fail,
"Creator lists differ",
&format!(
"internal={:?}, external={:?}",
internal.creators, external.creators
),
);
}
}
#[must_use]
pub fn collect_fixture_files(dir: &Path, format_filter: &Option<String>) -> Vec<PathBuf> {
let mut files = Vec::new();
if !dir.exists() {
return files;
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(collect_fixture_files(&path, format_filter));
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let fmt = match ext {
"png" => Some("png"),
"jpg" | "jpeg" => Some("jpeg"),
"webp" => Some("webp"),
_ => None,
};
if let Some(f) = fmt {
if format_filter.as_ref().is_none_or(|filter| filter == f) {
files.push(path);
}
}
}
}
}
files
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecodeExpectation {
#[default]
Pass,
Fail,
Either,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XmpExpectation {
#[default]
Valid,
Invalid,
Absent,
Either,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExtractionExpectation {
#[default]
Success,
NoNotice,
Reject,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExpectedLegalFields {
pub copyright_holder: Option<String>,
pub creator: Option<String>,
pub copyright_owner: Option<String>,
pub usage_terms: Option<String>,
pub web_statement_of_rights: Option<String>,
pub ai_constraints: Option<String>,
pub credit_line: Option<String>,
pub licensor_name: Option<String>,
pub licensor_email: Option<String>,
pub licensor_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureEntry {
pub id: String,
pub path: String,
pub format: String,
pub category: String,
pub authoring_tool: String,
pub authoring_tool_version: String,
pub generation_command: String,
pub source: String,
pub license: String,
pub sha256: String,
pub expected_dmi: String,
pub expected_conflict: bool,
#[serde(default)]
pub expected_legal_fields: ExpectedLegalFields,
#[serde(default)]
pub expected_malformed: bool,
#[serde(default)]
pub expected_decode: DecodeExpectation,
#[serde(default)]
pub expected_xmp: XmpExpectation,
#[serde(default)]
pub expected_internal: ExtractionExpectation,
#[serde(default)]
pub expected_external: ExtractionExpectation,
#[serde(default)]
pub required_external_fields: Vec<String>,
#[serde(default)]
pub expected_preservation: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureManifest {
#[serde(default = "Vec::new", rename = "fixture")]
pub entries: Vec<FixtureEntry>,
}
impl FixtureManifest {
pub fn compute_sha256(path: &Path) -> std::io::Result<String> {
use sha2::{Digest, Sha256};
let bytes = std::fs::read(path)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(hex::encode(hasher.finalize()))
}
#[must_use]
pub fn find_by_path(&self, path: &str) -> Option<&FixtureEntry> {
self.entries.iter().find(|e| e.path == path)
}
#[must_use]
pub fn path_index(&self) -> std::collections::HashMap<String, &FixtureEntry> {
self.entries.iter().map(|e| (e.path.clone(), e)).collect()
}
#[must_use]
pub fn entries_by_category(&self, category: &str) -> Vec<&FixtureEntry> {
self.entries
.iter()
.filter(|e| e.category == category)
.collect()
}
#[must_use]
pub fn entries_by_format(&self, format: &str) -> Vec<&FixtureEntry> {
self.entries.iter().filter(|e| e.format == format).collect()
}
#[must_use]
pub fn count_by_authoring_tool(&self) -> std::collections::HashMap<String, usize> {
let mut counts = std::collections::HashMap::new();
for entry in &self.entries {
*counts.entry(entry.authoring_tool.clone()).or_insert(0) += 1;
}
counts
}
}
pub fn load_manifest(path: &Path) -> Result<FixtureManifest, String> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read manifest {}: {}", path.display(), e))?;
let manifest: FixtureManifest =
toml::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))?;
Ok(manifest)
}
pub fn validate_manifest(manifest: &FixtureManifest) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
let valid_formats = ["png", "jpeg", "webp"];
let valid_categories = [
"canonical",
"legacy",
"conflicting",
"malformed",
"preservation",
];
let valid_sources = [
"generated",
"external",
"historical",
"generated-negative",
"current-generated",
];
for entry in &manifest.entries {
if entry.id.is_empty() {
errors.push(format!("Fixture at '{}' has empty ID", entry.path));
}
if !seen_ids.insert(&entry.id) {
errors.push(format!("Duplicate fixture ID: '{}'", entry.id));
}
if !seen_paths.insert(&entry.path) {
errors.push(format!("Duplicate fixture path: '{}'", entry.path));
}
if entry.path.starts_with('/') || entry.path.starts_with('\\') {
errors.push(format!("Fixture '{}' has absolute path", entry.id));
}
if entry.path.contains("..") {
errors.push(format!(
"Fixture '{}' contains path traversal (..)",
entry.id
));
}
if !valid_formats.contains(&entry.format.as_str()) {
errors.push(format!(
"Fixture '{}' has unsupported format: '{}'",
entry.id, entry.format
));
}
if !valid_categories.contains(&entry.category.as_str()) {
errors.push(format!(
"Fixture '{}' has unsupported category: '{}'",
entry.id, entry.category
));
}
if !valid_sources.contains(&entry.source.as_str()) {
errors.push(format!(
"Fixture '{}' has unsupported source: '{}'",
entry.id, entry.source
));
}
if entry.sha256.is_empty() {
errors.push(format!("Fixture '{}' has empty SHA-256", entry.id));
} else if entry.sha256.len() != 64 || !entry.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
errors.push(format!(
"Fixture '{}' has invalid SHA-256: expected 64 hex characters",
entry.id
));
}
if entry.source == "external" && entry.authoring_tool_version.is_empty() {
errors.push(format!(
"Fixture '{}' has empty authoring_tool_version for external source",
entry.id
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DigestCheckResult {
pub fixture_id: String,
pub fixture_path: String,
pub expected: String,
pub observed: String,
pub matches: bool,
}
pub fn verify_fixtures(manifest: &FixtureManifest, fixtures_dir: &Path) -> Vec<DigestCheckResult> {
manifest
.entries
.iter()
.map(|entry| {
let full_path = fixtures_dir.join(&entry.path);
let actual = FixtureManifest::compute_sha256(&full_path).unwrap_or_default();
DigestCheckResult {
fixture_id: entry.id.clone(),
fixture_path: entry.path.clone(),
expected: entry.sha256.clone(),
observed: actual.clone(),
matches: actual == entry.sha256,
}
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageMinimums {
pub canonical_png: usize,
pub canonical_jpeg: usize,
pub canonical_webp: usize,
pub legacy_min: usize,
pub legacy_formats: usize,
pub conflict_min: usize,
pub malformed_min: usize,
pub malformed_per_format: usize,
pub preservation_min: usize,
pub preservation_formats: usize,
pub external_canonical_png: usize,
pub external_canonical_jpeg: usize,
pub external_canonical_webp: usize,
pub external_legacy_min: usize,
pub external_alt_prefix_min: usize,
pub external_conflict_min: usize,
pub external_preservation_min: usize,
}
impl Default for CoverageMinimums {
fn default() -> Self {
Self {
canonical_png: 1,
canonical_jpeg: 1,
canonical_webp: 1,
legacy_min: 3,
legacy_formats: 2,
conflict_min: 3,
malformed_min: 4,
malformed_per_format: 1,
preservation_min: 3,
preservation_formats: 3,
external_canonical_png: 1,
external_canonical_jpeg: 1,
external_canonical_webp: 1,
external_legacy_min: 1,
external_alt_prefix_min: 1,
external_conflict_min: 1,
external_preservation_min: 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageCheckResult {
pub passed: bool,
pub violations: Vec<String>,
pub observed_canonical_png: usize,
pub observed_canonical_jpeg: usize,
pub observed_canonical_webp: usize,
pub observed_legacy: usize,
pub observed_conflict: usize,
pub observed_malformed: usize,
pub observed_preservation: usize,
pub observed_external_canonical_png: usize,
pub observed_external_canonical_jpeg: usize,
pub observed_external_canonical_webp: usize,
pub observed_external_legacy: usize,
pub observed_external_alt_prefix: usize,
pub observed_external_conflict: usize,
pub observed_external_preservation: usize,
}
#[must_use]
pub fn check_coverage(
manifest: &FixtureManifest,
minimums: &CoverageMinimums,
) -> CoverageCheckResult {
let mut violations = Vec::new();
let canonical = manifest.entries_by_category("canonical");
let canonical_png = canonical.iter().filter(|e| e.format == "png").count();
let canonical_jpeg = canonical.iter().filter(|e| e.format == "jpeg").count();
let canonical_webp = canonical.iter().filter(|e| e.format == "webp").count();
if canonical_png < minimums.canonical_png {
violations.push(format!(
"canonical PNG: {} < {}",
canonical_png, minimums.canonical_png
));
}
if canonical_jpeg < minimums.canonical_jpeg {
violations.push(format!(
"canonical JPEG: {} < {}",
canonical_jpeg, minimums.canonical_jpeg
));
}
if canonical_webp < minimums.canonical_webp {
violations.push(format!(
"canonical WebP: {} < {}",
canonical_webp, minimums.canonical_webp
));
}
let legacy = manifest.entries_by_category("legacy");
let legacy_format_count = legacy
.iter()
.map(|e| e.format.as_str())
.collect::<std::collections::HashSet<_>>()
.len();
if legacy.len() < minimums.legacy_min {
violations.push(format!(
"legacy: {} < {}",
legacy.len(),
minimums.legacy_min
));
}
if legacy_format_count < minimums.legacy_formats {
violations.push(format!(
"legacy formats: {} < {}",
legacy_format_count, minimums.legacy_formats
));
}
let conflict = manifest.entries_by_category("conflicting");
if conflict.len() < minimums.conflict_min {
violations.push(format!(
"conflict: {} < {}",
conflict.len(),
minimums.conflict_min
));
}
let malformed = manifest.entries_by_category("malformed");
if malformed.len() < minimums.malformed_min {
violations.push(format!(
"malformed: {} < {}",
malformed.len(),
minimums.malformed_min
));
}
let malformed_png = malformed.iter().filter(|e| e.format == "png").count();
let malformed_jpeg = malformed.iter().filter(|e| e.format == "jpeg").count();
let malformed_webp = malformed.iter().filter(|e| e.format == "webp").count();
if minimums.malformed_per_format > 0 {
if malformed_png < minimums.malformed_per_format {
violations.push(format!(
"malformed PNG: {} < {}",
malformed_png, minimums.malformed_per_format
));
}
if malformed_jpeg < minimums.malformed_per_format {
violations.push(format!(
"malformed JPEG: {} < {}",
malformed_jpeg, minimums.malformed_per_format
));
}
if malformed_webp < minimums.malformed_per_format {
violations.push(format!(
"malformed WebP: {} < {}",
malformed_webp, minimums.malformed_per_format
));
}
}
let preservation = manifest.entries_by_category("preservation");
let preservation_format_count = preservation
.iter()
.map(|e| e.format.as_str())
.collect::<std::collections::HashSet<_>>()
.len();
if preservation.len() < minimums.preservation_min {
violations.push(format!(
"preservation: {} < {}",
preservation.len(),
minimums.preservation_min
));
}
if preservation_format_count < minimums.preservation_formats {
violations.push(format!(
"preservation formats: {} < {}",
preservation_format_count, minimums.preservation_formats
));
}
let external_canonical_png = canonical
.iter()
.filter(|e| e.format == "png" && e.source == "external")
.count();
let external_canonical_jpeg = canonical
.iter()
.filter(|e| e.format == "jpeg" && e.source == "external")
.count();
let external_canonical_webp = canonical
.iter()
.filter(|e| e.format == "webp" && e.source == "external")
.count();
let external_legacy = legacy.iter().filter(|e| e.source == "external").count();
let external_conflict = conflict.iter().filter(|e| e.source == "external").count();
let external_preservation = preservation
.iter()
.filter(|e| e.source == "external")
.count();
let external_alt_prefix = manifest
.entries
.iter()
.filter(|e| e.source == "external" && e.category == "canonical")
.filter(|e| {
e.generation_command.contains("alt")
|| e.generation_command.contains("prefix")
|| e.id.contains("alt")
})
.count();
if external_canonical_png < minimums.external_canonical_png {
violations.push(format!(
"external canonical PNG: {} < {}",
external_canonical_png, minimums.external_canonical_png
));
}
if external_canonical_jpeg < minimums.external_canonical_jpeg {
violations.push(format!(
"external canonical JPEG: {} < {}",
external_canonical_jpeg, minimums.external_canonical_jpeg
));
}
if external_canonical_webp < minimums.external_canonical_webp {
violations.push(format!(
"external canonical WebP: {} < {}",
external_canonical_webp, minimums.external_canonical_webp
));
}
if external_legacy < minimums.external_legacy_min {
violations.push(format!(
"external legacy: {} < {}",
external_legacy, minimums.external_legacy_min
));
}
if external_alt_prefix < minimums.external_alt_prefix_min {
violations.push(format!(
"external alt-prefix: {} < {}",
external_alt_prefix, minimums.external_alt_prefix_min
));
}
if external_conflict < minimums.external_conflict_min {
violations.push(format!(
"external conflict: {} < {}",
external_conflict, minimums.external_conflict_min
));
}
if external_preservation < minimums.external_preservation_min {
violations.push(format!(
"external preservation: {} < {}",
external_preservation, minimums.external_preservation_min
));
}
CoverageCheckResult {
passed: violations.is_empty(),
violations,
observed_canonical_png: canonical_png,
observed_canonical_jpeg: canonical_jpeg,
observed_canonical_webp: canonical_webp,
observed_legacy: legacy.len(),
observed_conflict: conflict.len(),
observed_malformed: malformed.len(),
observed_preservation: preservation.len(),
observed_external_canonical_png: external_canonical_png,
observed_external_canonical_jpeg: external_canonical_jpeg,
observed_external_canonical_webp: external_canonical_webp,
observed_external_legacy: external_legacy,
observed_external_alt_prefix: external_alt_prefix,
observed_external_conflict: external_conflict,
observed_external_preservation: external_preservation,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceSummary {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub by_format: std::collections::HashMap<String, usize>,
pub by_category: std::collections::HashMap<String, usize>,
pub digest_verification: Option<Vec<DigestCheckResult>>,
pub coverage: Option<CoverageCheckResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage_minimums: Option<CoverageMinimums>,
}
impl ConformanceSummary {
#[must_use]
pub fn from_reports(reports: &[ConformanceReport]) -> Self {
let total = reports.len();
let passed = reports.iter().filter(|r| r.passed).count();
let failed = total - passed;
let mut by_format = std::collections::HashMap::new();
for report in reports {
*by_format.entry(report.format.clone()).or_insert(0) += 1;
}
Self {
total,
passed,
failed,
by_format,
by_category: std::collections::HashMap::new(),
digest_verification: None,
coverage: None,
coverage_minimums: None,
}
}
pub fn with_digest_verification(&mut self, results: Vec<DigestCheckResult>) {
self.digest_verification = Some(results);
}
pub fn with_coverage(&mut self, result: CoverageCheckResult) {
self.coverage = Some(result);
}
pub fn with_coverage_minimums(&mut self, minimums: CoverageMinimums) {
self.coverage_minimums = Some(minimums);
}
#[must_use]
pub fn summary(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"Conformance Summary: {} total, {} passed, {} failed",
self.total, self.passed, self.failed
));
if !self.by_format.is_empty() {
lines.push("By format:".to_string());
for (fmt, count) in &self.by_format {
lines.push(format!(" {}: {}", fmt, count));
}
}
if let Some(ref digest) = self.digest_verification {
let matching = digest.iter().filter(|d| d.matches).count();
lines.push(format!(
"Digest verification: {}/{} passed",
matching,
digest.len()
));
}
if let Some(ref coverage) = self.coverage {
if coverage.passed {
lines.push("Coverage: PASS".to_string());
} else {
lines.push("Coverage: FAIL".to_string());
for v in &coverage.violations {
lines.push(format!(" - {}", v));
}
}
}
lines.join("\n")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolReport {
pub name: String,
pub path: Option<String>,
pub version: Option<String>,
pub discovered: bool,
pub exercised: bool,
pub invocations: u32,
pub successes: u32,
pub failures: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestReport {
pub requested_path: String,
pub canonical_path: Option<String>,
pub sha256: String,
pub entry_count: usize,
pub validation: Result<(), Vec<String>>,
pub duplicate_count: usize,
pub unlisted_count: usize,
pub unexercised_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConformanceRunReport {
pub schema_version: u32,
pub generated_by: String,
pub crate_version: String,
pub commit_sha: Option<String>,
pub strict: bool,
pub complete: bool,
pub passed: bool,
pub started_at: Option<String>,
pub manifest: Option<ManifestReport>,
pub tools: Vec<ToolReport>,
pub coverage_minimums: Option<CoverageMinimums>,
pub coverage: Option<CoverageCheckResult>,
pub digest_verification: Vec<DigestCheckResult>,
pub summary: ConformanceSummary,
pub incomplete_reasons: Vec<String>,
pub fixtures: Vec<ConformanceReport>,
}
#[must_use]
pub fn is_progressive_jpeg(bytes: &[u8]) -> bool {
if bytes.len() < 4 || !bytes.starts_with(b"\xFF\xD8\xFF") {
return false;
}
let mut pos = 2;
while pos + 4 <= bytes.len() {
if bytes[pos] != 0xFF {
break;
}
let marker = bytes[pos + 1];
if marker == 0xD8 || marker == 0xD9 {
pos += 2;
continue;
}
if marker == 0xC0 || marker == 0xC2 {
return marker == 0xC2;
}
let length = u16::from_be_bytes([bytes[pos + 2], bytes[pos + 3]]) as usize;
pos += 2 + length;
}
false
}