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}