use std::str::FromStr;
use http::HeaderValue;
use http::header::InvalidHeaderValue;
use serde::{Deserialize, Serialize};
use stdx::str::StrExt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ETag {
Strong(String),
Weak(String),
}
#[derive(Debug, thiserror::Error)]
pub enum ParseETagError {
#[error("ParseETagError: InvalidFormat")]
InvalidFormat,
#[error("ParseETagError: InvalidChar")]
InvalidChar,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ETagComparison {
StrongMatch,
WeakMatch,
NoMatch,
}
impl ETag {
#[must_use]
pub fn value(&self) -> &str {
match self {
ETag::Strong(s) | ETag::Weak(s) => s,
}
}
#[must_use]
pub fn into_strong(self) -> Option<String> {
match self {
ETag::Strong(s) => Some(s),
ETag::Weak(_) => None,
}
}
#[must_use]
pub fn as_strong(&self) -> Option<&str> {
match self {
ETag::Strong(s) => Some(s),
ETag::Weak(_) => None,
}
}
#[must_use]
pub fn as_weak(&self) -> Option<&str> {
match self {
ETag::Weak(s) => Some(s),
ETag::Strong(_) => None,
}
}
#[must_use]
pub fn into_value(self) -> String {
match self {
ETag::Strong(s) | ETag::Weak(s) => s,
}
}
#[must_use]
pub fn into_weak(self) -> Option<String> {
match self {
ETag::Weak(s) => Some(s),
ETag::Strong(_) => None,
}
}
#[must_use]
pub fn strong_cmp(&self, other: &Self) -> bool {
match (self, other) {
(ETag::Strong(a), ETag::Strong(b)) => a == b,
_ => false,
}
}
#[must_use]
pub fn weak_cmp(&self, other: &Self) -> bool {
self.value() == other.value()
}
#[must_use]
pub fn compare(&self, other: &Self) -> ETagComparison {
if self.value() != other.value() {
return ETagComparison::NoMatch;
}
match (self, other) {
(ETag::Strong(_), ETag::Strong(_)) => ETagComparison::StrongMatch,
_ => ETagComparison::WeakMatch,
}
}
}
impl ETag {
fn check_header_value(s: &[u8]) -> bool {
s.iter().all(|&b| b >= 32 && b != 127 || b == b'\t')
}
fn is_valid_unquoted_etag(s: &[u8]) -> bool {
!s.is_empty() && s.iter().all(|&b| b.is_ascii_alphanumeric() || b == b'-')
}
pub fn parse_http_header(src: &[u8]) -> Result<Self, ParseETagError> {
match src {
[b'"', val @ .., b'"'] => {
if !Self::check_header_value(val) {
return Err(ParseETagError::InvalidChar);
}
let val = str::from_ascii_simd(val).map_err(|_| ParseETagError::InvalidChar)?;
Ok(ETag::Strong(val.to_owned()))
}
[b'W', b'/', b'"', val @ .., b'"'] => {
if !Self::check_header_value(val) {
return Err(ParseETagError::InvalidChar);
}
let val = str::from_ascii_simd(val).map_err(|_| ParseETagError::InvalidChar)?;
Ok(ETag::Weak(val.to_owned()))
}
val if Self::is_valid_unquoted_etag(val) => {
let val = str::from_ascii_simd(val).map_err(|_| ParseETagError::InvalidChar)?;
Ok(ETag::Strong(val.to_owned()))
}
_ => Err(ParseETagError::InvalidFormat),
}
}
pub fn to_http_header(&self) -> Result<HeaderValue, InvalidHeaderValue> {
let buf = match self {
ETag::Strong(s) => {
let mut buf = Vec::with_capacity(s.len() + 2);
buf.push(b'"');
buf.extend_from_slice(s.as_bytes());
buf.push(b'"');
buf
}
ETag::Weak(s) => {
let mut buf = Vec::with_capacity(s.len() + 4);
buf.extend_from_slice(b"W/\"");
buf.extend_from_slice(s.as_bytes());
buf.push(b'"');
buf
}
};
HeaderValue::try_from(buf)
}
}
impl FromStr for ETag {
type Err = ParseETagError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_http_header(s.as_bytes())
}
}
#[cfg(test)]
mod tests {
use super::{ETag, ETagComparison, ParseETagError};
#[test]
fn strong_value_and_header_ok() {
let etag = ETag::Strong("abc123".to_string());
assert_eq!(etag.value(), "abc123");
let hv = etag.to_http_header().expect("strong etag header");
assert_eq!(hv.as_bytes(), b"\"abc123\"");
}
#[test]
fn weak_value_and_header_ok() {
let etag = ETag::Weak("xyz".to_string());
assert_eq!(etag.value(), "xyz");
let hv = etag.to_http_header().expect("weak etag header");
assert_eq!(hv.as_bytes(), b"W/\"xyz\"");
}
#[test]
fn strong_empty_header_ok() {
let etag = ETag::Strong(String::new());
let hv = etag.to_http_header().expect("empty strong etag");
assert_eq!(hv.as_bytes(), b"\"\"");
}
#[test]
fn weak_empty_header_ok() {
let etag = ETag::Weak(String::new());
let hv = etag.to_http_header().expect("empty weak etag");
assert_eq!(hv.as_bytes(), b"W/\"\"");
}
#[test]
fn header_invalid_when_contains_newline() {
let strong_bad = ETag::Strong("a\nb".to_string());
assert!(strong_bad.to_http_header().is_err());
let weak_bad = ETag::Weak("a\r\nb".to_string());
assert!(weak_bad.to_http_header().is_err());
}
#[test]
fn parse_strong_ok() {
let etag = ETag::parse_http_header(b"\"abc123\"").expect("parse strong");
assert_eq!(etag.as_strong(), Some("abc123"));
}
#[test]
fn parse_weak_ok() {
let etag = ETag::parse_http_header(b"W/\"xyz\"").expect("parse weak");
assert_eq!(etag.as_weak(), Some("xyz"));
}
#[test]
fn parse_empty_ok() {
let s = ETag::parse_http_header(b"\"\"").expect("parse empty strong");
assert_eq!(s.as_strong(), Some(""));
let w = ETag::parse_http_header(b"W/\"\"").expect("parse empty weak");
assert_eq!(w.as_weak(), Some(""));
}
#[test]
fn parse_allows_tab() {
let s = ETag::parse_http_header(b"\"a\tb\"").expect("tab in strong");
assert_eq!(s.as_strong(), Some("a\tb"));
let w = ETag::parse_http_header(b"W/\"a\tb\"").expect("tab in weak");
assert_eq!(w.as_weak(), Some("a\tb"));
}
#[test]
fn parse_invalid_format_cases() {
let err = ETag::parse_http_header(b"").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"\"unclosed").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"W/\"unclosed").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"W/xyz").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"\"abc\"x").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"W/\"abc\"x").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"**").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"* ").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
}
#[test]
fn parse_unquoted_strong_etag() {
let etag = ETag::parse_http_header(b"abc").expect("parse unquoted");
assert_eq!(etag.as_strong(), Some("abc"));
let etag = ETag::parse_http_header(b"ABCORZ").expect("parse unquoted etag");
assert_eq!(etag.as_strong(), Some("ABCORZ"));
let etag = ETag::parse_http_header(b"4fcec74691ff529f6d016ec3629ff11b").expect("parse unquoted md5");
assert_eq!(etag.as_strong(), Some("4fcec74691ff529f6d016ec3629ff11b"));
let etag = ETag::parse_http_header(b"4fcec74691ff529f6d016ec3629ff11b-5").expect("parse multipart etag");
assert_eq!(etag.as_strong(), Some("4fcec74691ff529f6d016ec3629ff11b-5"));
}
#[test]
fn parse_invalid_char_cases() {
let err = ETag::parse_http_header(b"\"a\nb\"").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidChar));
let err = ETag::parse_http_header(b"W/\"a\rb\"").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidChar));
let err = ETag::parse_http_header(b"\"a\x7fb\"").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidChar));
let err = ETag::parse_http_header(b"W/\"a\x7fb\"").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidChar));
let err = ETag::parse_http_header(b"\"a\xc2\xb5b\"").unwrap_err(); assert!(matches!(err, ParseETagError::InvalidChar));
let err = ETag::parse_http_header(b"a\nb").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"a\rb").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"a\x7fb").unwrap_err();
assert!(matches!(err, ParseETagError::InvalidFormat));
let err = ETag::parse_http_header(b"a\xc2\xb5b").unwrap_err(); assert!(matches!(err, ParseETagError::InvalidFormat));
}
#[test]
fn to_header_allows_tab() {
let etag = ETag::Strong("a\tb".to_string());
let hv = etag.to_http_header().expect("header with tab");
assert_eq!(hv.as_bytes(), b"\"a\tb\"");
}
#[test]
fn header_invalid_when_contains_del_127() {
let s = String::from_utf8(vec![b'a', 0x7f, b'b']).unwrap();
assert!(ETag::Strong(s.clone()).to_http_header().is_err());
assert!(ETag::Weak(s).to_http_header().is_err());
}
#[test]
fn parse_and_header_roundtrip() {
let values = ["", "abc", "a\tb", " !#$%&()*+,-./:;<=>?@[]^_`{|}~"];
for v in values {
let e = ETag::Strong(v.to_string());
let hv = e.to_http_header().expect("strong header");
let p = ETag::parse_http_header(hv.as_bytes()).expect("parse strong back");
assert_eq!(p.as_strong(), Some(v));
let e = ETag::Weak(v.to_string());
let hv = e.to_http_header().expect("weak header");
let p = ETag::parse_http_header(hv.as_bytes()).expect("parse weak back");
assert_eq!(p.as_weak(), Some(v));
}
}
#[test]
fn from_str_trait() {
let e: ETag = "\"abc123\"".parse().expect("parse strong from str");
assert_eq!(e.as_strong(), Some("abc123"));
let e: ETag = "W/\"xyz\"".parse().expect("parse weak from str");
assert_eq!(e.as_weak(), Some("xyz"));
let e: ETag = "abc".parse().expect("parse unquoted from str");
assert_eq!(e.as_strong(), Some("abc"));
}
#[test]
fn strong_cmp_both_strong_same_value() {
let a = ETag::Strong("abc".to_string());
let b = ETag::Strong("abc".to_string());
assert!(a.strong_cmp(&b));
assert!(b.strong_cmp(&a));
}
#[test]
fn strong_cmp_both_strong_diff_value() {
let a = ETag::Strong("abc".to_string());
let b = ETag::Strong("xyz".to_string());
assert!(!a.strong_cmp(&b));
}
#[test]
fn strong_cmp_weak_never_matches() {
let strong = ETag::Strong("abc".to_string());
let weak = ETag::Weak("abc".to_string());
assert!(!strong.strong_cmp(&weak));
assert!(!weak.strong_cmp(&strong));
assert!(!weak.strong_cmp(&weak));
}
#[test]
fn weak_cmp_same_value() {
let s1 = ETag::Strong("abc".to_string());
let s2 = ETag::Strong("abc".to_string());
let w1 = ETag::Weak("abc".to_string());
let w2 = ETag::Weak("abc".to_string());
assert!(s1.weak_cmp(&s2));
assert!(s1.weak_cmp(&w1));
assert!(w1.weak_cmp(&s1));
assert!(w1.weak_cmp(&w2));
}
#[test]
fn weak_cmp_diff_value() {
let a = ETag::Strong("abc".to_string());
let b = ETag::Weak("xyz".to_string());
assert!(!a.weak_cmp(&b));
}
#[test]
fn compare_strong_match() {
let a = ETag::Strong("abc".to_string());
let b = ETag::Strong("abc".to_string());
assert_eq!(a.compare(&b), ETagComparison::StrongMatch);
assert_eq!(b.compare(&a), ETagComparison::StrongMatch);
}
#[test]
fn compare_weak_match() {
let s = ETag::Strong("abc".to_string());
let w = ETag::Weak("abc".to_string());
let w2 = ETag::Weak("abc".to_string());
assert_eq!(s.compare(&w), ETagComparison::WeakMatch);
assert_eq!(w.compare(&s), ETagComparison::WeakMatch);
assert_eq!(w.compare(&w2), ETagComparison::WeakMatch);
}
#[test]
fn compare_not_equal() {
let s1 = ETag::Strong("abc".to_string());
let s2 = ETag::Strong("xyz".to_string());
let w1 = ETag::Weak("abc".to_string());
let w2 = ETag::Weak("xyz".to_string());
assert_eq!(s1.compare(&s2), ETagComparison::NoMatch);
assert_eq!(s2.compare(&s1), ETagComparison::NoMatch);
assert_eq!(s1.compare(&w2), ETagComparison::NoMatch);
assert_eq!(s2.compare(&w1), ETagComparison::NoMatch);
assert_eq!(w1.compare(&s2), ETagComparison::NoMatch);
assert_eq!(w2.compare(&s1), ETagComparison::NoMatch);
assert_eq!(w1.compare(&w2), ETagComparison::NoMatch);
assert_eq!(w2.compare(&w1), ETagComparison::NoMatch);
}
}