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 | |
| 15 | use 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)] |
| 20 | pub struct DataUrl { |
| 21 | pub media_type: String, // `text/plain` where the URL named none |
| 22 | pub bytes: Vec<u8>, // decoded |
| 23 | } |
| 24 | |
| 25 | impl 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. |
| 59 | pub 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)] |
| 114 | mod 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 | } |