1#![no_std]
42#![forbid(unsafe_code)]
43#![doc(html_root_url = "https://docs.rs/http-fresh/0.1.0")]
44
45#[cfg(doctest)]
47#[doc = include_str!("../README.md")]
48struct ReadmeDoctests;
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct Request<'a> {
65 pub if_none_match: Option<&'a str>,
67 pub if_modified_since: Option<&'a str>,
69 pub cache_control: Option<&'a str>,
71}
72
73impl<'a> Request<'a> {
74 #[must_use]
76 pub fn new() -> Self {
77 Self::default()
78 }
79
80 #[must_use]
82 pub fn if_none_match(mut self, value: &'a str) -> Self {
83 self.if_none_match = Some(value);
84 self
85 }
86
87 #[must_use]
89 pub fn if_modified_since(mut self, value: &'a str) -> Self {
90 self.if_modified_since = Some(value);
91 self
92 }
93
94 #[must_use]
96 pub fn cache_control(mut self, value: &'a str) -> Self {
97 self.cache_control = Some(value);
98 self
99 }
100}
101
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
107pub struct Response<'a> {
108 pub etag: Option<&'a str>,
110 pub last_modified: Option<&'a str>,
112}
113
114impl<'a> Response<'a> {
115 #[must_use]
117 pub fn new() -> Self {
118 Self::default()
119 }
120
121 #[must_use]
123 pub fn etag(mut self, value: &'a str) -> Self {
124 self.etag = Some(value);
125 self
126 }
127
128 #[must_use]
130 pub fn last_modified(mut self, value: &'a str) -> Self {
131 self.last_modified = Some(value);
132 self
133 }
134}
135
136#[must_use]
159pub fn fresh(req: &Request<'_>, res: &Response<'_>) -> bool {
160 let modified_since = present(req.if_modified_since);
161 let none_match = present(req.if_none_match);
162
163 if modified_since.is_none() && none_match.is_none() {
165 return false;
166 }
167
168 if let Some(cc) = present(req.cache_control) {
170 if has_no_cache(cc) {
171 return false;
172 }
173 }
174
175 if let Some(none_match) = none_match {
177 if none_match != "*" {
178 let Some(etag) = present(res.etag) else {
179 return false;
180 };
181 if !none_match
182 .split(',')
183 .any(|tok| etag_matches(trim_spaces(tok), etag))
184 {
185 return false;
186 }
187 }
188 }
189
190 if let Some(modified_since) = modified_since {
192 let Some(last_modified) = present(res.last_modified) else {
193 return false;
194 };
195 let modified_stale = match (
196 parse_http_date(last_modified),
197 parse_http_date(modified_since),
198 ) {
199 (Some(last), Some(since)) => last > since,
200 _ => true,
202 };
203 if modified_stale {
204 return false;
205 }
206 }
207
208 true
209}
210
211fn present(value: Option<&str>) -> Option<&str> {
213 value.filter(|v| !v.is_empty())
214}
215
216fn trim_spaces(s: &str) -> &str {
219 s.trim_matches(' ')
220}
221
222fn etag_matches(tag: &str, etag: &str) -> bool {
225 tag == etag || tag.strip_prefix("W/") == Some(etag) || etag.strip_prefix("W/") == Some(tag)
226}
227
228fn has_no_cache(cc: &str) -> bool {
233 let needle = "no-cache";
234 let mut search_from = 0;
235 while let Some(rel) = cc[search_from..].find(needle) {
236 let start = search_from + rel;
237 let end = start + needle.len();
238
239 let left_ok = {
240 let mut iter = cc[..start]
242 .chars()
243 .rev()
244 .skip_while(|&c| is_js_whitespace(c));
245 matches!(iter.next(), None | Some(','))
246 };
247 let right_ok = {
248 let mut iter = cc[end..].chars().skip_while(|&c| is_js_whitespace(c));
250 matches!(iter.next(), None | Some(','))
251 };
252 if left_ok && right_ok {
253 return true;
254 }
255 search_from = start + 1;
256 }
257 false
258}
259
260fn is_js_whitespace(c: char) -> bool {
262 matches!(
263 c,
264 '\u{0009}'
265 | '\u{000A}'
266 | '\u{000B}'
267 | '\u{000C}'
268 | '\u{000D}'
269 | '\u{0020}'
270 | '\u{00A0}'
271 | '\u{1680}'
272 | '\u{2000}'
273 ..='\u{200A}'
274 | '\u{2028}'
275 | '\u{2029}'
276 | '\u{202F}'
277 | '\u{205F}'
278 | '\u{3000}'
279 | '\u{FEFF}'
280 )
281}
282
283fn parse_http_date(s: &str) -> Option<i64> {
288 parse_imf(s)
289 .or_else(|| parse_rfc850(s))
290 .or_else(|| parse_asctime(s))
291}
292
293fn parse_imf(s: &str) -> Option<i64> {
295 let comma = s.find(',')?;
296 let rest = s[comma + 1..].trim_start_matches(' ');
297 let mut it = rest.split(' ').filter(|p| !p.is_empty());
298 let day = parse_fixed_digits(it.next()?, 2)?;
299 let month = parse_month(it.next()?)?;
300 let year_str = it.next()?;
301 if year_str.len() != 4 {
302 return None;
303 }
304 let year = parse_fixed_digits(year_str, 4)?;
305 let (h, m, sec) = parse_time(it.next()?)?;
306 if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
307 return None;
308 }
309 make_epoch(i64::from(year), month, i64::from(day), h, m, sec)
310}
311
312fn parse_rfc850(s: &str) -> Option<i64> {
314 let comma = s.find(',')?;
315 let rest = s[comma + 1..].trim_start_matches(' ');
316 let mut it = rest.split(' ').filter(|p| !p.is_empty());
317 let date_part = it.next()?;
318 let (h, m, sec) = parse_time(it.next()?)?;
319 if !it.next()?.eq_ignore_ascii_case("GMT") || it.next().is_some() {
320 return None;
321 }
322 let mut dit = date_part.split('-');
323 let day = parse_fixed_digits(dit.next()?, 2)?;
324 let month = parse_month(dit.next()?)?;
325 let yy_str = dit.next()?;
326 if dit.next().is_some() || yy_str.len() != 2 {
327 return None;
328 }
329 let yy = i64::from(parse_fixed_digits(yy_str, 2)?);
330 let year = if yy < 50 { 2000 + yy } else { 1900 + yy };
332 make_epoch(year, month, i64::from(day), h, m, sec)
333}
334
335fn parse_asctime(s: &str) -> Option<i64> {
337 if s.contains(',') {
338 return None;
339 }
340 let mut it = s.split(' ').filter(|p| !p.is_empty());
341 let _weekday = it.next()?;
342 let month = parse_month(it.next()?)?;
343 let day = parse_day_1_or_2(it.next()?)?;
344 let (h, m, sec) = parse_time(it.next()?)?;
345 let year_str = it.next()?;
346 if it.next().is_some() || year_str.len() != 4 {
347 return None;
348 }
349 let year = i64::from(parse_fixed_digits(year_str, 4)?);
350 make_epoch(year, month, i64::from(day), h, m, sec)
351}
352
353fn parse_time(s: &str) -> Option<(i64, i64, i64)> {
355 let mut it = s.split(':');
356 let h = parse_fixed_digits(it.next()?, 2)?;
357 let m = parse_fixed_digits(it.next()?, 2)?;
358 let sec = parse_fixed_digits(it.next()?, 2)?;
359 if it.next().is_some() || h > 23 || m > 59 || sec > 60 {
360 return None;
361 }
362 Some((i64::from(h), i64::from(m), i64::from(sec)))
363}
364
365fn parse_fixed_digits(s: &str, width: usize) -> Option<u32> {
367 if s.len() != width || !s.bytes().all(|b| b.is_ascii_digit()) {
368 return None;
369 }
370 s.parse().ok()
371}
372
373fn parse_day_1_or_2(s: &str) -> Option<u32> {
376 if s.is_empty() || s.len() > 2 || !s.bytes().all(|b| b.is_ascii_digit()) {
377 return None;
378 }
379 s.parse().ok()
380}
381
382fn parse_month(s: &str) -> Option<i64> {
384 if s.len() != 3 {
385 return None;
386 }
387 let mut buf = [0u8; 3];
388 for (i, b) in s.bytes().enumerate() {
389 buf[i] = b.to_ascii_lowercase();
390 }
391 match &buf {
392 b"jan" => Some(1),
393 b"feb" => Some(2),
394 b"mar" => Some(3),
395 b"apr" => Some(4),
396 b"may" => Some(5),
397 b"jun" => Some(6),
398 b"jul" => Some(7),
399 b"aug" => Some(8),
400 b"sep" => Some(9),
401 b"oct" => Some(10),
402 b"nov" => Some(11),
403 b"dec" => Some(12),
404 _ => None,
405 }
406}
407
408fn make_epoch(year: i64, month: i64, day: i64, h: i64, m: i64, s: i64) -> Option<i64> {
410 if !(1..=31).contains(&day) {
411 return None;
412 }
413 let days = days_from_civil(year, month, day);
414 Some(days * 86_400 + h * 3_600 + m * 60 + s)
415}
416
417fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
419 let y = if month <= 2 { year - 1 } else { year };
420 let era = if y >= 0 { y } else { y - 399 } / 400;
421 let yoe = y - era * 400; let mp = if month > 2 { month - 3 } else { month + 9 }; let doy = (153 * mp + 2) / 5 + day - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn unconditional_request_is_stale() {
434 assert!(!fresh(&Request::new(), &Response::new()));
435 }
436
437 #[test]
438 fn star_if_none_match_is_fresh() {
439 let req = Request::new().if_none_match("*");
440 assert!(fresh(&req, &Response::new()));
441 }
442
443 #[test]
444 fn etag_strong_and_weak_matching() {
445 let req = Request::new().if_none_match("\"foo\"");
446 assert!(fresh(&req, &Response::new().etag("\"foo\"")));
447 assert!(!fresh(&req, &Response::new().etag("\"bar\"")));
448 assert!(fresh(
450 &Request::new().if_none_match("W/\"foo\""),
451 &Response::new().etag("\"foo\"")
452 ));
453 assert!(fresh(&req, &Response::new().etag("W/\"foo\"")));
454 }
455
456 #[test]
457 fn etag_list_matches_any() {
458 let req = Request::new().if_none_match("\"a\" , \"b\", \"c\"");
459 assert!(fresh(&req, &Response::new().etag("\"b\"")));
460 assert!(!fresh(&req, &Response::new().etag("\"z\"")));
461 }
462
463 #[test]
464 fn missing_response_etag_is_stale() {
465 let req = Request::new().if_none_match("\"foo\"");
466 assert!(!fresh(&req, &Response::new()));
467 }
468
469 #[test]
470 fn no_cache_forces_stale() {
471 let res = Response::new().etag("\"foo\"");
472 assert!(!fresh(
473 &Request::new()
474 .if_none_match("\"foo\"")
475 .cache_control("no-cache"),
476 &res
477 ));
478 assert!(!fresh(
479 &Request::new()
480 .if_none_match("\"foo\"")
481 .cache_control("max-age=0, no-cache"),
482 &res
483 ));
484 assert!(fresh(
486 &Request::new()
487 .if_none_match("\"foo\"")
488 .cache_control("no-cachex"),
489 &res
490 ));
491 assert!(fresh(
492 &Request::new()
493 .if_none_match("\"foo\"")
494 .cache_control("public, max-age=0"),
495 &res
496 ));
497 }
498
499 #[test]
500 fn modified_since_dates() {
501 let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
502 assert!(fresh(
504 &Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT"),
505 &res
506 ));
507 assert!(fresh(
509 &Request::new().if_modified_since("Mon, 07 Nov 1994 08:49:37 GMT"),
510 &res
511 ));
512 assert!(!fresh(
514 &Request::new().if_modified_since("Sat, 05 Nov 1994 08:49:37 GMT"),
515 &res
516 ));
517 }
518
519 #[test]
520 fn missing_last_modified_is_stale() {
521 let req = Request::new().if_modified_since("Sun, 06 Nov 1994 08:49:37 GMT");
522 assert!(!fresh(&req, &Response::new()));
523 }
524
525 #[test]
526 fn unparseable_date_is_stale() {
527 let req = Request::new().if_modified_since("not a date");
528 let res = Response::new().last_modified("Sun, 06 Nov 1994 08:49:37 GMT");
529 assert!(!fresh(&req, &res));
530 }
531
532 #[test]
533 fn empty_headers_treated_as_absent() {
534 assert!(!fresh(
535 &Request::new().if_modified_since("").if_none_match(""),
536 &Response::new()
537 ));
538 }
539
540 #[test]
541 fn all_three_date_formats_parse_equal() {
542 let imf = parse_http_date("Sun, 06 Nov 1994 08:49:37 GMT").unwrap();
543 let rfc850 = parse_http_date("Sunday, 06-Nov-94 08:49:37 GMT").unwrap();
544 let asctime = parse_http_date("Sun Nov 6 08:49:37 1994").unwrap();
545 assert_eq!(imf, rfc850);
546 assert_eq!(imf, asctime);
547 assert_eq!(imf, 784_111_777);
548 }
549
550 #[test]
551 fn rfc850_two_digit_year_pivot() {
552 let y49 = parse_http_date("Sun, 06-Nov-49 00:00:00 GMT").unwrap();
553 let y50 = parse_http_date("Sun, 06-Nov-50 00:00:00 GMT").unwrap();
554 assert!(y49 > y50); }
556}