Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/http/data_url.rs

7.1 KiB, 30 runs

created by r1870400018:16932, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The `data:` URL, as a browser hands one over.
2//!
3//! A page that lets someone choose a picture reads the file in the browser and gets back a `data:`
4//! URL: the media type, then the bytes, base64 in every practical case. Sending that string in an
5//! ordinary form field is how a small upload reaches a server without a multipart parser on either
6//! side, and it is the whole of what this module is for -- a picture, an icon, a signature, not a
7//! video.
8//!
9//! The size ceiling is the caller's and is checked against the decoded length, since base64 inflates
10//! by a third and a caller means the bytes it will store, not the string it was sent.
11//!
12//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
13//! Anthropic Claude
14
15use oxedyne_fe2o3_core::prelude::*;
16
17
18/// What a `data:` URL carries: what the bytes are, and the bytes.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct DataUrl {
21 pub media_type: String, // `text/plain` where the URL named none
22 pub bytes: Vec<u8>, // decoded
23}
24
25impl DataUrl {
26
27 /// Does the payload call itself an image of a type a browser draws everywhere?
28 ///
29 /// A caller storing a picture should ask this and refuse the rest: the media type is a claim by
30 /// whoever sent it, so it decides what will be served back with the bytes, and serving arbitrary
31 /// content under a type of its own choosing is how a picture becomes a page.
32 ///
33 /// **`image/svg+xml` is included, and it is a document, not a picture.** An SVG may hold script,
34 /// which runs as whatever origin serves it. A caller that stores one must serve it defanged --
35 /// `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff` --
36 /// or use [`is_raster_image`] instead and turn SVG away at the door.
37 pub fn is_web_image(&self) -> bool {
38 matches!(self.media_type.as_str(),
39 "image/png" | "image/jpeg" | "image/gif" | "image/webp" | "image/svg+xml")
40 }
41
42 /// Does the payload call itself an image made of pixels rather than of markup?
43 ///
44 /// The same question as [`is_web_image`](DataUrl::is_web_image) without SVG, for a caller that
45 /// would rather not think about serving a document it was handed. A raster carries no script and
46 /// no reference to anything else, so the worst a bad one does is fail to decode.
47 pub fn is_raster_image(&self) -> bool {
48 matches!(self.media_type.as_str(),
49 "image/png" | "image/jpeg" | "image/gif" | "image/webp")
50 }
51}
52
53/// Reads a `data:` URL, refusing one whose payload exceeds `max_bytes`.
54///
55/// The shape is `data:[<media type>][;base64],<payload>`. A URL that is not base64 is percent-encoded
56/// text, which is read too -- it costs a few lines and the syntax allows it. The ceiling is on the
57/// decoded bytes, and a string too long to be under it is refused before it is decoded, so an
58/// oversized payload is never held in memory twice.
59pub fn parse(s: &str, max_bytes: usize) -> Outcome<DataUrl> {
60 let rest = match s.strip_prefix("data:") {
61 Some(r) => r,
62 None => return Err(err!("A data URL begins with 'data:'."; Invalid, Input)),
63 };
64 let (meta, payload) = match rest.split_once(',') {
65 Some(p) => p,
66 None => return Err(err!(
67 "A data URL separates its media type from its payload with a comma.";
68 Invalid, Input, Missing)),
69 };
70 let b64 = meta.to_lowercase().ends_with(";base64");
71 let media_type = {
72 // Everything before the first parameter is the type; a charset or another parameter says
73 // nothing about bytes this reader hands on, so it is dropped rather than kept unread.
74 let head = if b64 { &meta[..meta.len() - ";base64".len()] } else { meta };
75 let t = head.split(';').next().unwrap_or("").trim().to_lowercase();
76 if t.is_empty() {
77 // The syntax's own default for a URL that named no type.
78 fmt!("text/plain")
79 } else {
80 t
81 }
82 };
83
84 // Four base64 characters carry three bytes, so the decoded length is known from the string's and
85 // an oversized payload is turned away before it is decoded.
86 let claimed = if b64 { payload.len() / 4 * 3 } else { payload.len() };
87 if claimed > max_bytes {
88 return Err(err!(
89 "A data URL of about {} bytes was sent, over the ceiling of {}.", claimed, max_bytes;
90 Invalid, Input, Size, TooBig));
91 }
92
93 let bytes = if b64 {
94 match base64::decode(payload.trim()) {
95 Ok(v) => v,
96 Err(e) => return Err(err!(e,
97 "The payload of a data URL is not base64.";
98 Invalid, Input, Decode)),
99 }
100 } else {
101 res!(crate::http::pct::decode(payload))
102 };
103 if bytes.len() > max_bytes {
104 return Err(err!(
105 "A data URL of {} bytes was sent, over the ceiling of {}.", bytes.len(), max_bytes;
106 Invalid, Input, Size, TooBig));
107 }
108 Ok(DataUrl { media_type, bytes })
109}
110
111
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 /// The ordinary case: what a browser hands back for a small picture.
118 #[test]
119 fn test_a_base64_picture_reads_00() -> Outcome<()> {
120 // A one-pixel GIF, which is the smallest real image there is.
121 let u = res!(parse("data:image/gif;base64,R0lGODlhAQABAAAAACw=", 1024));
122 assert_eq!(u.media_type, "image/gif");
123 assert_eq!(u.bytes.len(), 14);
124 assert!(u.is_web_image());
125 Ok(())
126 }
127
128 /// A URL naming no type is text, as the syntax says, and a parameter beside the type is dropped.
129 #[test]
130 fn test_a_typeless_url_is_text_01() -> Outcome<()> {
131 let u = res!(parse("data:,hello%20there", 1024));
132 assert_eq!(u.media_type, "text/plain");
133 assert_eq!(String::from_utf8_lossy(&u.bytes), "hello there");
134 let p = res!(parse("data:text/plain;charset=utf-8;base64,aGk=", 1024));
135 assert_eq!(p.media_type, "text/plain");
136 assert_eq!(String::from_utf8_lossy(&p.bytes), "hi");
137 assert!(!p.is_web_image());
138 Ok(())
139 }
140
141 /// A payload over the ceiling is refused, and refused on its decoded length rather than its
142 /// string's, so the third that base64 adds does not count against the caller.
143 #[test]
144 fn test_a_payload_over_the_ceiling_is_refused_02() -> Outcome<()> {
145 // Sixty bytes, which base64 writes in eighty characters.
146 let big = base64::encode(vec![7u8; 60]);
147 assert!(parse(&fmt!("data:image/png;base64,{}", big), 50).is_err(),
148 "an oversized payload was taken");
149 let ok = res!(parse(&fmt!("data:image/png;base64,{}", big), 64));
150 assert_eq!(ok.bytes.len(), 60);
151 Ok(())
152 }
153
154 /// An SVG is an image a browser draws and a document that may hold script, so the two questions
155 /// answer differently -- a caller that will not defang what it serves asks the narrower one.
156 #[test]
157 fn test_an_svg_is_an_image_but_not_a_raster_04() -> Outcome<()> {
158 let svg = res!(parse("data:image/svg+xml;base64,PHN2Zy8+", 1024));
159 assert!(svg.is_web_image(), "an SVG is an image a browser draws");
160 assert!(!svg.is_raster_image(), "an SVG is not made of pixels");
161 let png = res!(parse("data:image/png;base64,iVBORw0KGgo=", 1024));
162 assert!(png.is_raster_image(), "a PNG is made of pixels");
163 Ok(())
164 }
165
166 /// What is not a data URL says so, rather than being read as an empty one.
167 #[test]
168 fn test_what_is_not_a_data_url_is_refused_03() -> Outcome<()> {
169 assert!(parse("https://example.com/a.png", 1024).is_err(), "a plain URL was taken");
170 assert!(parse("data:image/png;base64", 1024).is_err(), "a URL with no comma was taken");
171 assert!(parse("data:image/png;base64,!!!not base64!!!", 1024).is_err(),
172 "a bad payload was taken");
173 Ok(())
174 }
175}