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
9pub 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub struct Revision(u64);
25
26impl Revision {
27 pub const MAX: Self = Self(i64::MAX.unsigned_abs());
29
30 #[must_use]
32 pub const fn get(self) -> u64 {
33 self.0
34 }
35
36 #[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#[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#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub struct RevisionEtag<'a> {
117 prefix: &'a str,
118}
119
120impl<'a> RevisionEtag<'a> {
121 #[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 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 #[must_use]
144 pub const fn prefix(&self) -> &'a str {
145 self.prefix
146 }
147
148 #[must_use]
150 pub fn format(&self, revision: Revision) -> String {
151 format!("\"{}{revision}\"", self.prefix)
152 }
153
154 #[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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209pub enum InvalidIfMatch {
210 RepeatedHeader,
212 Empty,
214 NonAscii,
216 Wildcard,
218 WeakValidator,
220 List,
222 Malformed,
224 PrefixMismatch,
226 InvalidRevision,
228 RevisionOutOfRange,
230}
231
232impl InvalidIfMatch {
233 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum PreconditionError {
265 Required,
267 Invalid(InvalidIfMatch),
269 Stale {
271 current: Option<Revision>,
273 },
274}
275
276impl PreconditionError {
277 #[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 #[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
341pub 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;