a2a_protocol_server/agent_card/
caching.rs1use 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 if let Ok(inm_str) = inm.to_str() {
119 if etag_matches(inm_str, current_etag) {
120 return ConditionalResult::NotModified;
121 }
122 return ConditionalResult::SendFull;
124 }
125 }
126
127 if let Some(ims) = req.headers().get("if-modified-since") {
129 if let Ok(ims_str) = ims.to_str() {
130 if ims_str == current_last_modified {
131 return ConditionalResult::NotModified;
132 }
133 }
134 }
135
136 ConditionalResult::SendFull
137}
138
139fn etag_matches(header_value: &str, current: &str) -> bool {
145 let header_value = header_value.trim();
146 if header_value == "*" {
147 return true;
148 }
149 let current_bare = current.strip_prefix("W/").unwrap_or(current);
151
152 for candidate in header_value.split(',') {
153 let candidate = candidate.trim();
154 let candidate_bare = candidate.strip_prefix("W/").unwrap_or(candidate);
155 if candidate_bare == current_bare {
156 return true;
157 }
158 }
159 false
160}
161
162#[derive(Debug, Clone, Copy)]
166pub struct CacheConfig {
167 pub max_age: u32,
169}
170
171impl CacheConfig {
172 #[must_use]
174 pub const fn with_max_age(max_age: u32) -> Self {
175 Self { max_age }
176 }
177
178 #[must_use]
180 pub fn header_value(&self) -> String {
181 format!("public, max-age={}", self.max_age)
182 }
183}
184
185impl Default for CacheConfig {
186 fn default() -> Self {
187 Self { max_age: 3600 }
189 }
190}
191
192#[cfg(test)]
195pub(crate) mod tests {
196 use super::*;
197 use a2a_protocol_types::agent_card::{
198 AgentCapabilities, AgentCard, AgentInterface, AgentSkill,
199 };
200 use bytes::Bytes;
201 use http_body_util::Full;
202
203 pub fn minimal_agent_card() -> AgentCard {
205 AgentCard {
206 url: None,
207 name: "Test Agent".into(),
208 description: "A test agent".into(),
209 version: "1.0.0".into(),
210 supported_interfaces: vec![AgentInterface {
211 url: "https://agent.example.com/rpc".into(),
212 protocol_binding: "JSONRPC".into(),
213 protocol_version: "1.0.0".into(),
214 tenant: None,
215 }],
216 default_input_modes: vec!["text/plain".into()],
217 default_output_modes: vec!["text/plain".into()],
218 skills: vec![AgentSkill {
219 id: "echo".into(),
220 name: "Echo".into(),
221 description: "Echoes input".into(),
222 tags: vec!["echo".into()],
223 examples: None,
224 input_modes: None,
225 output_modes: None,
226 security_requirements: None,
227 }],
228 capabilities: AgentCapabilities::none(),
229 provider: None,
230 icon_url: None,
231 documentation_url: None,
232 security_schemes: None,
233 security_requirements: None,
234 signatures: None,
235 }
236 }
237
238 #[test]
239 fn make_etag_deterministic() {
240 let data = b"hello world";
241 let etag1 = make_etag(data);
242 let etag2 = make_etag(data);
243 assert_eq!(etag1, etag2);
244 assert!(etag1.starts_with("W/\""));
245 assert!(etag1.ends_with('"'));
246 }
247
248 #[test]
249 fn make_etag_different_for_different_data() {
250 let etag1 = make_etag(b"hello");
251 let etag2 = make_etag(b"world");
252 assert_ne!(etag1, etag2);
253 }
254
255 #[test]
256 fn format_http_date_epoch() {
257 let epoch = SystemTime::UNIX_EPOCH;
258 let date = format_http_date(epoch);
259 assert_eq!(date, "Thu, 01 Jan 1970 00:00:00 GMT");
260 }
261
262 #[test]
263 fn etag_matches_exact() {
264 assert!(etag_matches("W/\"abc\"", "W/\"abc\""));
265 }
266
267 #[test]
268 fn etag_matches_wildcard() {
269 assert!(etag_matches("*", "W/\"abc\""));
270 }
271
272 #[test]
273 fn etag_matches_comma_list() {
274 assert!(etag_matches("W/\"aaa\", W/\"bbb\", W/\"ccc\"", "W/\"bbb\""));
275 }
276
277 #[test]
278 fn etag_no_match() {
279 assert!(!etag_matches("W/\"xxx\"", "W/\"yyy\""));
280 }
281
282 #[test]
283 fn check_conditional_if_none_match_hit() {
284 let req = hyper::Request::builder()
285 .header("if-none-match", "W/\"abc\"")
286 .body(Full::new(Bytes::new()))
287 .unwrap();
288 assert_eq!(
289 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
290 ConditionalResult::NotModified,
291 );
292 }
293
294 #[test]
295 fn check_conditional_if_none_match_miss() {
296 let req = hyper::Request::builder()
297 .header("if-none-match", "W/\"xyz\"")
298 .body(Full::new(Bytes::new()))
299 .unwrap();
300 assert_eq!(
301 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
302 ConditionalResult::SendFull,
303 );
304 }
305
306 #[test]
307 fn check_conditional_if_modified_since_match() {
308 let lm = "Thu, 01 Jan 2026 00:00:00 GMT";
309 let req = hyper::Request::builder()
310 .header("if-modified-since", lm)
311 .body(Full::new(Bytes::new()))
312 .unwrap();
313 assert_eq!(
314 check_conditional(&req, "W/\"abc\"", lm),
315 ConditionalResult::NotModified,
316 );
317 }
318
319 #[test]
320 fn check_conditional_no_headers() {
321 let req = hyper::Request::builder()
322 .body(Full::new(Bytes::new()))
323 .unwrap();
324 assert_eq!(
325 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
326 ConditionalResult::SendFull,
327 );
328 }
329
330 #[test]
332 fn check_conditional_if_modified_since_miss() {
333 let req = hyper::Request::builder()
334 .header("if-modified-since", "Mon, 01 Jan 2024 00:00:00 GMT")
335 .body(Full::new(Bytes::new()))
336 .unwrap();
337 assert_eq!(
338 check_conditional(&req, "W/\"abc\"", "Thu, 01 Jan 2026 00:00:00 GMT"),
339 ConditionalResult::SendFull,
340 "non-matching If-Modified-Since should return SendFull"
341 );
342 }
343
344 #[test]
346 fn check_conditional_if_none_match_non_utf8_falls_through() {
347 let lm = "Thu, 01 Jan 2026 00:00:00 GMT";
351 let req = hyper::Request::builder()
352 .header("if-none-match", "W/\"wrong\"")
353 .header("if-modified-since", lm)
354 .body(Full::new(Bytes::new()))
355 .unwrap();
356 assert_eq!(
359 check_conditional(&req, "W/\"abc\"", lm),
360 ConditionalResult::SendFull,
361 "If-None-Match miss should skip If-Modified-Since per RFC 7232"
362 );
363 }
364
365 #[test]
366 fn cache_config_default() {
367 let c = CacheConfig::default();
368 assert_eq!(c.header_value(), "public, max-age=3600");
369 }
370
371 #[test]
372 fn cache_config_custom() {
373 let c = CacheConfig::with_max_age(600);
374 assert_eq!(c.header_value(), "public, max-age=600");
375 }
376
377 #[test]
380 fn fnv1a_known_vectors() {
381 assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
383
384 let expected = (0xcbf2_9ce4_8422_2325_u64 ^ 0x61).wrapping_mul(0x0100_0000_01b3);
386 assert_eq!(fnv1a(b"a"), expected);
387 }
388
389 #[test]
390 fn fnv1a_xor_not_or() {
391 let h_ab = fnv1a(b"ab");
394 let h_ba = fnv1a(b"ba");
395 assert_ne!(h_ab, h_ba, "fnv1a should be order-sensitive (XOR step)");
396 }
397
398 #[test]
401 fn format_http_date_known_timestamp() {
402 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_750_000_245);
405 let date = format_http_date(time);
406 assert_eq!(date, "Sun, 15 Jun 2025 15:10:45 GMT");
407 }
408
409 #[test]
410 fn format_http_date_end_of_day() {
411 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86399);
413 let date = format_http_date(time);
414 assert_eq!(date, "Thu, 01 Jan 1970 23:59:59 GMT");
415 }
416
417 #[test]
418 fn format_http_date_next_day() {
419 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(86400);
421 let date = format_http_date(time);
422 assert_eq!(date, "Fri, 02 Jan 1970 00:00:00 GMT");
423 }
424
425 #[test]
426 fn format_http_date_midday() {
427 let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(45015);
429 let date = format_http_date(time);
430 assert_eq!(date, "Thu, 01 Jan 1970 12:30:15 GMT");
431 }
432}