Skip to main content

baukit_http/
precondition.rs

1use std::{collections::BTreeMap, error::Error as StdError, fmt};
2
3use axum::http::{HeaderMap, HeaderValue, StatusCode, header::IF_MATCH};
4use baukit_openapi::{INVALID_IF_MATCH_CODE, PRECONDITION_FAILED_CODE, PRECONDITION_REQUIRED_CODE};
5use serde_json::Value;
6
7use crate::ApiError;
8
9/// The longest prefix a [`RevisionEtag`] accepts, in bytes.
10pub const MAX_ETAG_PREFIX_BYTES: usize = 64;
11
12const WEAK_INDICATOR: &str = "W/";
13const QUOTE: u8 = b'"';
14const LIST_SEPARATOR: u8 = b',';
15const WILDCARD: &str = "*";
16const REASON_DETAIL: &str = "reason";
17const CURRENT_REVISION_DETAIL: &str = "currentRevision";
18
19/// A non-negative resource revision carried in a strong ETag.
20///
21/// The range is `0..=i64::MAX`, so every revision fits a PostgreSQL `BIGINT` and converts to
22/// `i64` without loss.
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct Revision(u64);
25
26impl Revision {
27    /// The largest revision, `i64::MAX`.
28    pub const MAX: Self = Self(i64::MAX.unsigned_abs());
29
30    /// Returns the revision as an unsigned integer.
31    #[must_use]
32    pub const fn get(self) -> u64 {
33        self.0
34    }
35
36    /// Returns the revision as a signed integer for storage.
37    #[must_use]
38    pub const fn to_i64(self) -> i64 {
39        self.0.cast_signed()
40    }
41}
42
43impl From<u32> for Revision {
44    fn from(value: u32) -> Self {
45        Self(u64::from(value))
46    }
47}
48
49impl TryFrom<u64> for Revision {
50    type Error = RevisionOutOfRange;
51
52    fn try_from(value: u64) -> Result<Self, Self::Error> {
53        if value > Self::MAX.0 {
54            return Err(RevisionOutOfRange);
55        }
56        Ok(Self(value))
57    }
58}
59
60impl TryFrom<i64> for Revision {
61    type Error = RevisionOutOfRange;
62
63    fn try_from(value: i64) -> Result<Self, Self::Error> {
64        u64::try_from(value)
65            .map(Self)
66            .map_err(|_| RevisionOutOfRange)
67    }
68}
69
70impl fmt::Display for Revision {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        self.0.fmt(formatter)
73    }
74}
75
76/// A value outside `0..=i64::MAX` cannot be a [`Revision`].
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub struct RevisionOutOfRange;
79
80impl fmt::Display for RevisionOutOfRange {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str("revision must be between 0 and i64::MAX")
83    }
84}
85
86impl StdError for RevisionOutOfRange {}
87
88impl From<RevisionOutOfRange> for ApiError {
89    fn from(error: RevisionOutOfRange) -> Self {
90        Self::internal(error)
91    }
92}
93
94/// An ETag prefix is longer than [`MAX_ETAG_PREFIX_BYTES`] or has a byte outside
95/// `[A-Za-z0-9._:-]`.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub struct InvalidEtagPrefix;
98
99impl fmt::Display for InvalidEtagPrefix {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        write!(
102            formatter,
103            "ETag prefixes must be at most {MAX_ETAG_PREFIX_BYTES} bytes of ASCII letters, digits, \
104             `.`, `_`, `:`, or `-`"
105        )
106    }
107}
108
109impl StdError for InvalidEtagPrefix {}
110
111/// Formats and parses strong revision ETags of the form `"<prefix><revision>"`.
112///
113/// The prefix is compared byte for byte, so it is case-sensitive. The revision is canonical
114/// decimal: no sign, no leading zeros, and `0` is allowed.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub struct RevisionEtag<'a> {
117    prefix: &'a str,
118}
119
120impl<'a> RevisionEtag<'a> {
121    /// Creates a codec for a fixed prefix, such as `"rev-"`.
122    ///
123    /// # Panics
124    ///
125    /// Panics when the prefix is invalid. In a `const` item the panic is a compile error. Use
126    /// [`RevisionEtag::try_new`] for prefixes built at runtime.
127    #[must_use]
128    pub const fn new(prefix: &'a str) -> Self {
129        assert!(is_valid_prefix(prefix), "invalid revision ETag prefix");
130        Self { prefix }
131    }
132
133    /// Creates a codec for a prefix built at runtime.
134    pub const fn try_new(prefix: &'a str) -> Result<Self, InvalidEtagPrefix> {
135        if is_valid_prefix(prefix) {
136            Ok(Self { prefix })
137        } else {
138            Err(InvalidEtagPrefix)
139        }
140    }
141
142    /// Returns the prefix.
143    #[must_use]
144    pub const fn prefix(&self) -> &'a str {
145        self.prefix
146    }
147
148    /// Returns the quoted ETag, for example `"rev-42"` including the quotes.
149    #[must_use]
150    pub fn format(&self, revision: Revision) -> String {
151        format!("\"{}{revision}\"", self.prefix)
152    }
153
154    /// Returns the quoted ETag as an `ETag` response header value.
155    #[must_use]
156    pub fn header_value(&self, revision: Revision) -> HeaderValue {
157        HeaderValue::from_str(&self.format(revision))
158            .expect("a validated prefix and a decimal revision form a valid header value")
159    }
160
161    /// Parses one `If-Match` field value.
162    pub fn parse(&self, value: &HeaderValue) -> Result<Revision, InvalidIfMatch> {
163        let text = visible_ascii(value.as_bytes())?;
164        let tag = text.trim_matches([' ', '\t']);
165        if tag.is_empty() {
166            return Err(InvalidIfMatch::Empty);
167        }
168        if tag.starts_with(WILDCARD) {
169            return Err(InvalidIfMatch::Wildcard);
170        }
171        let (weak, opaque) = single_entity_tag(tag)?;
172        if weak {
173            return Err(InvalidIfMatch::WeakValidator);
174        }
175        let digits = opaque
176            .strip_prefix(self.prefix)
177            .ok_or(InvalidIfMatch::PrefixMismatch)?;
178        parse_revision(digits)
179    }
180
181    /// Reads the revision from a route that requires `If-Match`.
182    ///
183    /// A missing header returns [`PreconditionError::Required`].
184    pub fn required_if_match(&self, headers: &HeaderMap) -> Result<Revision, PreconditionError> {
185        self.optional_if_match(headers)?
186            .ok_or(PreconditionError::Required)
187    }
188
189    /// Reads the revision from a route where `If-Match` is optional.
190    ///
191    /// A missing header returns `Ok(None)`, which permits an unconditional write.
192    pub fn optional_if_match(
193        &self,
194        headers: &HeaderMap,
195    ) -> Result<Option<Revision>, PreconditionError> {
196        let mut values = headers.get_all(IF_MATCH).iter();
197        let Some(value) = values.next() else {
198            return Ok(None);
199        };
200        if values.next().is_some() {
201            return Err(InvalidIfMatch::RepeatedHeader.into());
202        }
203        Ok(Some(self.parse(value)?))
204    }
205}
206
207/// Why an `If-Match` value is not one strong ETag for the resource.
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum InvalidIfMatch {
210    /// The request carries more than one `If-Match` header.
211    RepeatedHeader,
212    /// The header value is empty or only whitespace.
213    Empty,
214    /// The header value contains bytes outside visible ASCII, space, and tab.
215    NonAscii,
216    /// The header value is `*`.
217    Wildcard,
218    /// The ETag is weak (`W/"..."`).
219    WeakValidator,
220    /// The header value lists more than one ETag.
221    List,
222    /// The header value is not a quoted entity tag.
223    Malformed,
224    /// The ETag does not start with the route's prefix.
225    PrefixMismatch,
226    /// The revision is not canonical decimal digits.
227    InvalidRevision,
228    /// The revision is larger than [`Revision::MAX`].
229    RevisionOutOfRange,
230}
231
232impl InvalidIfMatch {
233    /// Returns the stable snake_case reason sent in the error `details.reason`.
234    #[must_use]
235    pub const fn reason(self) -> &'static str {
236        match self {
237            Self::RepeatedHeader => "repeated_header",
238            Self::Empty => "empty",
239            Self::NonAscii => "non_ascii",
240            Self::Wildcard => "wildcard",
241            Self::WeakValidator => "weak_validator",
242            Self::List => "list",
243            Self::Malformed => "malformed",
244            Self::PrefixMismatch => "prefix_mismatch",
245            Self::InvalidRevision => "invalid_revision",
246            Self::RevisionOutOfRange => "revision_out_of_range",
247        }
248    }
249}
250
251impl fmt::Display for InvalidIfMatch {
252    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(formatter, "invalid If-Match: {}", self.reason())
254    }
255}
256
257impl StdError for InvalidIfMatch {}
258
259/// A failed revision precondition.
260///
261/// Converts into [`ApiError`] as 428 `precondition_required`, 400 `invalid_if_match`, or 412
262/// `precondition_failed`.
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum PreconditionError {
265    /// A route that requires `If-Match` received none.
266    Required,
267    /// The `If-Match` value is not one strong ETag for the resource.
268    Invalid(InvalidIfMatch),
269    /// The expected revision is not the stored one.
270    Stale {
271        /// The stored revision, when the product knows it.
272        current: Option<Revision>,
273    },
274}
275
276impl PreconditionError {
277    /// Returns the HTTP status for this error.
278    #[must_use]
279    pub const fn status(&self) -> StatusCode {
280        match self {
281            Self::Required => StatusCode::PRECONDITION_REQUIRED,
282            Self::Invalid(_) => StatusCode::BAD_REQUEST,
283            Self::Stale { .. } => StatusCode::PRECONDITION_FAILED,
284        }
285    }
286
287    /// Returns the stable error code for this error.
288    #[must_use]
289    pub const fn code(&self) -> &'static str {
290        match self {
291            Self::Required => PRECONDITION_REQUIRED_CODE,
292            Self::Invalid(_) => INVALID_IF_MATCH_CODE,
293            Self::Stale { .. } => PRECONDITION_FAILED_CODE,
294        }
295    }
296
297    const fn message(&self) -> &'static str {
298        match self {
299            Self::Required => "If-Match is required",
300            Self::Invalid(_) => "If-Match must contain one strong ETag for this resource",
301            Self::Stale { .. } => "The resource changed since it was read",
302        }
303    }
304
305    fn details(&self) -> BTreeMap<String, Value> {
306        match self {
307            Self::Required | Self::Stale { current: None } => BTreeMap::new(),
308            Self::Invalid(reason) => {
309                BTreeMap::from([(REASON_DETAIL.to_owned(), Value::from(reason.reason()))])
310            }
311            Self::Stale {
312                current: Some(current),
313            } => BTreeMap::from([(
314                CURRENT_REVISION_DETAIL.to_owned(),
315                Value::from(current.get()),
316            )]),
317        }
318    }
319}
320
321impl From<InvalidIfMatch> for PreconditionError {
322    fn from(error: InvalidIfMatch) -> Self {
323        Self::Invalid(error)
324    }
325}
326
327impl fmt::Display for PreconditionError {
328    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
329        write!(formatter, "{} ({})", self.message(), self.code())
330    }
331}
332
333impl StdError for PreconditionError {}
334
335impl From<PreconditionError> for ApiError {
336    fn from(error: PreconditionError) -> Self {
337        Self::new(error.status(), error.code(), error.message()).with_details(error.details())
338    }
339}
340
341/// Compares the revision a client sent with the stored one.
342///
343/// A mismatch returns [`PreconditionError::Stale`] carrying `current`, which becomes 412
344/// `precondition_failed` with `details.currentRevision`.
345pub fn ensure_current_revision(
346    expected: Revision,
347    current: Revision,
348) -> Result<(), PreconditionError> {
349    if expected == current {
350        return Ok(());
351    }
352    Err(PreconditionError::Stale {
353        current: Some(current),
354    })
355}
356
357const fn is_valid_prefix(prefix: &str) -> bool {
358    let bytes = prefix.as_bytes();
359    if bytes.len() > MAX_ETAG_PREFIX_BYTES {
360        return false;
361    }
362    let mut index = 0;
363    while index < bytes.len() {
364        let byte = bytes[index];
365        if !(byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')) {
366            return false;
367        }
368        index += 1;
369    }
370    true
371}
372
373fn visible_ascii(bytes: &[u8]) -> Result<&str, InvalidIfMatch> {
374    if !bytes
375        .iter()
376        .all(|byte| byte.is_ascii_graphic() || matches!(byte, b' ' | b'\t'))
377    {
378        return Err(InvalidIfMatch::NonAscii);
379    }
380    std::str::from_utf8(bytes).map_err(|_| InvalidIfMatch::NonAscii)
381}
382
383fn single_entity_tag(tag: &str) -> Result<(bool, &str), InvalidIfMatch> {
384    let (weak, quoted) = tag
385        .strip_prefix(WEAK_INDICATOR)
386        .map_or((false, tag), |rest| (true, rest));
387    let body = quoted
388        .strip_prefix(char::from(QUOTE))
389        .ok_or(InvalidIfMatch::Malformed)?;
390    let end = body
391        .bytes()
392        .position(|byte| byte == QUOTE)
393        .ok_or(InvalidIfMatch::Malformed)?;
394    let (opaque, rest) = (&body[..end], &body[end + 1..]);
395    let rest = rest.trim_start_matches([' ', '\t']);
396    if rest.bytes().next() == Some(LIST_SEPARATOR) {
397        return Err(InvalidIfMatch::List);
398    }
399    if !rest.is_empty() || opaque.contains([' ', '\t']) {
400        return Err(InvalidIfMatch::Malformed);
401    }
402    Ok((weak, opaque))
403}
404
405fn parse_revision(digits: &str) -> Result<Revision, InvalidIfMatch> {
406    let canonical = !digits.is_empty()
407        && digits.bytes().all(|byte| byte.is_ascii_digit())
408        && (digits == "0" || !digits.starts_with('0'));
409    if !canonical {
410        return Err(InvalidIfMatch::InvalidRevision);
411    }
412    digits
413        .parse::<u64>()
414        .ok()
415        .and_then(|value| Revision::try_from(value).ok())
416        .ok_or(InvalidIfMatch::RevisionOutOfRange)
417}
418
419#[cfg(test)]
420mod tests;