Skip to main content

actix_http/header/shared/
quality_item.rs

1use std::{borrow::Cow, cmp, fmt, iter, str};
2
3use super::Quality;
4use crate::error::ParseError;
5
6/// Represents an item with a quality value as defined
7/// in [RFC 7231 ยง5.3.1](https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.1).
8///
9/// # Parsing and Formatting
10/// This wrapper be used to parse header value items that have a q-factor annotation as well as
11/// serialize items with a their q-factor.
12///
13/// When parsing, the `q` parameter is recognized at any position outside quoted parameter values.
14/// Other parameters are preserved for the wrapped item's parser.
15///
16/// # Ordering
17/// Since this context of use for this type is header value items, ordering is defined for
18/// `QualityItem`s but _only_ considers the item's quality. Order of appearance should be used as
19/// the secondary sorting parameter; i.e., a stable sort over the quality values will produce a
20/// correctly sorted sequence.
21///
22/// # Examples
23/// ```
24/// # use actix_http::header::{QualityItem, q};
25/// let q_item: QualityItem<String> = "hello;q=0.3".parse().unwrap();
26/// assert_eq!(&q_item.item, "hello");
27/// assert_eq!(q_item.quality, q(0.3));
28///
29/// // note that format is normalized compared to parsed item
30/// assert_eq!(q_item.to_string(), "hello; q=0.3");
31///
32/// // item with q=0.3 is greater than item with q=0.1
33/// let q_item_fallback: QualityItem<String> = "abc;q=0.1".parse().unwrap();
34/// assert!(q_item > q_item_fallback);
35/// ```
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct QualityItem<T> {
38    /// The wrapped contents of the field.
39    pub item: T,
40
41    /// The quality (client or server preference) for the value.
42    pub quality: Quality,
43}
44
45impl<T> QualityItem<T> {
46    /// Constructs a new `QualityItem` from an item and a quality value.
47    ///
48    /// The item can be of any type. The quality should be a value in the range [0, 1].
49    pub fn new(item: T, quality: Quality) -> Self {
50        QualityItem { item, quality }
51    }
52
53    /// Constructs a new `QualityItem` from an item, using the maximum q-value.
54    pub fn max(item: T) -> Self {
55        Self::new(item, Quality::MAX)
56    }
57
58    /// Constructs a new `QualityItem` from an item, using the minimum, non-zero q-value.
59    pub fn min(item: T) -> Self {
60        Self::new(item, Quality::MIN)
61    }
62
63    /// Constructs a new `QualityItem` from an item, using zero q-value of zero.
64    pub fn zero(item: T) -> Self {
65        Self::new(item, Quality::ZERO)
66    }
67}
68
69impl<T: PartialEq> PartialOrd for QualityItem<T> {
70    fn partial_cmp(&self, other: &QualityItem<T>) -> Option<cmp::Ordering> {
71        self.quality.partial_cmp(&other.quality)
72    }
73}
74
75impl<T: fmt::Display> fmt::Display for QualityItem<T> {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        fmt::Display::fmt(&self.item, f)?;
78
79        match self.quality {
80            // q-factor value is implied for max value
81            Quality::MAX => Ok(()),
82
83            // fast path for zero
84            Quality::ZERO => f.write_str("; q=0"),
85
86            // quality formatting is already using itoa
87            q => write!(f, "; q={}", q),
88        }
89    }
90}
91
92impl<T: str::FromStr> str::FromStr for QualityItem<T> {
93    type Err = ParseError;
94
95    fn from_str(q_item_str: &str) -> Result<Self, Self::Err> {
96        if !q_item_str.is_ascii() {
97            return Err(ParseError::Header);
98        }
99
100        let mut parameter_start = None;
101        let mut quality_parameter = None;
102        let mut quoted = false;
103        let mut escaped = false;
104
105        // Only unquoted semicolons delimit parameters; quoted-pairs escape the next byte.
106        for (index, byte) in q_item_str.bytes().chain(iter::once(b';')).enumerate() {
107            if escaped {
108                // skip escaped char
109                escaped = false;
110                continue;
111            }
112
113            match byte {
114                b'\\' if quoted => escaped = true,
115
116                b'"' => quoted = !quoted,
117
118                b';' if !quoted => {
119                    if let Some(start) = parameter_start {
120                        let attribute = q_item_str[start + 1..index].trim();
121
122                        // Retain the existing rejection of a short final attribute.
123                        if index == q_item_str.len() && attribute.len() < 2 {
124                            return Err(ParseError::Header);
125                        }
126
127                        if attribute.starts_with("q=") || attribute.starts_with("Q=") {
128                            // Preserve the existing last-quality-parameter behavior.
129                            quality_parameter = Some((start, index, &attribute[2..]));
130                        }
131                    }
132
133                    parameter_start = Some(index);
134                }
135
136                _ => {}
137            }
138        }
139
140        let mut raw_item = Cow::Borrowed(q_item_str);
141        let mut quality = Quality::MAX;
142
143        if let Some((start, end, value)) = quality_parameter {
144            // Parse decimal digits directly to preserve the specified precision.
145            quality = match value.as_bytes() {
146                [b'0'] => Quality::ZERO,
147                [b'1'] => Quality::MAX,
148
149                [b'0', b'.', digits @ ..]
150                    if digits.len() <= 3 && digits.iter().all(u8::is_ascii_digit) =>
151                {
152                    let mut value = 0;
153                    let mut place = 100;
154
155                    for &digit in digits {
156                        value += u16::from(digit - b'0') * place;
157                        place /= 10;
158                    }
159
160                    Quality(value)
161                }
162
163                [b'1', b'.', digits @ ..]
164                    if digits.len() <= 3 && digits.iter().all(|&digit| digit == b'0') =>
165                {
166                    Quality::MAX
167                }
168
169                _ => return Err(ParseError::Header),
170            };
171
172            raw_item = if end == q_item_str.len() {
173                Cow::Borrowed(q_item_str[..start].trim())
174            } else {
175                Cow::Owned([&q_item_str[..start], &q_item_str[end..]].concat())
176            };
177        }
178
179        let item = raw_item.parse::<T>().map_err(|_| ParseError::Header)?;
180
181        Ok(QualityItem::new(item, quality))
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn quality_parameter_position() {
191        for input in [
192            "text/plain; q=0.2; charset=utf-8; format=flowed",
193            "text/plain; charset=utf-8; Q=0.2; format=flowed",
194            "text/plain; charset=utf-8; format=flowed; q=0.2",
195        ] {
196            let parsed = input.parse::<QualityItem<mime::Mime>>().unwrap();
197
198            assert_eq!(parsed.quality, Quality(200), "{input}");
199            assert_eq!(
200                parsed.item,
201                "text/plain; charset=utf-8; format=flowed"
202                    .parse::<mime::Mime>()
203                    .unwrap(),
204                "{input}"
205            );
206        }
207    }
208
209    #[test]
210    fn quoted_quality_is_not_parameter() {
211        for input in [
212            r#"text/plain; note="x; q=0.2""#,
213            r#"text/plain; note="x; q=invalid""#,
214            r#"text/plain; note="x;Q=0""#,
215            r#"text/plain; note="x\";q=0.2""#,
216            r#"text/plain; note="x\\;q=0.2""#,
217        ] {
218            let parsed = input.parse::<QualityItem<String>>().unwrap();
219
220            assert_eq!(parsed.item, input);
221            assert_eq!(parsed.quality, Quality::MAX, "{input}");
222        }
223    }
224
225    #[test]
226    fn quality_parameter_with_quoted_parameters() {
227        for input in [
228            r#"text/plain; q=0.2; note="x; q=0.8"; format=flowed"#,
229            r#"text/plain; note="x; q=0.8"; q=0.2; format=flowed"#,
230            r#"text/plain; note="x; q=0.8"; format=flowed; q=0.2"#,
231        ] {
232            let parsed = input.parse::<QualityItem<mime::Mime>>().unwrap();
233
234            assert_eq!(parsed.quality, Quality(200), "{input}");
235            assert_eq!(parsed.item.get_param("note").unwrap(), "x; q=0.8");
236            assert_eq!(parsed.item.get_param("format").unwrap(), "flowed");
237            assert!(parsed.item.get_param("q").is_none(), "{input}");
238        }
239    }
240
241    #[test]
242    fn invalid_quality_not_last() {
243        for quality in ["2", "invalid", "0.2739999", "NaN", "", "-1"] {
244            for input in [
245                format!("text/plain; q={quality}; charset=utf-8"),
246                format!("text/plain; charset=utf-8; Q={quality}; format=flowed"),
247            ] {
248                assert!(
249                    input.parse::<QualityItem<mime::Mime>>().is_err(),
250                    "accepted invalid quality in {input}"
251                );
252            }
253        }
254    }
255
256    #[test]
257    fn ordinary_parameters_keep_default_quality() {
258        for input in [
259            "text/plain; charset=utf-8",
260            "text/plain; charset=utf-8; format=flowed",
261            "text/plain; quality=0.2; query=yes",
262        ] {
263            let parsed = input.parse::<QualityItem<mime::Mime>>().unwrap();
264            assert_eq!(parsed.quality, Quality::MAX, "{input}");
265            assert_eq!(parsed.item, input.parse::<mime::Mime>().unwrap());
266        }
267    }
268
269    #[test]
270    fn generic_item_keeps_unrelated_parameters() {
271        for (input, expected) in [
272            ("hello; q=0.2; extension=yes", "hello; extension=yes"),
273            (
274                r#"hello; note="x; q=invalid"; q=0.2; extension=yes"#,
275                r#"hello; note="x; q=invalid"; extension=yes"#,
276            ),
277            ("  hello  ; q=0.2  ", "hello"),
278            ("hello; q=0.1; q=0.2", "hello; q=0.1"),
279        ] {
280            let parsed = input.parse::<QualityItem<String>>().unwrap();
281            assert_eq!(parsed.item, expected, "{input}");
282            assert_eq!(parsed.quality, Quality(200), "{input}");
283        }
284    }
285
286    // copy of encoding from actix-web headers
287    #[allow(clippy::enum_variant_names)] // allow Encoding prefix on EncodingExt
288    #[derive(Debug, Clone, PartialEq, Eq)]
289    pub enum Encoding {
290        Chunked,
291        Brotli,
292        Gzip,
293        Deflate,
294        Compress,
295        Identity,
296        Trailers,
297        EncodingExt(String),
298    }
299
300    impl fmt::Display for Encoding {
301        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
302            use Encoding::*;
303            f.write_str(match *self {
304                Chunked => "chunked",
305                Brotli => "br",
306                Gzip => "gzip",
307                Deflate => "deflate",
308                Compress => "compress",
309                Identity => "identity",
310                Trailers => "trailers",
311                EncodingExt(ref s) => s.as_ref(),
312            })
313        }
314    }
315
316    impl str::FromStr for Encoding {
317        type Err = crate::error::ParseError;
318
319        fn from_str(s: &str) -> Result<Encoding, crate::error::ParseError> {
320            use Encoding::*;
321
322            match s {
323                "chunked" => Ok(Chunked),
324                "br" => Ok(Brotli),
325                "deflate" => Ok(Deflate),
326                "gzip" => Ok(Gzip),
327                "compress" => Ok(Compress),
328                "identity" => Ok(Identity),
329                "trailers" => Ok(Trailers),
330                _ => Ok(EncodingExt(s.to_owned())),
331            }
332        }
333    }
334
335    #[test]
336    fn test_quality_item_fmt_q() {
337        assert_eq!(QualityItem::max(Encoding::Chunked).to_string(), "chunked");
338
339        assert_eq!(
340            QualityItem::new(Encoding::Chunked, Quality(1)).to_string(),
341            "chunked; q=0.001",
342        );
343    }
344
345    #[test]
346    fn test_quality_item_fmt_q_05() {
347        let x = QualityItem {
348            item: Encoding::EncodingExt("identity".to_owned()),
349            quality: Quality(500),
350        };
351
352        assert_eq!(x.to_string(), "identity; q=0.5");
353    }
354
355    #[test]
356    fn test_quality_item_fmt_q_0() {
357        let x = QualityItem {
358            item: Encoding::EncodingExt("identity".to_owned()),
359            quality: Quality::ZERO,
360        };
361
362        assert_eq!(x.to_string(), "identity; q=0");
363    }
364
365    #[test]
366    fn test_quality_item_from_str1() {
367        let x = "chunked".parse::<QualityItem<Encoding>>().unwrap();
368
369        assert_eq!(
370            x,
371            QualityItem {
372                item: Encoding::Chunked,
373                quality: Quality(1000),
374            }
375        );
376    }
377
378    #[test]
379    fn test_quality_item_from_str2() {
380        let x = "chunked; q=1".parse::<QualityItem<Encoding>>().unwrap();
381
382        assert_eq!(
383            x,
384            QualityItem {
385                item: Encoding::Chunked,
386                quality: Quality(1000),
387            }
388        );
389    }
390
391    #[test]
392    fn test_quality_item_from_str3() {
393        let x = "gzip; q=0.5".parse::<QualityItem<Encoding>>().unwrap();
394
395        assert_eq!(
396            x,
397            QualityItem {
398                item: Encoding::Gzip,
399                quality: Quality(500),
400            }
401        );
402    }
403
404    #[test]
405    fn test_quality_item_from_str4() {
406        let x = "gzip; q=0.273".parse::<QualityItem<Encoding>>().unwrap();
407
408        assert_eq!(
409            x,
410            QualityItem {
411                item: Encoding::Gzip,
412                quality: Quality(273),
413            }
414        );
415    }
416
417    #[test]
418    fn test_quality_item_from_str5() {
419        "gzip; q=0.2739999"
420            .parse::<QualityItem<Encoding>>()
421            .unwrap_err();
422    }
423
424    #[test]
425    fn test_quality_item_from_str6() {
426        "gzip; q=2".parse::<QualityItem<Encoding>>().unwrap_err();
427    }
428
429    #[test]
430    fn quality_value_rejects_invalid_numeric_syntax() {
431        for value in [
432            "", ".5", ".0001", "+0.5", "-0", "-0.0", "00.5", "00001", "1e-1", "1e-4", "0.0000",
433            "1.0000", "1.001", "0.0.1", "NaN", "inf", " 0.5", "\"0.5\"",
434        ] {
435            let input = format!("gzip; q={value}");
436
437            assert!(
438                input.parse::<QualityItem<Encoding>>().is_err(),
439                "accepted invalid quality in {input:?}"
440            );
441        }
442    }
443
444    #[test]
445    fn quality_value_accepts_valid_numeric_syntax() {
446        for (value, expected) in [
447            ("0", Quality::ZERO),
448            ("0.", Quality::ZERO),
449            ("0.0", Quality::ZERO),
450            ("0.00", Quality::ZERO),
451            ("0.000", Quality::ZERO),
452            ("0.001", Quality::MIN),
453            ("0.01", Quality(10)),
454            ("0.1", Quality(100)),
455            ("0.10", Quality(100)),
456            ("0.100", Quality(100)),
457            ("0.999", Quality(999)),
458            ("1", Quality::MAX),
459            ("1.", Quality::MAX),
460            ("1.0", Quality::MAX),
461            ("1.00", Quality::MAX),
462            ("1.000", Quality::MAX),
463        ] {
464            let input = format!("gzip; q={value}");
465            let parsed = input.parse::<QualityItem<Encoding>>().unwrap();
466
467            assert_eq!(parsed.quality, expected, "{input}");
468        }
469    }
470
471    #[test]
472    fn quality_value_preserves_thousandths() {
473        for value in 0..1000 {
474            let input = format!("gzip; q=0.{value:03}");
475            let parsed = input.parse::<QualityItem<Encoding>>().unwrap();
476
477            assert_eq!(parsed.quality, Quality(value), "{input}");
478        }
479    }
480
481    #[test]
482    fn test_quality_item_ordering() {
483        let x = "gzip; q=0.5".parse::<QualityItem<Encoding>>().unwrap();
484        let y = "gzip; q=0.273".parse::<QualityItem<Encoding>>().unwrap();
485
486        assert!(x > y);
487    }
488
489    #[test]
490    fn test_fuzzing_bugs() {
491        "99999;".parse::<QualityItem<String>>().unwrap_err();
492
493        "\x0d;;;=\u{d6aa}=="
494            .parse::<QualityItem<String>>()
495            .unwrap_err();
496    }
497}