Skip to main content

contextgraph_types/
validate.rs

1//! Format validators for fields the protocol declares but could not previously
2//! check (`SPEC.md` §F4, §F5; issues #10 and #12).
3//!
4//! Two guarantees were unfalsifiable before this module existed:
5//!
6//! - **Bi-temporal retrieval.** `valid_from` / `valid_to` / `recorded_at` /
7//!   `as_of` were free-form strings with no format rule anywhere, so a provider
8//!   emitting `"valid_from": "last tuesday"` was fully conformant. A guarantee
9//!   nothing can falsify is not a guarantee.
10//! - **Provenance integrity.** `Provenance.digest` was documented as the thing
11//!   that lets a host detect tampering "before the frame enters a prompt", but
12//!   no grammar said which algorithms were valid, what case the hex was in, or
13//!   which bytes were digested. Two independent providers would disagree and
14//!   both would be "conformant".
15//!
16//! These validators are deliberately dependency-free — no `chrono`, no `regex`.
17//! `contextgraph-types` is the crate every provider in every language ports
18//! from, and each dependency it carries is one more thing an implementer has to
19//! reproduce or justify.
20
21/// Whether `s` is a timestamp in the protocol's temporal profile.
22///
23/// The profile is a **strict subset** of RFC 3339: `YYYY-MM-DDTHH:MM:SS(.f+)?Z`
24/// — uppercase `T`, uppercase `Z`, UTC only.
25///
26/// Naming it a subset is deliberate honesty. RFC 3339 also permits a lowercase
27/// `t`, a space separator, and numeric offsets like `+02:00`. Allowing those
28/// would mean every implementation needs offset arithmetic just to compare two
29/// timestamps, and two frames with the same instant would compare unequal as
30/// strings — which quietly breaks the dedup and cache-key properties other
31/// parts of the protocol depend on. One spelling per instant is worth more than
32/// full generality here.
33///
34/// ```
35/// use contextgraph_types::is_protocol_timestamp;
36///
37/// assert!(is_protocol_timestamp("2026-07-20T18:00:00Z"));
38/// assert!(is_protocol_timestamp("2026-07-20T18:00:00.123Z"));
39/// assert!(!is_protocol_timestamp("2026-07-20T18:00:00+02:00")); // UTC only
40/// assert!(!is_protocol_timestamp("last tuesday"));
41/// ```
42pub fn is_protocol_timestamp(s: &str) -> bool {
43    let b = s.as_bytes();
44    // Shortest legal form is "YYYY-MM-DDTHH:MM:SSZ" = 20 bytes.
45    if b.len() < 20 {
46        return false;
47    }
48    if b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' {
49        return false;
50    }
51    if !b[..4].iter().all(u8::is_ascii_digit) {
52        return false;
53    }
54    let Some(month) = two_digits(&b[5..7]) else {
55        return false;
56    };
57    let Some(day) = two_digits(&b[8..10]) else {
58        return false;
59    };
60    let Some(hour) = two_digits(&b[11..13]) else {
61        return false;
62    };
63    let Some(minute) = two_digits(&b[14..16]) else {
64        return false;
65    };
66    let Some(second) = two_digits(&b[17..19]) else {
67        return false;
68    };
69
70    let year: u32 = s[..4].parse().unwrap_or(0);
71    if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
72        return false;
73    }
74    // RFC 3339 permits second 60 to represent a leap second.
75    if hour > 23 || minute > 59 || second > 60 {
76        return false;
77    }
78
79    match &b[19..] {
80        // No fractional part.
81        [b'Z'] => true,
82        // Fractional seconds: at least one digit, then Z.
83        [b'.', rest @ ..] => {
84            let Some((last, digits)) = rest.split_last() else {
85                return false;
86            };
87            *last == b'Z' && !digits.is_empty() && digits.iter().all(u8::is_ascii_digit)
88        }
89        _ => false,
90    }
91}
92
93/// Render a Unix instant (seconds since the epoch, UTC) as a protocol
94/// timestamp: the exact spelling [`is_protocol_timestamp`] accepts.
95///
96/// This exists so a host can *stamp* a temporal field without taking on a date
97/// library. `contextgraph-types` deliberately carries no dependency beyond
98/// serde (see the module docs), and every implementer in every language has to
99/// reproduce whatever this crate does — so "pull in chrono" is a cost paid by
100/// the whole ecosystem, for arithmetic that fits in twenty lines.
101///
102/// The gap it closes is concrete: the host recorded every consent decision with
103/// `granted_at: None`, because it had no way to spell the current instant. An
104/// audit ledger that never records *when* is missing the field that makes it an
105/// audit ledger.
106///
107/// Leap seconds are not representable — a Unix timestamp cannot express one —
108/// so this never emits `:60`, though the validator accepts it from a peer.
109///
110/// ```
111/// use contextgraph_types::{format_protocol_timestamp, is_protocol_timestamp};
112///
113/// assert_eq!(format_protocol_timestamp(0), "1970-01-01T00:00:00Z");
114/// assert_eq!(format_protocol_timestamp(1_784_000_000), "2026-07-14T03:33:20Z");
115/// assert!(is_protocol_timestamp(&format_protocol_timestamp(1_784_000_000)));
116/// ```
117pub fn format_protocol_timestamp(unix_seconds: i64) -> String {
118    // `div_euclid`/`rem_euclid` rather than `/` and `%`: for instants before
119    // the epoch the truncating operators would round the day *up* and yield a
120    // negative time-of-day.
121    let days = unix_seconds.div_euclid(SECONDS_PER_DAY);
122    let second_of_day = unix_seconds.rem_euclid(SECONDS_PER_DAY);
123
124    let (year, month, day) = civil_from_days(days);
125    let hour = second_of_day / 3_600;
126    let minute = (second_of_day % 3_600) / 60;
127    let second = second_of_day % 60;
128
129    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
130}
131
132const SECONDS_PER_DAY: i64 = 86_400;
133
134/// Civil (proleptic Gregorian) date from a day count relative to 1970-01-01.
135///
136/// Howard Hinnant's `civil_from_days`, the standard formulation. It shifts the
137/// epoch to 0000-03-01 so the leap day lands at the *end* of the year, which is
138/// what lets the era arithmetic below avoid special-casing February.
139fn civil_from_days(days: i64) -> (i64, i64, i64) {
140    // 719_468 = days from 0000-03-01 to 1970-01-01.
141    let z = days + 719_468;
142    // A 400-year era is exactly 146_097 days — the Gregorian cycle.
143    let era = z.div_euclid(146_097);
144    let day_of_era = z.rem_euclid(146_097); // [0, 146096]
145    let year_of_era =
146        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; // [0, 399]
147    let year = year_of_era + era * 400;
148    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); // [0, 365]
149    // Month index in the March-based year.
150    let month_prime = (5 * day_of_year + 2) / 153; // [0, 11]
151    let day = day_of_year - (153 * month_prime + 2) / 5 + 1; // [1, 31]
152    let month = if month_prime < 10 {
153        month_prime + 3
154    } else {
155        month_prime - 9
156    }; // [1, 12]
157    // January and February belong to the following calendar year.
158    let year = if month <= 2 { year + 1 } else { year };
159    (year, month, day)
160}
161
162fn two_digits(pair: &[u8]) -> Option<u32> {
163    if pair.len() == 2 && pair.iter().all(u8::is_ascii_digit) {
164        Some((pair[0] - b'0') as u32 * 10 + (pair[1] - b'0') as u32)
165    } else {
166        None
167    }
168}
169
170fn days_in_month(year: u32, month: u32) -> u32 {
171    match month {
172        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
173        4 | 6 | 9 | 11 => 30,
174        2 if is_leap_year(year) => 29,
175        2 => 28,
176        _ => 0,
177    }
178}
179
180fn is_leap_year(year: u32) -> bool {
181    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
182}
183
184/// The digest algorithms this protocol revision defines.
185///
186/// The prefix is part of the grammar precisely so a future algorithm is an
187/// additive change rather than an ambiguous reinterpretation of existing
188/// digests.
189pub const DIGEST_ALGORITHMS: &[&str] = &["sha256"];
190
191/// Whether `s` is a well-formed content digest: `<algorithm>:<lowercase hex>`,
192/// e.g. `sha256:` followed by 64 lowercase hex characters (`SPEC.md` §F5).
193///
194/// Lowercase is mandated rather than merely conventional: a digest is compared
195/// byte-for-byte, and two implementations disagreeing on hex case would produce
196/// spurious mismatches that look exactly like tampering.
197///
198/// This checks the *grammar* only. Whether the digest matches the bytes it
199/// claims to cover is a separate, host-side question — see
200/// `contextgraph_host::verify`.
201///
202/// ```
203/// use contextgraph_types::is_well_formed_digest;
204///
205/// let ok = format!("sha256:{}", "a".repeat(64));
206/// assert!(is_well_formed_digest(&ok));
207/// assert!(!is_well_formed_digest("sha256:abc"));           // wrong length
208/// assert!(!is_well_formed_digest(&ok.to_uppercase()));     // must be lowercase
209/// ```
210pub fn is_well_formed_digest(s: &str) -> bool {
211    let Some((algorithm, hex)) = s.split_once(':') else {
212        return false;
213    };
214    let expected_hex_len = match algorithm {
215        "sha256" => 64,
216        _ => return false,
217    };
218    hex.len() == expected_hex_len
219        && hex
220            .bytes()
221            .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn digest(hex: &str) -> String {
229        format!("sha256:{hex}")
230    }
231
232    #[test]
233    fn formats_known_instants() {
234        assert_eq!(format_protocol_timestamp(0), "1970-01-01T00:00:00Z");
235        assert_eq!(format_protocol_timestamp(1), "1970-01-01T00:00:01Z");
236        assert_eq!(format_protocol_timestamp(86_399), "1970-01-01T23:59:59Z");
237        assert_eq!(format_protocol_timestamp(86_400), "1970-01-02T00:00:00Z");
238        assert_eq!(
239            format_protocol_timestamp(1_784_000_000),
240            "2026-07-14T03:33:20Z"
241        );
242    }
243
244    #[test]
245    fn formats_leap_days_and_century_rules() {
246        // 2000 is a leap year (divisible by 400); 1900 was not (divisible by
247        // 100 but not 400). The era arithmetic has to get both right.
248        assert_eq!(
249            format_protocol_timestamp(951_782_400),
250            "2000-02-29T00:00:00Z"
251        );
252        assert_eq!(
253            format_protocol_timestamp(-2_203_891_200),
254            "1900-03-01T00:00:00Z"
255        );
256        assert_eq!(
257            format_protocol_timestamp(1_709_164_800),
258            "2024-02-29T00:00:00Z"
259        );
260    }
261
262    #[test]
263    fn formats_instants_before_the_epoch_without_rounding_the_day_up() {
264        // The trap `div_euclid` avoids: truncating division would place this
265        // one second *before* the epoch on 1970-01-01 with a negative clock.
266        assert_eq!(format_protocol_timestamp(-1), "1969-12-31T23:59:59Z");
267        assert_eq!(format_protocol_timestamp(-86_400), "1969-12-31T00:00:00Z");
268    }
269
270    /// The formatter and the validator must agree — a host that stamps a field
271    /// with the one and is checked by the other cannot be allowed to disagree.
272    #[test]
273    fn everything_it_formats_is_a_valid_protocol_timestamp() {
274        // A wide spread: pre-epoch, epoch, leap days, far future, and a stride
275        // that lands on assorted times of day.
276        let mut instants = vec![-2_203_891_200, -86_401, -1, 0, 951_782_400, 1_784_000_000];
277        let mut t = -62_135_596_800; // 0001-01-01T00:00:00Z
278        while t < 4_102_444_800 {
279            // through 2100
280            instants.push(t);
281            t += 999_999_937; // a prime-ish stride, so it doesn't align to days
282        }
283        for instant in instants {
284            let formatted = format_protocol_timestamp(instant);
285            assert!(
286                is_protocol_timestamp(&formatted),
287                "format_protocol_timestamp({instant}) produced `{formatted}`, which the validator rejects"
288            );
289        }
290    }
291
292    #[test]
293    fn accepts_the_canonical_timestamp_spelling() {
294        assert!(is_protocol_timestamp("2026-07-20T18:00:00Z"));
295        assert!(is_protocol_timestamp("1970-01-01T00:00:00Z"));
296        assert!(is_protocol_timestamp("2026-12-31T23:59:59Z"));
297    }
298
299    #[test]
300    fn accepts_fractional_seconds_of_any_precision() {
301        assert!(is_protocol_timestamp("2026-07-20T18:00:00.1Z"));
302        assert!(is_protocol_timestamp("2026-07-20T18:00:00.123Z"));
303        assert!(is_protocol_timestamp("2026-07-20T18:00:00.123456789Z"));
304    }
305
306    #[test]
307    fn rejects_prose_which_is_the_bug_this_check_exists_for() {
308        // The literal example from issue #10: this was fully conformant before.
309        assert!(!is_protocol_timestamp("last tuesday"));
310        assert!(!is_protocol_timestamp(""));
311        assert!(!is_protocol_timestamp("2026-07-20"));
312    }
313
314    #[test]
315    fn rejects_non_utc_spellings_of_a_valid_instant() {
316        // All of these are legal RFC 3339; none are in the protocol profile.
317        assert!(!is_protocol_timestamp("2026-07-20T18:00:00+02:00"));
318        assert!(!is_protocol_timestamp("2026-07-20T18:00:00-05:00"));
319        assert!(!is_protocol_timestamp("2026-07-20t18:00:00Z"));
320        assert!(!is_protocol_timestamp("2026-07-20 18:00:00Z"));
321        assert!(!is_protocol_timestamp("2026-07-20T18:00:00z"));
322    }
323
324    #[test]
325    fn rejects_out_of_range_components() {
326        assert!(!is_protocol_timestamp("2026-13-01T00:00:00Z")); // month 13
327        assert!(!is_protocol_timestamp("2026-00-01T00:00:00Z")); // month 0
328        assert!(!is_protocol_timestamp("2026-07-32T00:00:00Z")); // day 32
329        assert!(!is_protocol_timestamp("2026-07-00T00:00:00Z")); // day 0
330        assert!(!is_protocol_timestamp("2026-07-20T24:00:00Z")); // hour 24
331        assert!(!is_protocol_timestamp("2026-07-20T00:60:00Z")); // minute 60
332    }
333
334    #[test]
335    fn honors_month_lengths_and_leap_years() {
336        assert!(is_protocol_timestamp("2026-01-31T00:00:00Z"));
337        assert!(!is_protocol_timestamp("2026-04-31T00:00:00Z")); // April has 30
338
339        assert!(!is_protocol_timestamp("2026-02-29T00:00:00Z")); // 2026 is not a leap year
340        assert!(is_protocol_timestamp("2024-02-29T00:00:00Z")); // 2024 is
341        assert!(is_protocol_timestamp("2000-02-29T00:00:00Z")); // 400-divisible
342        assert!(!is_protocol_timestamp("1900-02-29T00:00:00Z")); // 100-divisible, not 400
343    }
344
345    #[test]
346    fn accepts_a_leap_second() {
347        // RFC 3339 permits :60 for a leap second; rejecting it would make the
348        // protocol reject legitimate timestamps from correct clocks.
349        assert!(is_protocol_timestamp("2016-12-31T23:59:60Z"));
350        assert!(!is_protocol_timestamp("2016-12-31T23:59:61Z"));
351    }
352
353    #[test]
354    fn rejects_a_malformed_fractional_part() {
355        assert!(!is_protocol_timestamp("2026-07-20T18:00:00.Z")); // no digits
356        assert!(!is_protocol_timestamp("2026-07-20T18:00:00.12")); // no Z
357        assert!(!is_protocol_timestamp("2026-07-20T18:00:00.1a2Z")); // non-digit
358    }
359
360    #[test]
361    fn accepts_a_well_formed_sha256_digest() {
362        assert!(is_well_formed_digest(&digest(&"a".repeat(64))));
363        assert!(is_well_formed_digest(&digest(
364            &"0123456789abcdef".repeat(4)
365        )));
366    }
367
368    #[test]
369    fn rejects_the_placeholder_digest_the_repo_used_in_examples() {
370        // `sha256:abc` appears throughout the pre-spec fixtures. It is not a
371        // digest, and now it does not pass for one.
372        assert!(!is_well_formed_digest("sha256:abc"));
373    }
374
375    #[test]
376    fn rejects_uppercase_hex_so_comparison_never_yields_a_false_mismatch() {
377        let upper = format!("sha256:{}", "A".repeat(64));
378        assert!(!is_well_formed_digest(&upper));
379    }
380
381    #[test]
382    fn rejects_a_missing_or_unknown_algorithm_prefix() {
383        assert!(!is_well_formed_digest(&"a".repeat(64))); // bare hex, no prefix
384        assert!(!is_well_formed_digest(&format!("md5:{}", "a".repeat(32))));
385        assert!(!is_well_formed_digest(&format!(
386            "sha512:{}",
387            "a".repeat(64)
388        )));
389    }
390
391    #[test]
392    fn rejects_non_hex_characters_of_the_right_length() {
393        assert!(!is_well_formed_digest(&digest(&"g".repeat(64))));
394    }
395
396    #[test]
397    fn the_declared_algorithm_list_matches_what_the_validator_accepts() {
398        // Keeps the public constant honest if a future algorithm is added to
399        // one place and not the other.
400        for algorithm in DIGEST_ALGORITHMS {
401            let candidate = format!("{algorithm}:{}", "a".repeat(64));
402            assert!(
403                is_well_formed_digest(&candidate),
404                "{algorithm} is advertised but not accepted"
405            );
406        }
407    }
408}