1use std::fmt::Write;
12use std::time::SystemTime;
13
14#[must_use]
21pub fn make_etag(data: &[u8]) -> String {
22 let hash = fnv1a(data);
23 format!("W/\"{hash:016x}\"")
24}
25
26fn fnv1a(data: &[u8]) -> u64 {
28 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
29 for &byte in data {
30 hash ^= u64::from(byte);
31 hash = hash.wrapping_mul(0x0100_0000_01b3);
32 }
33 hash
34}
35
36#[must_use]
40pub fn format_http_date(time: SystemTime) -> String {
41 let dur = time
42 .duration_since(SystemTime::UNIX_EPOCH)
43 .unwrap_or_default();
44 let secs = dur.as_secs();
45
46 let days = secs / 86400;
48 let day_secs = secs % 86400;
49 let hours = day_secs / 3600;
50 let minutes = (day_secs % 3600) / 60;
51 let seconds = day_secs % 60;
52
53 #[allow(clippy::cast_possible_wrap)]
55 let (year, month, day) = civil_from_days(days as i64);
56
57 let dow = ((days + 4) % 7) as usize;
59 let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
60 let month_names = [
61 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
62 ];
63
64 let mut buf = String::with_capacity(29);
65 let _ = write!(
66 buf,
67 "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
68 day_names[dow],
69 day,
70 month_names[month as usize - 1],
71 year,
72 hours,
73 minutes,
74 seconds
75 );
76 buf
77}
78
79#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
81fn civil_from_days(days: i64) -> (i64, u32, u32) {
82 let z = days + 719_468;
83 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
84 let doe = (z - era * 146_097) as u32;
85 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
86 let y = i64::from(yoe) + era * 400;
87 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
88 let mp = (5 * doy + 2) / 153;
89 let d = doy - (153 * mp + 2) / 5 + 1;
90 let m = if mp < 10 { mp + 3 } else { mp - 9 };
91 let y = if m <= 2 { y + 1 } else { y };
92 (y, m, d)
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum ConditionalResult {
100 NotModified,
102 SendFull,
104}
105
106#[must_use]
111pub fn check_conditional(
112 req: &hyper::Request<impl hyper::body::Body>,
113 current_etag: &str,
114 current_last_modified: &str,
115) -> ConditionalResult {
116 if let Some(inm) = req.headers().get("if-none-match")
118 && let Ok(inm_str) = inm.to_str()
119 {
120 if etag_matches(inm_str, current_etag) {
121 return ConditionalResult::NotModified;
122 }
123 return ConditionalResult::SendFull;
125 }
126
127 if let Some(ims) = req.headers().get("if-modified-since")
129 && let Ok(ims_str) = ims.to_str()
130 && ims_str == current_last_modified
131 {
132 return ConditionalResult::NotModified;
133 }
134
135 ConditionalResult::SendFull
136}
137
138fn etag_matches(header_value: &str, current: &str) -> bool {
144 let header_value = header_value.trim();
145 if header_value == "*" {
146 return true;
147 }
148 let current_bare = current.strip_prefix("W/").unwrap_or(current);
150
151 for candidate in header_value.split(',') {
152 let candidate = candidate.trim();
153 let candidate_bare = candidate.strip_prefix("W/").unwrap_or(candidate);
154 if candidate_bare == current_bare {
155 return true;
156 }
157 }
158 false
159}
160
161#[derive(Debug, Clone, Copy)]
170#[non_exhaustive]
171pub struct CacheConfig {
172 pub max_age: u32,
174}
175
176impl CacheConfig {
177 #[must_use]
179 pub const fn with_max_age(max_age: u32) -> Self {
180 Self { max_age }
181 }
182
183 #[must_use]
185 pub fn header_value(&self) -> String {
186 format!("public, max-age={}", self.max_age)
187 }
188}
189
190impl Default for CacheConfig {
191 fn default() -> Self {
192 Self { max_age: 3600 }
194 }
195}
196
197#[cfg(test)]
200pub(crate) mod tests {
201 use super::*;
202 use a2a_protocol_types::agent_card::{
203 AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
204 };
205 use bytes::Bytes;
206 use http_body_util::Full;
207
208 pub fn minimal_agent_card() -> AgentCard {
210 AgentCard {
211 url: None,
212 name: "Test Agent".into(),
213 description: "A test agent".into(),
214 version: "1.0.0".into(),
215 supported_interfaces: vec![AgentInterface {
216 url: "https://agent.example.com/rpc".into(),
217 protocol_binding: "JSONRPC".into(),
218 protocol_version: "1.0.0".into(),
219 tenant: None,
220 }],
221 default_input_modes: vec!["text/plain".into()],
222 default_output_modes: vec!["text/plain".into()],
223 skills: vec![AgentSkill {
224 id: "echo".into(),
225 name: "Echo".into(),
226 description: "Echoes input".into(),
227 tags: vec!["echo".into()],
228 examples: None,
229 input_modes: None,
230 output_modes: None,
231 security_requirements: None,
232 }],
233 capabilities: AgentCapabilities::none(),
234 provider: None,
235 icon_url: None,
236 documentation_url: None,
237 security_schemes: None,
238 security_requirements: None,
239 signatures: None,
240 }
241 }
242
243 #[test]
244 fn make_etag_deterministic() {
245 let data = b"hello world";
246 let etag1 = make_etag(data);
247 let etag2 = make_etag(data);
248 assert_eq!(etag1, etag2);
249 assert!(etag1.starts_with("W/\""));
250 assert!(etag1.ends_with('"'));
251 }
252
253 #[test]
254 fn make_etag_different_for_different_data() {
255 let etag1 = make_etag(b"hello");
256 let etag2 = make_etag(b"world");
257 assert_ne!(etag1, etag2);
258 }
259
260 #[test]
261 fn format_http_date_epoch() {
262 let epoch = SystemTime::UNIX_EPOCH;
263 let date = format_http_date(epoch);
264 assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
265 }
266
267 #[test]
268 fn etag_matches_exact() {
269 assert!(etag_matches("W/\"abc\"", "W/\"abc\""));
270 }
271
272 #[test]
273 fn etag_matches_wildcard() {
274 assert!(etag_matches("*", "W/\"abc\""));
275 }
276
277 #[test]
278 fn etag_matches_comma_list() {
279 assert!(etag_matches("W/\"aaa\", W/\"bbb\", W/\"ccc\"", "W/\"bbb\""));
280 }
281
282 #[test]
283 fn etag_no_match() {
284 assert!(!etag_matches("W/\"xxx\"", "W/\"yyy\""));
285 }
286
287 #[test]
288 fn check_conditional_if_none_match_hit() {
289 let req = hyper::Request::builder()
290 .header("if-none-match", "W/\"abc\"")
291 .body(Full::new(Bytes::new()))
292 .unwrap();
293 assert_eq!(
294 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
295 ConditionalResult::NotModified,
296 );
297 }
298
299 #[test]
300 fn check_conditional_if_none_match_miss() {
301 let req = hyper::Request::builder()
302 .header("if-none-match", "W/\"xyz\"")
303 .body(Full::new(Bytes::new()))
304 .unwrap();
305 assert_eq!(
306 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
307 ConditionalResult::SendFull,
308 );
309 }
310
311 #[test]
312 fn check_conditional_if_modified_since_match() {
313 let lm = "Thu, 01 Jan 2026 00:00:00 GMT";
314 let req = hyper::Request::builder()
315 .header("if-modified-since", lm)
316 .body(Full::new(Bytes::new()))
317 .unwrap();
318 assert_eq!(
319 check_conditional(&req, "W/\"abc\"", lm),
320 ConditionalResult::NotModified,
321 );
322 }
323
324 #[test]
325 fn check_conditional_no_headers() {
326 let req = hyper::Request::builder()
327 .body(Full::new(Bytes::new()))
328 .unwrap();
329 assert_eq!(
330 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
331 ConditionalResult::SendFull,
332 );
333 }
334
335 #[test]
337 fn check_conditional_if_modified_since_miss() {
338 let req = hyper::Request::builder()
339 .header("if-modified-since", "Mon, 01 Jan 2024 00:00:00 GMT")
340 .body(Full::new(Bytes::new()))
341 .unwrap();
342 assert_eq!(
343 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
344 ConditionalResult::SendFull,
345 "non-matching If-Modified-Since should return SendFull"
346 );
347 }
348
349 #[test]
351 fn check_conditional_if_none_match_non_utf8_falls_through() {
352 let lm = "Thu, 01 Jan 2026 00:00:00 GMT";
356 let req = hyper::Request::builder()
357 .header("if-none-match", "W/\"wrong\"")
358 .header("if-modified-since", lm)
359 .body(Full::new(Bytes::new()))
360 .unwrap();
361 assert_eq!(
364 check_conditional(&req, "W/\"abc\"", lm),
365 ConditionalResult::SendFull,
366 "If-None-Match miss should skip If-Modified-Since per RFC 7232"
367 );
368 }
369
370 #[test]
371 fn cache_config_default() {
372 let c = CacheConfig::default();
373 assert_eq!(c.header_value(), "public, max-age=3600");
374 }
375
376 #[test]
377 fn cache_config_custom() {
378 let c = CacheConfig::with_max_age(600);
379 assert_eq!(c.header_value(), "public, max-age=600");
380 }
381
382 #[test]
385 fn fnv1a_known_vectors() {
386 assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
388
389 let expected = (0xcbf2_9ce4_8422_2325_u64 ^ 0x61).wrapping_mul(0x0100_0000_01b3);
391 assert_eq!(fnv1a(b"a"), expected);
392 }
393
394 #[test]
395 fn fnv1a_xor_not_or() {
396 let h_ab = fnv1a(b"ab");
399 let h_ba = fnv1a(b"ba");
400 assert_ne!(h_ab, h_ba, "fnv1a should be order-sensitive (XOR step)");
401 }
402
403 #[test]
418 fn format_http_date_calendar_boundaries() {
419 let cases: &[(u64, &str)] = &[
421 (951_825_600, "Tue, 29 Feb 2000 12:00:00 GMT"), (4_107_542_399, "Sun, 28 Feb 2100 23:59:59 GMT"), (4_107_542_400, "Mon, 01 Mar 2100 00:00:00 GMT"), (13_574_563_200, "Tue, 29 Feb 2400 00:00:00 GMT"), (68_193_000, "Tue, 29 Feb 1972 06:30:00 GMT"), (2_147_483_647, "Tue, 19 Jan 2038 03:14:07 GMT"), (1_735_689_599, "Tue, 31 Dec 2024 23:59:59 GMT"), (946_684_799, "Fri, 31 Dec 1999 23:59:59 GMT"), ];
430
431 for (secs, expected) in cases {
432 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(*secs);
433 assert_eq!(
434 format_http_date(time),
435 *expected,
436 "wrong civil date for {secs} seconds since the epoch"
437 );
438 }
439 }
440
441 #[test]
473 fn civil_from_days_handles_pre_gregorian_epoch_days() {
474 assert_eq!(civil_from_days(-719_468), (0, 3, 1));
476
477 assert_eq!(civil_from_days(-719_469), (0, 2, 29));
480
481 assert_eq!(civil_from_days(-719_530), (-1, 12, 30));
487 assert_eq!(civil_from_days(-1_000_000), (-768, 2, 4));
488 }
489
490 #[test]
491 fn format_http_date_known_timestamp() {
492 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_750_000_245);
495 let date = format_http_date(time);
496 assert_eq!(date, "Sun, 15 Jun 2025 15:10:45 GMT");
497 }
498
499 #[test]
500 fn format_http_date_end_of_day() {
501 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86399);
503 let date = format_http_date(time);
504 assert_eq!(date, "Thu, 01 Jan 1970 23:59:59 GMT");
505 }
506
507 #[test]
508 fn format_http_date_next_day() {
509 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86400);
511 let date = format_http_date(time);
512 assert_eq!(date, "Fri, 02 Jan 1970 00:00:00 GMT");
513 }
514
515 #[test]
516 fn format_http_date_midday() {
517 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(45015);
519 let date = format_http_date(time);
520 assert_eq!(date, "Thu, 01 Jan 1970 12:30:15 GMT");
521 }
522}