objectstore_types/headers.rs
1//! Escaping for free-form values carried in HTTP headers.
2//!
3//! HTTP header values carry only visible ASCII, so any value that may contain arbitrary Unicode —
4//! an object key, a filename, a custom metadata value — has to be escaped to survive the
5//! transport. This module owns that escaping for the whole workspace: it is the only place that
6//! depends on [`percent_encoding`], so callers escape by name rather than by assembling a
7//! character set of their own.
8//!
9//! Encoding is a property of the *transport*, never of the value: everything in memory holds the
10//! logical string, and [`encode_header_value`] is applied only when writing a header.
11
12use std::borrow::Cow;
13use std::fmt;
14use std::str::Utf8Error;
15
16use http::HeaderValue;
17use http::header::ToStrError;
18use percent_encoding::{
19 AsciiSet, CONTROLS, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode,
20};
21
22/// The characters escaped when a free-form value is written into a header value.
23///
24/// Non-ASCII bytes are escaped by the encoder itself; this set adds the C0 controls and `DEL`,
25/// which a header value cannot carry, plus `%` so that a literal percent sign can never be
26/// confused with an escape sequence when decoding.
27///
28/// Every other visible ASCII character is left alone, which keeps values that are already plain
29/// ASCII byte-identical to their logical form on the wire.
30const HEADER_ESCAPE: &AsciiSet = &CONTROLS.add(b'%');
31
32/// The characters escaped in an [RFC 8187] `ext-value`.
33///
34/// This is the complement of the spec's `attr-char` production: alphanumerics plus a handful of
35/// symbols survive, everything else — including the non-ASCII bytes this exists for — is escaped.
36///
37/// [RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187
38const EXT_VALUE_ESCAPE: &AsciiSet = &NON_ALPHANUMERIC
39 .remove(b'!')
40 .remove(b'#')
41 .remove(b'$')
42 .remove(b'&')
43 .remove(b'+')
44 .remove(b'-')
45 .remove(b'.')
46 .remove(b'^')
47 .remove(b'_')
48 .remove(b'`')
49 .remove(b'|')
50 .remove(b'~');
51
52/// Escapes a logical string into a header value.
53///
54/// This is the inverse of [`decode_header_value`]. Escaping cannot fail — the result is always
55/// visible ASCII — so this returns the [`HeaderValue`] directly rather than a string a caller has
56/// to parse and handle the impossible error of.
57///
58/// # Examples
59///
60/// ```
61/// use objectstore_types::headers::encode_header_value;
62///
63/// assert_eq!(encode_header_value("report.pdf"), "report.pdf");
64/// assert_eq!(encode_header_value("réport.pdf"), "r%C3%A9port.pdf");
65/// assert_eq!(encode_header_value("100% done"), "100%25 done");
66/// ```
67pub fn encode_header_value(value: &str) -> HeaderValue {
68 // INVARIANT: `HEADER_ESCAPE` escapes every byte a header value cannot carry — the controls,
69 // `DEL`, and everything non-ASCII — so what is left is always visible ASCII.
70 HeaderValue::from_str(&encode_header_str(value))
71 .expect("escaped value is always a valid header value")
72}
73
74/// Escapes a logical string for a transport that is not an HTTP header.
75///
76/// Use this where the escaped form is needed as a string rather than a header — notably GCS object
77/// metadata, which is written as JSON but has to match what the `x-goog-meta-*` headers carry.
78/// Where the target *is* a header, prefer [`encode_header_value`].
79///
80/// Values that are already plain ASCII are returned borrowed and unchanged.
81///
82/// # Examples
83///
84/// ```
85/// use objectstore_types::headers::encode_header_str;
86///
87/// assert_eq!(encode_header_str("report.pdf"), "report.pdf");
88/// assert_eq!(encode_header_str("réport.pdf"), "r%C3%A9port.pdf");
89/// ```
90pub fn encode_header_str(value: &str) -> Cow<'_, str> {
91 utf8_percent_encode(value, HEADER_ESCAPE).into()
92}
93
94/// The reasons a header value can fail to decode into a logical string.
95#[derive(Debug, thiserror::Error)]
96pub enum DecodeError {
97 /// The raw header value contained bytes outside visible ASCII.
98 ///
99 /// A conforming writer escapes those, so this means the value was not written by one.
100 #[error("header value is not visible ASCII")]
101 NotAscii(#[from] ToStrError),
102
103 /// The escape sequences did not decode to valid UTF-8.
104 #[error("header value is not valid percent-encoded UTF-8")]
105 InvalidUtf8(#[from] Utf8Error),
106}
107
108/// Decodes a header value into the logical string it carries.
109///
110/// This is the inverse of [`encode_header_value`], and the counterpart most callers want: it
111/// covers both ways a raw header can fail to be a logical string, so there is no separate
112/// [`HeaderValue::to_str`] step to handle. Decoding does not depend on how aggressively the writer
113/// escaped, so values written by older peers — or with a different escape set — read back
114/// unchanged.
115///
116/// Callers are expected to wrap the error in one of their own that names the header at fault.
117///
118/// # Examples
119///
120/// ```
121/// use http::HeaderValue;
122/// use objectstore_types::headers::decode_header_value;
123///
124/// let value = HeaderValue::from_static("r%C3%A9port.pdf");
125/// assert_eq!(decode_header_value(&value)?, "réport.pdf");
126/// # Ok::<(), objectstore_types::headers::DecodeError>(())
127/// ```
128pub fn decode_header_value(value: &HeaderValue) -> Result<String, DecodeError> {
129 Ok(decode_header_str(value.to_str()?)?)
130}
131
132/// Decodes an escaped string back into its logical form.
133///
134/// Use this for escaped values that do not arrive in an actual header — notably GCS object
135/// metadata, which is read back as JSON. Where the value *is* a header, prefer
136/// [`decode_header_value`], which also rejects raw bytes outside visible ASCII.
137///
138/// # Examples
139///
140/// ```
141/// use objectstore_types::headers::decode_header_str;
142///
143/// assert_eq!(decode_header_str("r%C3%A9port.pdf")?, "réport.pdf");
144/// assert_eq!(decode_header_str("100%25 done")?, "100% done");
145/// assert!(decode_header_str("%FF.pdf").is_err());
146/// # Ok::<(), std::str::Utf8Error>(())
147/// ```
148pub fn decode_header_str(value: &str) -> Result<String, Utf8Error> {
149 Ok(percent_decode_str(value).decode_utf8()?.into_owned())
150}
151
152/// A logical string wrapped for use as an [RFC 8187] `ext-value`, in header parameters like
153/// `Content-Disposition`'s `filename*`.
154///
155/// The string is escaped lazily as this is displayed, so a caller can write it straight into a
156/// header it is already building instead of allocating an intermediate string. The output includes
157/// the charset prefix, so it goes directly after the `=` of a header parameter.
158///
159/// Unlike [`encode_header_value`], this escapes everything outside a narrow `attr-char` set,
160/// because an `ext-value` sits inside a header parameter rather than spanning a whole value.
161///
162/// [RFC 8187]: https://www.rfc-editor.org/rfc/rfc8187
163///
164/// # Examples
165///
166/// ```
167/// use objectstore_types::headers::ExtValue;
168///
169/// assert_eq!(ExtValue("réport.pdf").to_string(), "UTF-8''r%C3%A9port.pdf");
170/// ```
171#[derive(Debug)]
172pub struct ExtValue<'a>(pub &'a str);
173
174impl fmt::Display for ExtValue<'_> {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 f.write_str("UTF-8''")?;
177 fmt::Display::fmt(&utf8_percent_encode(self.0, EXT_VALUE_ESCAPE), f)
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn encodes_non_ascii() {
187 assert_eq!(
188 encode_header_value("réport-📄.pdf"),
189 "r%C3%A9port-%F0%9F%93%84.pdf",
190 );
191 }
192
193 #[test]
194 fn encodes_percent() {
195 assert_eq!(encode_header_value("100% done"), "100%25 done");
196 }
197
198 #[test]
199 fn encodes_control_characters() {
200 assert_eq!(encode_header_value("a\r\nb\tc\x7f"), "a%0D%0Ab%09c%7F");
201 }
202
203 #[test]
204 fn encodes_to_a_borrowed_str_when_unchanged() {
205 std::assert_matches!(encode_header_str("report.pdf"), Cow::Borrowed(_));
206 std::assert_matches!(encode_header_str("réport.pdf"), Cow::Owned(_));
207 }
208
209 #[test]
210 fn leaves_visible_ascii_alone() {
211 // Must stay byte-identical so values written before this encoding existed are unaffected.
212 let value = r#"has"quote path/to.txt!$&'()*+,;=:@?<>[]{}|^`~#"#;
213 assert_eq!(encode_header_value(value), value);
214 }
215
216 #[test]
217 fn roundtrips() {
218 for value in [
219 "réport-📄.pdf",
220 "100% done",
221 "50%.pdf",
222 "plain.txt",
223 "a\r\nb",
224 "",
225 ] {
226 let encoded = encode_header_str(value);
227 assert!(encoded.is_ascii(), "{encoded} is not ascii");
228 assert_eq!(decode_header_str(&encoded).unwrap(), value);
229
230 assert_eq!(
231 decode_header_value(&encode_header_value(value)).unwrap(),
232 value,
233 );
234 }
235 }
236
237 #[test]
238 fn decodes_aggressively_escaped_values() {
239 // Decoding is independent of the writer's escape set, which is what makes it safe to
240 // change how much we escape without breaking peers.
241 assert_eq!(decode_header_str("%6B%65%79%2D%31").unwrap(), "key-1");
242 }
243
244 #[test]
245 fn decode_rejects_invalid_utf8() {
246 let header = HeaderValue::from_static("%FF.pdf");
247 std::assert_matches!(
248 decode_header_value(&header),
249 Err(DecodeError::InvalidUtf8(_)),
250 );
251 assert!(decode_header_str("%FF.pdf").is_err());
252 }
253
254 #[test]
255 fn decode_rejects_raw_non_ascii() {
256 // A conforming writer escapes these, so an unescaped byte means the value is malformed
257 // rather than merely unencoded.
258 let header = HeaderValue::from_bytes("réport.pdf".as_bytes()).unwrap();
259 std::assert_matches!(decode_header_value(&header), Err(DecodeError::NotAscii(_)),);
260 }
261
262 #[test]
263 fn ext_value_escapes_reserved_characters() {
264 assert_eq!(
265 ExtValue("réport 📄.pdf").to_string(),
266 "UTF-8''r%C3%A9port%20%F0%9F%93%84.pdf",
267 );
268 assert_eq!(ExtValue("a\"b;c").to_string(), "UTF-8''a%22b%3Bc");
269 }
270}