use crate::{
registry::FORMATS, ChronoError, Encoding, Format, LeapSemantics, PosixNs, TzSemantics, Unit,
};
#[derive(Debug, Clone, serde::Serialize)]
pub struct Candidate {
pub format_id: &'static str,
pub label: &'static str,
pub citation: &'static str,
pub instant: PosixNs,
pub rendered: Option<String>,
pub score: f64,
pub components: Vec<(&'static str, f64)>,
pub assumptions: Vec<String>,
pub sentinel: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Endian {
Little,
Big,
}
#[derive(Debug, Clone, Default)]
pub struct InterpretContext<'a> {
pub observed_width_bytes: Option<u8>,
pub endian: Option<Endian>,
pub artifact: Option<&'a str>,
pub neighbours: &'a [i64],
}
#[must_use]
pub fn interpret_int(value: i64) -> Vec<Candidate> {
interpret_int_with_context(value, &InterpretContext::default())
}
#[must_use]
pub fn identify(value: &str) -> Vec<Candidate> {
let s = value.trim();
let mut cands = Vec::new();
if let Ok(v) = s.parse::<i64>() {
cands.extend(interpret_int(v));
} else if let Ok(v) = s.parse::<f64>() {
cands.extend(interpret_float(v));
}
cands.extend(interpret_string(s));
cands.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
cands
}
#[must_use]
pub fn identify_json(value: &str) -> String {
serde_json::to_string(&identify(value))
.unwrap_or_else(|_| "[]".to_owned())
}
#[must_use]
#[tracing::instrument(level = "debug", skip(ctx))]
pub fn interpret_int_with_context(value: i64, ctx: &InterpretContext) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
for f in FORMATS.iter() {
if let Some(c) = build_candidate(f, value, ctx) {
out.push(c);
}
}
out.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
prevalence(b.format_id)
.partial_cmp(&prevalence(a.format_id))
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| a.format_id.cmp(b.format_id))
});
out
}
#[must_use]
pub fn interpret_float(value: f64) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
for f in FORMATS.iter() {
if let Some(c) = build_candidate_float(f, value) {
out.push(c);
}
}
out.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
prevalence(b.format_id)
.partial_cmp(&prevalence(a.format_id))
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| a.format_id.cmp(b.format_id))
});
out
}
fn build_candidate_float(f: &Format, value: f64) -> Option<Candidate> {
let instant = f.decode_float(value).ok()?;
let rendered = instant.to_rfc3339()?;
let components = score_components_float(f, instant);
let score = overall_score(&components);
let assumptions = assumptions(f);
Some(Candidate {
format_id: f.id,
label: f.label,
citation: f.citation,
instant,
rendered: Some(rendered),
score,
components,
assumptions,
sentinel: false,
})
}
fn score_components_float(f: &Format, instant: PosixNs) -> Vec<(&'static str, f64)> {
let in_window = f64::from(u8::from(
instant.0 >= f.plausible.0 && instant.0 < f.plausible.1,
));
vec![
("representable", 1.0),
("in_window", in_window),
("granularity_match", 1.0),
("magnitude_fit", magnitude_fit(f.encoding, instant)),
("epoch_distance", epoch_distance(f.encoding, instant)),
("prevalence", prevalence(f.id)),
("not_sentinel", 1.0),
]
}
fn prevalence(id: &str) -> f64 {
match id {
"active" | "excel1904" | "sony" | "dttm" | "bitdate" | "bitdec" | "bcd" | "moto"
| "symantec" | "dvr" | "ns40" | "ns40le" | "logtime" | "semioctet" | "gsm" | "nokiale"
| "mjd" | "dhcp6" | "hfs" | "gmsgid" => 0.5,
_ => 1.0,
}
}
fn build_candidate(f: &Format, value: i64, ctx: &InterpretContext) -> Option<Candidate> {
let instant = f.decode_int(value).ok()?;
let rendered = instant.to_rfc3339()?;
let components = score_components(f, value, instant, ctx);
let score = overall_score(&components);
let mut assumptions = assumptions(f);
let sentinel = sentinel_reason(value);
if let Some(reason) = sentinel {
assumptions.push(format!(
"value {value} is a likely sentinel ({reason}) — an 'unset'/'never' marker, not necessarily a real instant"
));
}
if f.id == "filetime" && instant.0 % 1_000_000_000 == 0 {
assumptions.push(
"sub-second field is exactly zero — consistent with a SetFileTime-style \
manipulation, not a naturally-recorded instant"
.to_string(),
);
}
if matches!(
f.encoding,
Encoding::LinearInt {
unit: Unit::Seconds,
..
}
) {
const SIGNED_MAX: i64 = i32::MAX as i64; const UNSIGNED_MAX: i64 = u32::MAX as i64; const NEAR: i64 = 63_072_000; if (SIGNED_MAX + 1..=UNSIGNED_MAX).contains(&value) {
assumptions.push(
"stored value exceeds the signed 32-bit range (2^31) but fits an unsigned \
32-bit field — consistent with an unsigned 32-bit time field, or a value \
past a signed field's rollover"
.to_string(),
);
} else if (SIGNED_MAX - NEAR..=SIGNED_MAX).contains(&value) {
assumptions.push(
"stored value is within ~2 years of the signed 32-bit maximum (2^31-1) — \
consistent with approaching the representable limit of a signed 32-bit field \
(the Unix Y2038 boundary for a 1970-epoch field)"
.to_string(),
);
}
}
#[cfg(feature = "leap")]
if crate::leap::within_leap_smear_window((instant.0 / 1_000_000_000) as i64) {
assumptions.push(
"within ±12h of a leap second — a cloud-smeared clock (Google/AWS/Meta) may \
have this reading off by up to 1 s"
.to_string(),
);
}
Some(Candidate {
format_id: f.id,
label: f.label,
citation: f.citation,
instant,
rendered: Some(rendered),
score,
components,
assumptions,
sentinel: sentinel.is_some(),
})
}
fn decode_one(format_id: &str, value: i64, ctx: &InterpretContext) -> Option<Candidate> {
build_candidate(crate::format(format_id).ok()?, value, ctx)
}
fn assumptions(f: &Format) -> Vec<String> {
let mut out = vec![format!(
"consistent with {} [{}] — a reading, not a determination",
f.label, f.citation
)];
if matches!(f.leap, LeapSemantics::PosixIgnored) {
out.push(
"indistinguishable from a leap-smeared source without clock-policy metadata"
.to_string(),
);
}
if matches!(f.tz, TzSemantics::LocalNaive) {
out.push(
"stored as LOCAL wall-clock time with no offset — the instant is naive, not UTC"
.to_string(),
);
}
out
}
#[must_use]
pub fn sentinel_reason(value: i64) -> Option<&'static str> {
match value {
0 => Some("possible sentinel: zero / unset"),
-1 => Some("possible sentinel: -1 / all-ones (unset)"),
i64::MIN => Some("possible sentinel: i64 min (0x8000000000000000 — unset/overflow)"),
i64::MAX => Some("known sentinel: 0x7FFFFFFFFFFFFFFF (e.g. AD accountExpires 'never')"),
_ => None,
}
}
fn score_components(
f: &Format,
value: i64,
instant: PosixNs,
ctx: &InterpretContext,
) -> Vec<(&'static str, f64)> {
let representable = 1.0;
let in_window = f64::from(u8::from(
instant.0 >= f.plausible.0 && instant.0 < f.plausible.1,
));
let granularity = granularity_match(f.encoding, value);
let magnitude = magnitude_fit(f.encoding, instant);
let epoch_dist = epoch_distance(f.encoding, instant);
let not_sentinel = f64::from(u8::from(sentinel_reason(value).is_none()));
let mut components = vec![
("representable", representable),
("in_window", in_window),
("granularity_match", granularity),
("magnitude_fit", magnitude),
("epoch_distance", epoch_dist),
("prevalence", prevalence(f.id)),
("not_sentinel", not_sentinel),
];
if let Some(width) = ctx.observed_width_bytes {
components.push(("byte_width_match", byte_width_match(f, value, width)));
if ctx.endian.is_some() {
components.push(("endian_match", endian_match(f, value, width)));
}
}
if let Some(hint) = ctx.artifact {
components.push(("artifact_match", artifact_match(f, hint)));
}
if !ctx.neighbours.is_empty() {
components.push((
"neighbour_monotonicity",
neighbour_monotonicity(f, ctx.neighbours),
));
}
components
}
fn significant_bytes(value: i64) -> u8 {
let n = value.unsigned_abs();
if n == 0 {
return 1;
}
((64 - n.leading_zeros()).div_ceil(8)) as u8
}
fn byte_width_match(f: &Format, value: i64, observed: u8) -> f64 {
let natural = f.storage_bytes();
if observed == natural {
1.0
} else if significant_bytes(value) <= natural {
0.5
} else {
0.0
}
}
fn decode_in_window(f: &Format, value: i64) -> bool {
f.decode_int(value)
.ok()
.is_some_and(|inst| inst.0 >= f.plausible.0 && inst.0 < f.plausible.1)
}
fn byte_swapped(value: i64, width: u8) -> Option<i64> {
match width {
4 => u32::try_from(value).ok().map(|v| i64::from(v.swap_bytes())),
8 => Some((value as u64).swap_bytes() as i64),
_ => None,
}
}
fn endian_match(f: &Format, value: i64, width: u8) -> f64 {
let this_in = decode_in_window(f, value);
let flip_in = byte_swapped(value, width).is_some_and(|v| decode_in_window(f, v));
match (this_in, flip_in) {
(true, false) => 1.0,
(true, true) => 0.5,
(false, _) => 0.0,
}
}
fn artifact_match(f: &Format, hint: &str) -> f64 {
#[cfg(feature = "artifact-hints")]
if let Some(a) = forensicnomicon::timestamp_artifacts::timestamp_format_for(hint) {
return if f.id == a.format { 1.0 } else { 0.3 };
}
let haystack = format!("{} {} {}", f.id, f.family, f.label).to_lowercase();
let matched = hint
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| t.len() >= 3)
.any(|t| haystack.contains(&t.to_lowercase()));
if matched {
1.0
} else {
0.2
}
}
fn neighbour_monotonicity(f: &Format, neighbours: &[i64]) -> f64 {
if neighbours.len() < 2 {
return f64::from(u8::from(
neighbours.first().is_some_and(|&v| decode_in_window(f, v)),
));
}
let mut consistent = 0u32;
let mut total = 0u32;
for pair in neighbours.windows(2) {
let (a, b) = (pair[0], pair[1]);
total += 1;
let (ia, ib) = (f.decode_int(a).ok(), f.decode_int(b).ok());
if let (Some(ta), Some(tb)) = (ia, ib) {
let in_window = decode_in_window(f, a) && decode_in_window(f, b);
if in_window && ((b >= a) == (tb.0 >= ta.0)) {
consistent += 1;
}
}
}
f64::from(consistent) / f64::from(total)
}
const TWO_YEARS_NS: i128 = 730 * 86_400 * 1_000_000_000;
fn magnitude_fit(strategy: Encoding, instant: PosixNs) -> f64 {
match strategy {
Encoding::Embedded { epoch_ns, .. } => {
let past = instant.0 - epoch_ns;
if past <= 0 {
0.0
} else {
(past as f64 / TWO_YEARS_NS as f64).min(1.0)
}
}
Encoding::LinearInt { .. } | Encoding::LinearFloat { .. } | Encoding::Packed(_) => 1.0,
}
}
fn epoch_distance(strategy: Encoding, instant: PosixNs) -> f64 {
let epoch_ns = match strategy {
Encoding::LinearInt { epoch_ns, .. }
| Encoding::LinearFloat { epoch_ns, .. }
| Encoding::Embedded { epoch_ns, .. } => epoch_ns,
Encoding::Packed(_) => return 1.0,
};
let past = instant.0 - epoch_ns;
if past <= 0 {
0.0
} else {
(past as f64 / TWO_YEARS_NS as f64).min(1.0)
}
}
fn granularity_match(strategy: Encoding, value: i64) -> f64 {
let unit: Unit = match strategy {
Encoding::LinearInt { unit, .. }
| Encoding::LinearFloat { unit, .. }
| Encoding::Embedded { unit, .. } => unit,
Encoding::Packed(_) => return 1.0,
};
let ssd = unit.sub_second_digits();
if ssd == 0 {
return 1.0;
}
let tz = trailing_zeros_base10(value).min(ssd);
1.0 - f64::from(tz) / f64::from(ssd)
}
fn trailing_zeros_base10(value: i64) -> u32 {
let mut n = value.unsigned_abs();
if n == 0 {
return 0;
}
let mut z = 0;
while n.is_multiple_of(10) {
z += 1;
n /= 10;
}
z
}
fn overall_score(components: &[(&'static str, f64)]) -> f64 {
let weight = |name: &str| match name {
"prevalence" => 0.0,
"in_window"
| "magnitude_fit"
| "not_sentinel"
| "byte_width_match"
| "endian_match"
| "neighbour_monotonicity" => 2.0,
_ => 1.0,
};
let (num, den) = components.iter().fold((0.0, 0.0), |(num, den), (n, v)| {
let w = weight(n);
(num + w * v, den + w)
});
if den == 0.0 {
0.0
} else {
num / den
}
}
pub fn interpret_hex(hex: &str) -> Result<Vec<(String, Vec<Candidate>)>, ChronoError> {
const MAX_HEX_BYTES: usize = 64 * 1024;
let clean: String = hex
.chars()
.filter(|c| !c.is_whitespace() && *c != '_' && *c != ':')
.collect();
let clean = clean
.strip_prefix("0x")
.or_else(|| clean.strip_prefix("0X"))
.unwrap_or(&clean);
let bytes = hex::decode(clean).map_err(|_| ChronoError::OutOfRange {
what: "hex (not valid hex bytes)",
value: 0,
})?;
if bytes.len() > MAX_HEX_BYTES {
return Err(ChronoError::OutOfRange {
what: "hex input exceeds 64 KiB",
value: bytes.len() as i128,
});
}
Ok(identify_bytes(&bytes))
}
#[must_use]
pub fn identify_bytes(bytes: &[u8]) -> Vec<(String, Vec<Candidate>)> {
let mut out = Vec::new();
for (label, value, width, endian) in byte_ints(bytes) {
let ctx = InterpretContext {
observed_width_bytes: Some(width),
endian: Some(endian),
..Default::default()
};
out.push((label, interpret_int_with_context(value, &ctx)));
}
if let Some(eight) = bytes.get(..8).and_then(|s| <[u8; 8]>::try_from(s).ok()) {
for (label, v) in [
("f64 LE (IEEE-754 double)", f64::from_le_bytes(eight)),
("f64 BE (IEEE-754 double)", f64::from_be_bytes(eight)),
] {
let cands = interpret_float(v);
if !cands.is_empty() {
out.push((label.to_string(), cands));
}
}
}
if let Some(four) = bytes.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()) {
let lo = u16::from_le_bytes([four[0], four[1]]);
let hi = u16::from_le_bytes([four[2], four[3]]);
let fat_ctx = InterpretContext {
observed_width_bytes: Some(4),
..Default::default()
};
if let Some(c) = decode_one("fat", (i64::from(lo) << 16) | i64::from(hi), &fat_ctx) {
out.push(("FAT/DOS bytes date|time (LE words)".to_string(), vec![c]));
}
if let Some(c) = decode_one("fat", (i64::from(hi) << 16) | i64::from(lo), &fat_ctx) {
out.push((
"FAT/DOS bytes time|date (LE words, directory order)".to_string(),
vec![c],
));
}
}
if let Some(sixteen) = bytes.get(..16) {
if let Some(c) = systemtime_candidate(sixteen) {
out.push((
"SYSTEMTIME (16-byte struct, LE u16 fields)".to_string(),
vec![c],
));
}
}
if bytes
.get(..8)
.and_then(|s| <[u8; 8]>::try_from(s).ok())
.is_some_and(|e| u64::from_le_bytes(e) == u64::MAX)
{
out.push(("u64 all-ones".to_string(), vec![all_ones_sentinel()]));
}
out
}
fn systemtime_candidate(b: &[u8]) -> Option<Candidate> {
let field = |i: usize| -> Option<u16> {
let lo = *b.get(i * 2)?;
let hi = *b.get(i * 2 + 1)?;
Some(u16::from_le_bytes([lo, hi]))
};
let year = i16::try_from(field(0)?).ok()?;
let month = i8::try_from(field(1)?).ok()?;
let day = i8::try_from(field(3)?).ok()?;
let hour = i8::try_from(field(4)?).ok()?;
let minute = i8::try_from(field(5)?).ok()?;
let second = i8::try_from(field(6)?).ok()?;
let millis = field(7)?;
if millis > 999 {
return None;
}
let subsec_nanos = i32::from(millis) * 1_000_000;
let instant = civil_to_posix(year, month, day, hour, minute, second, subsec_nanos, 0)?;
Some(string_candidate(
"systemtime",
"Microsoft 128-bit SYSTEMTIME",
"[MS-DTYP] §2.3.13 SYSTEMTIME (8× little-endian WORD fields)",
instant,
"decoded as a 16-byte SYSTEMTIME struct (UTC unless the source noted local)",
))
}
fn byte_ints(b: &[u8]) -> Vec<(String, i64, u8, Endian)> {
let total = b.len();
let suffix = |w: usize| {
if total > w {
format!(" (first {w} of {total})")
} else {
String::new()
}
};
let mut v = Vec::new();
if let Some(four) = b.get(..4).and_then(|s| <[u8; 4]>::try_from(s).ok()) {
v.push((
format!("u32 LE{}", suffix(4)),
i64::from(u32::from_le_bytes(four)),
4,
Endian::Little,
));
v.push((
format!("u32 BE{}", suffix(4)),
i64::from(u32::from_be_bytes(four)),
4,
Endian::Big,
));
if u32::from_le_bytes(four) >= 0x8000_0000 {
v.push((
format!("i32 LE signed (wrapped time_t){}", suffix(4)),
i64::from(i32::from_le_bytes(four)),
4,
Endian::Little,
));
}
if u32::from_be_bytes(four) >= 0x8000_0000 {
v.push((
format!("i32 BE signed (wrapped time_t){}", suffix(4)),
i64::from(i32::from_be_bytes(four)),
4,
Endian::Big,
));
}
}
if let Some(eight) = b.get(..8).and_then(|s| <[u8; 8]>::try_from(s).ok()) {
if let Ok(n) = i64::try_from(u64::from_le_bytes(eight)) {
v.push((format!("u64 LE{}", suffix(8)), n, 8, Endian::Little));
}
if let Ok(n) = i64::try_from(u64::from_be_bytes(eight)) {
v.push((format!("u64 BE{}", suffix(8)), n, 8, Endian::Big));
}
}
v
}
fn all_ones_sentinel() -> Candidate {
Candidate {
format_id: "sentinel",
label: "all-ones value (0xFFFFFFFFFFFFFFFF)",
citation: "",
instant: PosixNs(0),
rendered: None,
score: 0.0,
components: vec![("not_sentinel", 0.0)],
assumptions: vec![
"0xFFFFFFFFFFFFFFFF — all-ones; commonly an 'unset'/'never' marker, not a real instant"
.to_string(),
],
sentinel: true,
}
}
#[allow(clippy::too_many_lines)]
struct StringFormat {
parse: fn(&str) -> Option<PosixNs>,
id: &'static str,
label: &'static str,
spec: &'static str,
note: &'static str,
}
const STRING_FORMATS: &[StringFormat] = &[
StringFormat {
parse: parse_ulid,
id: "ulid",
label: "ULID (first 48 bits = Unix ms)",
spec: "ULID spec (Crockford base32; 48-bit ms timestamp)",
note: "parsed as a ULID — the leading 48 bits are milliseconds since the Unix epoch",
},
StringFormat {
parse: parse_uuid_v1,
id: "uuid_v1",
label: "UUID version 1 (100ns since 1582-10-15)",
spec: "RFC 9562 §5.1 (UUIDv1 60-bit Gregorian timestamp)",
note: "parsed as a UUIDv1 — a 60-bit count of 100ns intervals since 1582-10-15 UTC",
},
StringFormat {
parse: parse_uuid_v6,
id: "uuid_v6",
label: "UUID version 6 (reordered 100ns since 1582-10-15)",
spec: "RFC 9562 §5.6 (UUIDv6 60-bit Gregorian timestamp)",
note: "parsed as a UUIDv6 — the v1 Gregorian timestamp reordered most-significant-first",
},
StringFormat {
parse: parse_uuid_v7,
id: "uuid_v7",
label: "UUID version 7 (Unix ms in the high 48 bits)",
spec: "RFC 9562 §5.7 (UUIDv7 48-bit Unix-ms timestamp)",
note: "parsed as a UUIDv7 — the leading 48 bits are milliseconds since the Unix epoch",
},
StringFormat {
parse: parse_objectid,
id: "objectid",
label: "MongoDB ObjectId (Unix seconds in the first 4 bytes)",
spec: "MongoDB ObjectId spec (4-byte big-endian Unix-seconds prefix)",
note: "parsed as a MongoDB ObjectId — the first 4 bytes are big-endian Unix seconds",
},
StringFormat {
parse: parse_google_ei,
id: "google_ei",
label: "Google ei= URL parameter (Unix seconds in the first 4 bytes)",
spec: "Google ei URL param (urlsafe base64; first 4 bytes little-endian Unix seconds)",
note: "parsed as a Google ei= URL parameter — the leading 4 bytes are little-endian Unix seconds",
},
StringFormat {
parse: parse_clf,
id: "clf",
label: "Apache/nginx common-log-format date",
spec: "Apache mod_log_config (CLF): dd/Mon/YYYY:HH:MM:SS ±HHMM",
note: "parsed as an Apache/nginx CLF date-time (offset normalised to UTC)",
},
StringFormat {
parse: parse_pdf_date,
id: "pdf_date",
label: "PDF metadata date (D:YYYYMMDDHHmmSS)",
spec: "ISO 32000-1 §7.9.4 (PDF date string)",
note: "parsed as a PDF metadata date (offset normalised to UTC)",
},
StringFormat {
parse: parse_dmtf_cim,
id: "dmtf_cim",
label: "DMTF/WMI CIM_DATETIME",
spec: "DMTF DSP0004 (CIM_DATETIME): yyyymmddHHMMSS.mmmmmm±UUU",
note: "parsed as a DMTF/WMI CIM datetime — UUU is the offset in minutes east of UTC",
},
StringFormat {
parse: parse_rfc2822,
id: "rfc2822",
label: "RFC 2822 / email date",
spec: "RFC 5322 §3.3 (date-time; via jiff)",
note: "parsed as an RFC 2822 date-time (offset normalised to UTC)",
},
StringFormat {
parse: parse_exif,
id: "exif",
label: "EXIF DateTime (YYYY:MM:DD HH:MM:SS)",
spec: "CIPA DC-008 (EXIF) DateTime / DateTimeOriginal",
note: "parsed as an EXIF DateTime; NO offset is stored — assumed UTC, but is usually local time",
},
StringFormat {
parse: parse_iso_ordinal,
id: "iso_ordinal",
label: "ISO 8601 ordinal date (YYYY-DDD)",
spec: "ISO 8601 §5.2.2.1 (ordinal date)",
note: "parsed as an ISO 8601 ordinal date (day-of-year), midnight UTC assumed",
},
StringFormat {
parse: parse_iso_week,
id: "iso_week",
label: "ISO 8601 week date (YYYY-Www-D)",
spec: "ISO 8601 §5.2.3 (week date)",
note: "parsed as an ISO 8601 week date, midnight UTC assumed",
},
];
#[must_use]
#[tracing::instrument(level = "debug", skip(text), fields(len = text.len()))]
pub fn interpret_string(text: &str) -> Vec<Candidate> {
let s = text.trim();
let mut out = Vec::new();
push_iso8601(s, &mut out);
push_asn1(s, &mut out);
push_jwt(s, &mut out);
for f in STRING_FORMATS {
if let Some(instant) = (f.parse)(s) {
out.push(string_candidate(f.id, f.label, f.spec, instant, f.note));
}
}
out
}
fn push_jwt(s: &str, out: &mut Vec<Candidate>) {
let mut parts = s.split('.');
let (Some(_header), Some(payload), Some(_sig)) = (parts.next(), parts.next(), parts.next())
else {
return;
};
if parts.next().is_some() {
return; }
let Some(bytes) = b64url_decode(payload) else {
return;
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return;
};
for (claim, id, label, note) in [
(
"iat",
"jwt_iat",
"JWT issued-at (iat)",
"JWT `iat` claim — Unix seconds",
),
(
"exp",
"jwt_exp",
"JWT expiry (exp)",
"JWT `exp` claim — Unix seconds",
),
(
"nbf",
"jwt_nbf",
"JWT not-before (nbf)",
"JWT `nbf` claim — Unix seconds",
),
] {
if let Some(secs) = claims.get(claim).and_then(serde_json::Value::as_i64) {
let inst = PosixNs(i128::from(secs) * 1_000_000_000);
out.push(string_candidate(
id,
label,
"RFC 7519 (JWT registered claims)",
inst,
note,
));
}
}
}
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut acc: u32 = 0;
let mut bits = 0u32;
for ch in s.bytes() {
if ch == b'=' {
break;
}
acc = (acc << 6) | u32::from(urlsafe_b64_val(ch)?);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xFF) as u8);
}
}
Some(out)
}
fn push_iso8601(s: &str, out: &mut Vec<Candidate>) {
if let Ok(ts) = s.parse::<jiff::Timestamp>() {
out.push(string_candidate(
"iso8601",
"ISO 8601 / RFC 3339 string",
"ISO 8601:2019 / RFC 3339",
PosixNs(ts.as_nanosecond()),
"parsed as an ISO 8601 / RFC 3339 string (offset normalised to UTC)",
));
}
}
fn push_asn1(s: &str, out: &mut Vec<Candidate>) {
if let Some((instant, had_tz)) = parse_asn1_generalizedtime(s) {
out.push(string_candidate(
"asn1_generalizedtime",
"ASN.1 GeneralizedTime",
"ITU-T X.680 / RFC 5280 §4.1.2.5.2",
instant,
&asn1_assumption("GeneralizedTime (4-digit year)", had_tz),
));
}
if let Some((instant, had_tz)) = parse_asn1_utctime(s) {
out.push(string_candidate(
"asn1_utctime",
"ASN.1 UTCTime",
"ITU-T X.680 / RFC 5280 §4.1.2.5.1",
instant,
&asn1_assumption(
"UTCTime (2-digit year; RFC 5280 pivot: <50 => 20YY, else 19YY)",
had_tz,
),
));
}
}
fn parse_iso_ordinal(s: &str) -> Option<PosixNs> {
let (y, d) = s.split_once('-')?;
if y.len() != 4 || d.len() != 3 || !d.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let year: i16 = y.parse().ok()?;
let doy: i64 = d.parse().ok()?;
let date = jiff::civil::Date::new(year, 1, 1)
.ok()?
.checked_add(jiff::Span::new().days(doy - 1))
.ok()?;
civil_to_posix(date.year(), date.month(), date.day(), 0, 0, 0, 0, 0)
}
fn parse_iso_week(s: &str) -> Option<PosixNs> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 3 || parts[0].len() != 4 {
return None;
}
let year: i16 = parts[0].parse().ok()?;
let week: i8 = parts[1].strip_prefix('W')?.parse().ok()?;
let day: i8 = parts[2].parse().ok()?;
let weekday = jiff::civil::Weekday::from_monday_one_offset(day).ok()?;
let date = jiff::civil::ISOWeekDate::new(year, week, weekday)
.ok()?
.date();
civil_to_posix(date.year(), date.month(), date.day(), 0, 0, 0, 0, 0)
}
fn parse_ulid(s: &str) -> Option<PosixNs> {
const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
if s.len() != 26 {
return None;
}
let mut value: u128 = 0;
for ch in s.bytes() {
let up = ch.to_ascii_uppercase();
let idx = ALPHABET.iter().position(|&a| a == up)?;
value = value.checked_mul(32)?.checked_add(idx as u128)?;
}
let ms = i128::from(u64::try_from(value >> 80).ok()?);
Some(PosixNs(ms.checked_mul(Unit::Millis.nanos())?))
}
const UUID_V1_EPOCH_NS: i128 = -12_219_292_800 * 1_000_000_000;
fn parse_uuid_v1(s: &str) -> Option<PosixNs> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let time_low = u64::from_str_radix(hex.get(0..8)?, 16).ok()?;
let time_mid = u64::from_str_radix(hex.get(8..12)?, 16).ok()?;
let time_hi_version = u64::from_str_radix(hex.get(12..16)?, 16).ok()?;
if (time_hi_version >> 12) != 1 {
return None; }
let ts = ((time_hi_version & 0x0FFF) << 48) | (time_mid << 32) | time_low;
let ns = i128::from(ts)
.checked_mul(100)?
.checked_add(UUID_V1_EPOCH_NS)?;
Some(PosixNs(ns))
}
fn uuid_hex(s: &str) -> Option<String> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
(hex.len() == 32 && hex.bytes().all(|b| b.is_ascii_hexdigit())).then_some(hex)
}
fn parse_uuid_v6(s: &str) -> Option<PosixNs> {
let hex = uuid_hex(s)?;
let time_high = u64::from_str_radix(hex.get(0..8)?, 16).ok()?;
let time_mid = u64::from_str_radix(hex.get(8..12)?, 16).ok()?;
let time_low_ver = u64::from_str_radix(hex.get(12..16)?, 16).ok()?;
if (time_low_ver >> 12) != 6 {
return None;
}
let ts = (time_high << 28) | (time_mid << 12) | (time_low_ver & 0x0FFF);
let ns = i128::from(ts)
.checked_mul(100)?
.checked_add(UUID_V1_EPOCH_NS)?;
Some(PosixNs(ns))
}
fn parse_uuid_v7(s: &str) -> Option<PosixNs> {
let hex = uuid_hex(s)?;
let high32 = u64::from_str_radix(hex.get(0..8)?, 16).ok()?;
let mid16 = u64::from_str_radix(hex.get(8..12)?, 16).ok()?;
let ver = u64::from_str_radix(hex.get(12..16)?, 16).ok()? >> 12;
if ver != 7 {
return None;
}
let ms = i128::from((high32 << 16) | mid16);
Some(PosixNs(ms.checked_mul(Unit::Millis.nanos())?))
}
fn parse_objectid(s: &str) -> Option<PosixNs> {
if s.len() != 24 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let secs = i128::from(u32::from_str_radix(s.get(0..8)?, 16).ok()?);
Some(PosixNs(secs.checked_mul(Unit::Seconds.nanos())?))
}
fn parse_google_ei(s: &str) -> Option<PosixNs> {
let val = s.split("ei=").nth(1)?.split(['&', '#']).next()?;
let mut acc: u64 = 0;
for ch in val.get(..6)?.bytes() {
acc = (acc << 6) | u64::from(urlsafe_b64_val(ch)?);
}
let bytes = ((acc >> 4) as u32).to_be_bytes();
let secs = i128::from(u32::from_le_bytes(bytes));
Some(PosixNs(secs.checked_mul(Unit::Seconds.nanos())?))
}
fn urlsafe_b64_val(c: u8) -> Option<u8> {
match c {
b'A'..=b'Z' => Some(c - b'A'),
b'a'..=b'z' => Some(c - b'a' + 26),
b'0'..=b'9' => Some(c - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
fn parse_rfc2822(s: &str) -> Option<PosixNs> {
jiff::fmt::rfc2822::parse(s)
.ok()
.map(|zoned| PosixNs(zoned.timestamp().as_nanosecond()))
}
fn parse_exif(text: &str) -> Option<PosixNs> {
let (date, time) = text.trim().split_once(' ')?;
let date_parts: Vec<&str> = date.split(':').collect();
let time_parts: Vec<&str> = time.split(':').collect();
if date_parts.len() != 3 || time_parts.len() != 3 {
return None;
}
let year: i16 = date_parts[0].parse().ok()?;
let month: i8 = date_parts[1].parse().ok()?;
let day: i8 = date_parts[2].parse().ok()?;
let hour: i8 = time_parts[0].parse().ok()?;
let minute: i8 = time_parts[1].parse().ok()?;
let second: i8 = time_parts[2].parse().ok()?;
civil_to_posix(year, month, day, hour, minute, second, 0, 0)
}
fn asn1_assumption(kind: &str, had_tz: bool) -> String {
if had_tz {
format!("parsed as ASN.1 {kind}")
} else {
format!(
"parsed as ASN.1 {kind}; NO timezone designator — assumed UTC, but may be local time"
)
}
}
fn string_candidate(
format_id: &'static str,
label: &'static str,
citation: &'static str,
instant: PosixNs,
assumption: &str,
) -> Candidate {
Candidate {
format_id,
label,
citation,
instant,
rendered: instant.to_rfc3339(),
score: 1.0,
components: vec![
("representable", 1.0),
("self_describing", 1.0),
("not_sentinel", 1.0),
],
assumptions: vec![assumption.to_string()],
sentinel: false,
}
}
fn split_tz(s: &str) -> Option<(String, i64, bool)> {
if let Some(core) = s.strip_suffix('Z').or_else(|| s.strip_suffix('z')) {
return Some((core.to_string(), 0, true));
}
if s.len() >= 5 && s.is_char_boundary(s.len() - 5) {
let (core, suf) = s.split_at(s.len() - 5);
let b = suf.as_bytes();
if (b[0] == b'+' || b[0] == b'-') && suf[1..].bytes().all(|c| c.is_ascii_digit()) {
let hh: i64 = suf[1..3].parse().ok()?;
let mm: i64 = suf[3..5].parse().ok()?;
if hh > 23 || mm > 59 {
return None; }
let mag = hh * 3600 + mm * 60;
return Some((
core.to_string(),
if b[0] == b'-' { -mag } else { mag },
true,
));
}
}
Some((s.to_string(), 0, false))
}
#[allow(clippy::too_many_arguments)]
fn civil_to_posix(
y: i16,
mo: i8,
d: i8,
h: i8,
mi: i8,
s: i8,
subsec_nanos: i32,
offset_secs: i64,
) -> Option<PosixNs> {
let dt = jiff::civil::DateTime::new(y, mo, d, h, mi, s, subsec_nanos).ok()?;
let off = jiff::tz::Offset::from_seconds(i32::try_from(offset_secs).ok()?).ok()?;
let zoned = dt.to_zoned(jiff::tz::TimeZone::fixed(off)).ok()?;
Some(PosixNs(zoned.timestamp().as_nanosecond()))
}
#[must_use]
pub fn parse_syslog_with_reference(dt: &str, reference: PosixNs) -> Option<PosixNs> {
let mut parts = dt.split_whitespace();
let mon = month_abbr(parts.next()?)?;
let day: i8 = parts.next()?.parse().ok()?;
let mut t = parts.next()?.split(':');
let (h, mi, s) = (
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
);
if t.next().is_some() || parts.next().is_some() {
return None;
}
let ref_year = jiff::Timestamp::from_nanosecond(reference.0)
.ok()?
.to_zoned(jiff::tz::TimeZone::UTC)
.year();
let candidate = civil_to_posix(ref_year, mon, day, h, mi, s, 0, 0)?;
if candidate.0 > reference.0 {
civil_to_posix(ref_year - 1, mon, day, h, mi, s, 0, 0)
} else {
Some(candidate)
}
}
fn frac_to_nanos(frac: &str) -> i32 {
let mut t: String = frac.chars().take(9).collect();
while t.len() < 9 {
t.push('0');
}
t.parse().unwrap_or(0)
}
fn month_abbr(m: &str) -> Option<i8> {
const MONTHS: [&str; 12] = [
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
];
let m = m.to_ascii_lowercase();
MONTHS
.iter()
.position(|x| *x == m)
.map(|i| i8::try_from(i + 1).unwrap_or(1))
}
fn numeric_offset_secs(tz: &str) -> Option<i64> {
let sign = match tz.as_bytes().first()? {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let digits = &tz[1..];
if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let h: i64 = digits.get(0..2)?.parse().ok()?;
let mi: i64 = digits.get(2..4)?.parse().ok()?;
Some(sign * (h * 3600 + mi * 60))
}
fn parse_clf(s: &str) -> Option<PosixNs> {
let s = s
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim();
let (date_time, tz) = s.rsplit_once(' ')?;
let (date, time) = date_time.split_once(':')?;
let mut d = date.split('/');
let day: i8 = d.next()?.parse().ok()?;
let mon = month_abbr(d.next()?)?;
let year: i16 = d.next()?.parse().ok()?;
if d.next().is_some() {
return None;
}
let mut t = time.split(':');
let (h, mi, sec) = (
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
);
if t.next().is_some() {
return None;
}
civil_to_posix(year, mon, day, h, mi, sec, 0, numeric_offset_secs(tz)?)
}
fn parse_pdf_date(s: &str) -> Option<PosixNs> {
let body = s.trim().strip_prefix("D:")?;
let digits: String = body.chars().take_while(char::is_ascii_digit).collect();
let year: i16 = digits.get(0..4)?.parse().ok()?;
let f = |r: std::ops::Range<usize>, dflt: i8| -> i8 {
digits.get(r).and_then(|x| x.parse().ok()).unwrap_or(dflt)
};
let (mo, d) = (f(4..6, 1), f(6..8, 1));
let (h, mi, sec) = (f(8..10, 0), f(10..12, 0), f(12..14, 0));
let rest = &body[digits.len()..];
let offset = if rest.is_empty() || rest.starts_with('Z') {
0
} else {
let cleaned: String = rest.chars().filter(|c| *c != '\'').take(5).collect();
numeric_offset_secs(&cleaned)?
};
civil_to_posix(year, mo, d, h, mi, sec, 0, offset)
}
fn parse_dmtf_cim(s: &str) -> Option<PosixNs> {
let s = s.trim();
let (main, tz) = s.split_once(['+', '-'])?;
let sign: i64 = if s.as_bytes()[main.len()] == b'-' {
-1
} else {
1
};
let (date, frac) = main.split_once('.')?;
if date.len() != 14 || !date.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if frac.len() != 6 || !frac.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let g = |r: std::ops::Range<usize>| -> Option<i64> { date.get(r)?.parse().ok() };
let year = i16::try_from(g(0..4)?).ok()?;
let offset = if tz == "***" {
0
} else {
if tz.len() != 3 || !tz.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
sign * tz.parse::<i64>().ok()? * 60
};
civil_to_posix(
year,
i8::try_from(g(4..6)?).ok()?,
i8::try_from(g(6..8)?).ok()?,
i8::try_from(g(8..10)?).ok()?,
i8::try_from(g(10..12)?).ok()?,
i8::try_from(g(12..14)?).ok()?,
frac_to_nanos(frac),
offset,
)
}
fn parse_asn1(s: &str, year_digits: usize) -> Option<(PosixNs, bool)> {
let (core, off, had_tz) = split_tz(s)?;
let (digits, frac) = match core.split_once(['.', ',']) {
Some((d, f)) => (d.to_string(), Some(f.to_string())),
None => (core, None),
};
if !digits.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
let year = if year_digits == 4 {
digits.get(0..4)?.parse().ok()?
} else {
let yy: i16 = digits.get(0..2)?.parse().ok()?;
if yy < 50 {
2000 + yy
} else {
1900 + yy
}
};
let base = year_digits;
let len = digits.len();
let mo = digits.get(base..base + 2)?.parse().ok()?;
let d = digits.get(base + 2..base + 4)?.parse().ok()?;
let h = digits.get(base + 4..base + 6)?.parse().ok()?;
let sec_present = len == base + 10;
let min_present = sec_present || len == base + 8;
if len != base + 6 && len != base + 8 && len != base + 10 {
return None;
}
let mi = if min_present {
digits.get(base + 6..base + 8)?.parse().ok()?
} else {
0
};
let s = if sec_present {
digits.get(base + 8..base + 10)?.parse().ok()?
} else {
0
};
let subsec = match frac {
Some(f) if sec_present && !f.is_empty() && f.bytes().all(|c| c.is_ascii_digit()) => {
frac_to_nanos(&f)
}
Some(_) => return None,
None => 0,
};
let instant = civil_to_posix(year, mo, d, h, mi, s, subsec, off)?;
Some((instant, had_tz))
}
fn parse_asn1_generalizedtime(s: &str) -> Option<(PosixNs, bool)> {
parse_asn1(s, 4)
}
fn parse_asn1_utctime(s: &str) -> Option<(PosixNs, bool)> {
parse_asn1(s, 2)
}
#[must_use]
pub fn explain(id: &str) -> Option<String> {
let f = crate::format(id).ok()?;
let epoch = f
.decode_int(0)
.ok()
.and_then(PosixNs::to_rfc3339)
.unwrap_or_else(|| "n/a (packed civil — no linear zero)".to_string());
let tick = match f.encoding {
Encoding::LinearInt { unit, .. }
| Encoding::LinearFloat { unit, .. }
| Encoding::Embedded { unit, .. } => format!("{} ns/tick ({unit:?})", unit.nanos()),
Encoding::Packed(_) => "packed calendar fields (no linear tick)".to_string(),
};
let render = |ns: i128| match PosixNs(ns).to_rfc3339() {
Some(s) => s,
None => format!("{ns} ns"),
};
let sentinels: Vec<String> = [0_i64, -1, i64::MAX]
.into_iter()
.filter_map(|v| sentinel_reason(v).map(|r| format!("{v} → {r}")))
.collect();
let sentinels = if sentinels.is_empty() {
"none".to_string()
} else {
sentinels.join("; ")
};
Some(format!(
"{id} — {label}\n \
family: {family}\n \
epoch: {epoch} (value 0)\n \
tick: {tick}\n \
timezone: {tz:?}\n \
leap: {leap:?}\n \
valid: {lo} .. {hi}\n \
sentinels: {sentinels}\n \
citation: {citation}",
label = f.label,
family = f.family,
tz = f.tz,
leap = f.leap,
lo = render(f.plausible.0),
hi = render(f.plausible.1),
citation = f.citation,
))
}