use super::parse;
use super::validate::{
indicator_pair_valid, isdst_byte_valid, rfc_designation_index_valid, utoff_structural_valid,
};
use crate::json::escape;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TzifStructuralVerdict {
Conformant,
Violation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PosixFooterVerdict {
NotApplicable,
Absent,
Parseable,
ParseError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReaderCompatibilityVerdict {
NoKnownHazard,
LegacyTransitionCountHazard,
V4ReaderHazard,
NotExercised,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeapExpiryVerdict {
NoLeapTable,
LeapTableNoExpiration,
ExpirationPresent,
NotApplicable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TzifVersionVerdict {
V1,
V2,
V3,
V4,
Unknown,
}
impl TzifStructuralVerdict {
pub fn as_str(&self) -> &'static str {
match self {
TzifStructuralVerdict::Conformant => "conformant",
TzifStructuralVerdict::Violation => "violation",
}
}
}
impl PosixFooterVerdict {
pub fn as_str(self) -> &'static str {
match self {
PosixFooterVerdict::NotApplicable => "not_applicable",
PosixFooterVerdict::Absent => "absent",
PosixFooterVerdict::Parseable => "parseable",
PosixFooterVerdict::ParseError => "parse_error",
}
}
}
impl ReaderCompatibilityVerdict {
pub fn as_str(self) -> &'static str {
match self {
ReaderCompatibilityVerdict::NoKnownHazard => "no_known_hazard",
ReaderCompatibilityVerdict::LegacyTransitionCountHazard => {
"legacy_transition_count_hazard"
}
ReaderCompatibilityVerdict::V4ReaderHazard => "v4_reader_hazard",
ReaderCompatibilityVerdict::NotExercised => "not_exercised",
ReaderCompatibilityVerdict::Unknown => "unknown",
}
}
}
impl LeapExpiryVerdict {
pub fn as_str(self) -> &'static str {
match self {
LeapExpiryVerdict::NoLeapTable => "no_leap_table",
LeapExpiryVerdict::LeapTableNoExpiration => "leap_table_no_expiration",
LeapExpiryVerdict::ExpirationPresent => "expiration_present",
LeapExpiryVerdict::NotApplicable => "not_applicable",
}
}
}
impl TzifVersionVerdict {
pub fn as_str(self) -> &'static str {
match self {
TzifVersionVerdict::V1 => "v1",
TzifVersionVerdict::V2 => "v2",
TzifVersionVerdict::V3 => "v3",
TzifVersionVerdict::V4 => "v4",
TzifVersionVerdict::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone)]
pub struct TzifValidation {
pub structural: TzifStructuralVerdict,
pub footer: PosixFooterVerdict,
pub reader_compatibility: ReaderCompatibilityVerdict,
pub leap_expiry: LeapExpiryVerdict,
pub version: TzifVersionVerdict,
pub violations: Vec<String>,
}
const LEGACY_TIMECNT_THRESHOLD: u32 = 1200;
pub fn validate(bytes: &[u8]) -> TzifValidation {
let parsed = match parse(bytes) {
Ok(p) => p,
Err(e) => {
return TzifValidation {
structural: TzifStructuralVerdict::Violation,
footer: PosixFooterVerdict::NotApplicable,
reader_compatibility: ReaderCompatibilityVerdict::Unknown,
leap_expiry: LeapExpiryVerdict::NotApplicable,
version: TzifVersionVerdict::Unknown,
violations: vec![format!("parse failed: {e}")],
};
}
};
let c = &parsed.counts;
let mut v: Vec<String> = Vec::new();
if c.typecnt == 0 {
v.push("typecnt must be >= 1".into());
}
for (i, t) in parsed.transitions.iter().enumerate() {
if (t.type_index as u32) >= c.typecnt {
v.push(format!(
"transition[{i}] type index {} out of bounds (typecnt={})",
t.type_index, c.typecnt
));
break;
}
}
if c.isutcnt != 0 && c.isutcnt != c.typecnt {
v.push(format!(
"isutcnt {} must be 0 or typecnt ({})",
c.isutcnt, c.typecnt
));
}
if c.isstdcnt != 0 && c.isstdcnt != c.typecnt {
v.push(format!(
"isstdcnt {} must be 0 or typecnt ({})",
c.isstdcnt, c.typecnt
));
}
if parsed.transitions.windows(2).any(|w| w[0].at >= w[1].at) {
v.push("transition times are not strictly ascending".into());
}
let raw = &parsed.raw;
for (i, t) in parsed.types.iter().enumerate() {
if !utoff_structural_valid(t.utoff) {
v.push(format!(
"ttinfo[{i}] utoff == i32::MIN — RFC 9636 §3.2 forbids -2^31"
));
}
if let Some(&isdst) = raw.isdst.get(i) {
if !isdst_byte_valid(isdst) {
v.push(format!("ttinfo[{i}] isdst byte {isdst} not in {{0,1}}"));
}
}
if let Some(&didx) = raw.desigidx.get(i) {
let charcnt = raw.designation.len();
let idx = didx as usize;
let has_nul = idx < charcnt && raw.designation[idx..].contains(&0);
if !rfc_designation_index_valid(idx, charcnt, has_nul) {
v.push(format!(
"ttinfo[{i}] designation index {didx} invalid for charcnt {charcnt} (RFC 9636 §3.2: idx<charcnt, charcnt!=0, NUL at/after idx)"
));
}
}
}
if raw.std_indicators.len() == raw.ut_indicators.len() {
for (i, (&isut, &isstd)) in raw
.ut_indicators
.iter()
.zip(raw.std_indicators.iter())
.enumerate()
{
if !indicator_pair_valid(isut, isstd) {
v.push(format!(
"indicator[{i}]: isut={isut} isstd={isstd} violates RFC 9636 §3.2 (each in {{0,1}}, isut==1 ⇒ isstd==1)"
));
}
}
} else {
for (i, &b) in raw.std_indicators.iter().enumerate() {
if b > 1 {
v.push(format!("isstd indicator[{i}] byte {b} not in {{0,1}}"));
}
}
for (i, &b) in raw.ut_indicators.iter().enumerate() {
if b > 1 {
v.push(format!("isut indicator[{i}] byte {b} not in {{0,1}}"));
}
}
}
let structural = if v.is_empty() {
TzifStructuralVerdict::Conformant
} else {
TzifStructuralVerdict::Violation
};
let version = match parsed.version {
0 => TzifVersionVerdict::V1,
b'2' => TzifVersionVerdict::V2,
b'3' => TzifVersionVerdict::V3,
b'4' => TzifVersionVerdict::V4,
_ => TzifVersionVerdict::Unknown,
};
let footer = if matches!(version, TzifVersionVerdict::V1) {
PosixFooterVerdict::NotApplicable
} else if parsed.footer.is_empty() {
PosixFooterVerdict::Absent
} else {
PosixFooterVerdict::Parseable
};
let leap_expiry = if parsed.leaps.is_empty() {
LeapExpiryVerdict::NoLeapTable
} else {
let n = parsed.leaps.len();
let expiry_marker = n >= 2 && parsed.leaps[n - 1].corr == parsed.leaps[n - 2].corr;
if expiry_marker {
LeapExpiryVerdict::ExpirationPresent
} else {
LeapExpiryVerdict::LeapTableNoExpiration
}
};
let reader_compatibility = if c.timecnt > LEGACY_TIMECNT_THRESHOLD {
ReaderCompatibilityVerdict::LegacyTransitionCountHazard
} else if matches!(leap_expiry, LeapExpiryVerdict::ExpirationPresent)
|| matches!(version, TzifVersionVerdict::V4)
{
ReaderCompatibilityVerdict::V4ReaderHazard
} else {
ReaderCompatibilityVerdict::NoKnownHazard
};
TzifValidation {
structural,
footer,
reader_compatibility,
leap_expiry,
version,
violations: v,
}
}
impl TzifValidation {
pub fn to_json_fields(&self) -> String {
let viol = self
.violations
.iter()
.map(|s| escape(s))
.collect::<Vec<_>>()
.join(", ");
format!(
"\"tzif_structural_verdict\": {}, \"posix_footer_verdict\": {}, \
\"reader_compatibility_verdict\": {}, \"leap_expiry_verdict\": {}, \
\"tzif_version_verdict\": {}, \"violations\": [{}]",
escape(self.structural.as_str()),
escape(self.footer.as_str()),
escape(self.reader_compatibility.as_str()),
escape(self.leap_expiry.as_str()),
escape(self.version.as_str()),
viol,
)
}
}
#[derive(Debug, Clone)]
pub struct TzifValidationRow {
pub producer: &'static str,
pub zone: String,
pub validation: TzifValidation,
}
#[derive(Debug, Clone)]
pub struct TzifValidationReport {
pub rows: Vec<TzifValidationRow>,
pub reference_validated: bool,
}
impl TzifValidationReport {
pub fn to_json(&self) -> String {
let mut s = String::new();
s.push_str("{\n");
s.push_str(" \"schema\": \"zic-rs-tzif-validation-v1\",\n");
s.push_str(&format!(
" \"artifact_category\": {},\n",
escape(crate::manifest::ArtifactCategory::StructuralValidationArtifact.as_str())
));
s.push_str(&format!(
" \"reference_validated\": {},\n",
self.reference_validated
));
s.push_str(&format!(
" \"note\": {},\n",
escape(
"RFC 9636 byte-format integrity only — NOT semantic behaviour (semantic-report) and NOT a \
hardened security sandbox; does not claim arbitrary-TZif round-trip."
)
));
s.push_str(" \"validations\": [");
for (i, row) in self.rows.iter().enumerate() {
s.push_str(if i == 0 { "\n" } else { ",\n" });
s.push_str(&format!(
" {{ \"producer\": {}, \"zone\": {}, {} }}",
escape(row.producer),
escape(&row.zone),
row.validation.to_json_fields(),
));
}
s.push_str(if self.rows.is_empty() {
"]\n"
} else {
"\n ]\n"
});
s.push_str("}\n");
s
}
}
pub fn build_validation_report(
db: &crate::model::Database,
zones: &[String],
reference_zic: &str,
inputs: &[std::path::PathBuf],
work_dir: &std::path::Path,
) -> crate::error::Result<TzifValidationReport> {
let mut rows = Vec::new();
for zone in zones {
if let Ok(bytes) = crate::compile_zone_to_bytes(db, zone) {
rows.push(TzifValidationRow {
producer: "zic_rs",
zone: zone.clone(),
validation: validate(&bytes),
});
}
}
let reference_validated = crate::compare::reference_zic::is_available(reference_zic);
if reference_validated {
let ref_root = work_dir.join("ref");
crate::compare::reference_zic::compile_with_reference(reference_zic, inputs, &ref_root)?;
for zone in zones {
let p = crate::compare::reference_zic::compiled_path(&ref_root, zone);
if let Ok(bytes) = std::fs::read(&p) {
rows.push(TzifValidationRow {
producer: "reference_zic",
zone: zone.clone(),
validation: validate(&bytes),
});
}
}
}
Ok(TzifValidationReport {
rows,
reference_validated,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tzif::{write_bytes, TzifData};
fn valid_bytes() -> Vec<u8> {
write_bytes(&TzifData::fixed(-18000, "EST", "EST5")).unwrap()
}
#[test]
fn well_formed_zic_rs_output_is_conformant() {
let r = validate(&valid_bytes());
assert_eq!(
r.structural,
TzifStructuralVerdict::Conformant,
"{:?}",
r.violations
);
assert!(r.violations.is_empty());
assert_eq!(r.version, TzifVersionVerdict::V2);
assert_eq!(r.footer, PosixFooterVerdict::Parseable);
assert_eq!(r.leap_expiry, LeapExpiryVerdict::NoLeapTable);
assert_eq!(
r.reader_compatibility,
ReaderCompatibilityVerdict::NoKnownHazard
);
}
#[test]
fn bad_magic_is_a_violation_not_a_panic() {
let r = validate(b"NOPEnotatzif................");
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(r.violations[0].contains("parse failed"));
}
#[test]
fn truncated_header_is_a_violation() {
let r = validate(b"TZif2"); assert_eq!(r.structural, TzifStructuralVerdict::Violation);
}
#[test]
fn empty_input_is_a_violation() {
assert_eq!(validate(b"").structural, TzifStructuralVerdict::Violation);
}
#[test]
fn type_index_out_of_bounds_is_caught_as_a_violation() {
let mut b: Vec<u8> = Vec::new();
b.extend_from_slice(b"TZif"); b.push(0); b.extend_from_slice(&[0u8; 15]); for n in [0u32, 0, 0, 1, 1, 4] {
b.extend_from_slice(&n.to_be_bytes());
}
b.extend_from_slice(&0i32.to_be_bytes()); b.push(5); b.extend_from_slice(&[0, 0, 0, 0, 0, 0]); b.extend_from_slice(b"UTC\0");
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations
.iter()
.any(|s| s.contains("type index 5 out of range")),
"expected a type-index-bounds violation (now enforced by parse), got {:?}",
r.violations
);
}
fn v1_one_type(
utoff: i32,
isdst: u8,
desigidx: u8,
designation: &[u8],
std_ind: &[u8],
ut_ind: &[u8],
) -> Vec<u8> {
let mut b = Vec::new();
b.extend_from_slice(b"TZif");
b.push(0); b.extend_from_slice(&[0u8; 15]);
for n in [
ut_ind.len() as u32,
std_ind.len() as u32,
0,
0,
1,
designation.len() as u32,
] {
b.extend_from_slice(&n.to_be_bytes());
}
b.extend_from_slice(&utoff.to_be_bytes()); b.push(isdst); b.push(desigidx); b.extend_from_slice(designation);
b.extend_from_slice(std_ind);
b.extend_from_slice(ut_ind);
b
}
#[test]
fn v1_one_type_clean_is_conformant() {
let r = validate(&v1_one_type(-18000, 1, 0, b"EST\0", &[0], &[0]));
assert_eq!(
r.structural,
TzifStructuralVerdict::Conformant,
"{:?}",
r.violations
);
}
#[test]
fn isdst_byte_out_of_range_parses_but_is_a_format_violation() {
let b = v1_one_type(0, 2, 0, b"UTC\0", &[], &[]); assert!(
parse(&b).is_ok(),
"parse is memory-safe-lenient (does not reject isdst=2)"
);
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("isdst byte 2")),
"{:?}",
r.violations
);
}
#[test]
fn designation_index_equals_charcnt_parses_but_is_a_format_violation() {
let b = v1_one_type(0, 0, 4, b"UTC\0", &[], &[]);
assert!(parse(&b).is_ok());
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("designation index")),
"{:?}",
r.violations
);
}
#[test]
fn designation_without_nul_parses_but_is_a_format_violation() {
let b = v1_one_type(0, 0, 0, b"ABC", &[], &[]); assert!(parse(&b).is_ok());
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("designation index")),
"{:?}",
r.violations
);
}
#[test]
fn indicator_pair_ut_without_std_parses_but_is_a_format_violation() {
let b = v1_one_type(0, 0, 0, b"UTC\0", &[0], &[1]); assert!(parse(&b).is_ok());
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("isut=1 isstd=0")),
"{:?}",
r.violations
);
}
#[test]
fn indicator_byte_out_of_range_parses_but_is_a_format_violation() {
let b = v1_one_type(0, 0, 0, b"UTC\0", &[2], &[]); assert!(parse(&b).is_ok());
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("isstd indicator")),
"{:?}",
r.violations
);
}
#[test]
fn utoff_i32_min_parses_but_is_a_format_violation() {
let b = v1_one_type(i32::MIN, 0, 0, b"UTC\0", &[], &[]);
assert!(parse(&b).is_ok());
let r = validate(&b);
assert_eq!(r.structural, TzifStructuralVerdict::Violation);
assert!(
r.violations.iter().any(|s| s.contains("utoff == i32::MIN")),
"{:?}",
r.violations
);
}
}