Skip to main content

running_process_platform_internal/platform/window_icon/
ico.rs

1//! Locating the best image inside an `.ico` blob (#577).
2//!
3//! # This parses untrusted input
4//!
5//! An icon supplied as bytes may be anything — a truncated download, a file
6//! that is not an icon at all, or something crafted. Every field that becomes
7//! an offset or a length is therefore checked against the actual buffer before
8//! use, and a blob that does not describe a valid image is *refused* rather
9//! than passed to the OS with a length the OS will trust.
10//!
11//! Handing `CreateIconFromResourceEx` an offset past the end of the buffer
12//! would have it read whatever follows in our address space.
13//!
14//! # Why we choose the image rather than the OS
15//!
16//! `LoadImage` picks from a file on disk; there is no equivalent that takes a
17//! whole `.ico` from memory. `CreateIconFromResourceEx` wants the bytes of one
18//! image, so the directory has to be walked to find which.
19
20/// Size of the `ICONDIR` header: reserved, type, count — 2 bytes each.
21const ICONDIR_LEN: usize = 6;
22/// Size of one `ICONDIRENTRY`.
23const ICONDIRENTRY_LEN: usize = 16;
24/// `ICONDIR::idType` for icons. 2 would be a cursor, which is a different
25/// thing wearing the same layout.
26const TYPE_ICON: u16 = 1;
27
28/// Why an icon blob could not be used.
29#[derive(Debug, PartialEq, Eq)]
30pub enum IcoError {
31    /// Too small to contain a directory, or truncated mid-entry.
32    Truncated,
33    /// Not an icon: bad reserved field or wrong type.
34    NotAnIcon,
35    /// The directory claims no images.
36    NoImages,
37    /// An entry's offset or length falls outside the buffer.
38    EntryOutOfBounds,
39}
40
41impl std::fmt::Display for IcoError {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        let text = match self {
44            Self::Truncated => "icon data is truncated",
45            Self::NotAnIcon => "data is not an icon (bad ICONDIR header)",
46            Self::NoImages => "icon directory contains no images",
47            Self::EntryOutOfBounds => "an icon entry points outside the supplied data",
48        };
49        f.write_str(text)
50    }
51}
52
53/// The byte range of the chosen image within the blob.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct ImageSpan {
56    /// Offset of the image data.
57    pub offset: usize,
58    /// Length of the image data.
59    pub len: usize,
60}
61
62fn u16_at(bytes: &[u8], at: usize) -> u16 {
63    u16::from_le_bytes([bytes[at], bytes[at + 1]])
64}
65
66fn u32_at(bytes: &[u8], at: usize) -> u32 {
67    u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]])
68}
69
70/// Find the largest image in an `.ico` blob.
71///
72/// Largest by pixel dimensions, then by colour depth. A window icon is scaled
73/// down by the OS, so starting from the biggest image gives the best result at
74/// whatever size is actually drawn; picking the first entry would often give a
75/// 16×16 that looks blurred in the taskbar.
76pub fn best_image(bytes: &[u8]) -> Result<ImageSpan, IcoError> {
77    if bytes.len() < ICONDIR_LEN {
78        return Err(IcoError::Truncated);
79    }
80    // idReserved must be 0 and idType must be 1. Checking both is what
81    // separates "an icon" from "any file that happens to start with 6 bytes".
82    if u16_at(bytes, 0) != 0 || u16_at(bytes, 2) != TYPE_ICON {
83        return Err(IcoError::NotAnIcon);
84    }
85    let count = u16_at(bytes, 4) as usize;
86    if count == 0 {
87        return Err(IcoError::NoImages);
88    }
89
90    // The directory itself must fit before any entry is read.
91    let directory_end = ICONDIR_LEN
92        .checked_add(
93            count
94                .checked_mul(ICONDIRENTRY_LEN)
95                .ok_or(IcoError::Truncated)?,
96        )
97        .ok_or(IcoError::Truncated)?;
98    if bytes.len() < directory_end {
99        return Err(IcoError::Truncated);
100    }
101
102    let mut best: Option<(u32, u16, ImageSpan)> = None;
103    for index in 0..count {
104        let entry = ICONDIR_LEN + index * ICONDIRENTRY_LEN;
105        // 0 in the width/height byte means 256 — the field is one byte and
106        // 256 does not fit. Treating it as 0 would rank the largest image
107        // last.
108        let width = match bytes[entry] {
109            0 => 256u32,
110            w => u32::from(w),
111        };
112        let height = match bytes[entry + 1] {
113            0 => 256u32,
114            h => u32::from(h),
115        };
116        let bit_count = u16_at(bytes, entry + 6);
117        let len = u32_at(bytes, entry + 8) as usize;
118        let offset = u32_at(bytes, entry + 12) as usize;
119
120        // Every entry is bounds-checked even though only one is used: a blob
121        // whose entries do not fit is malformed, and quietly skipping the bad
122        // ones would let a crafted file steer the choice.
123        let end = offset.checked_add(len).ok_or(IcoError::EntryOutOfBounds)?;
124        if len == 0 || end > bytes.len() || offset < directory_end {
125            return Err(IcoError::EntryOutOfBounds);
126        }
127
128        let pixels = width * height;
129        let candidate = (pixels, bit_count, ImageSpan { offset, len });
130        match &best {
131            Some((best_pixels, best_depth, _))
132                if (*best_pixels, *best_depth) >= (pixels, bit_count) => {}
133            _ => best = Some(candidate),
134        }
135    }
136
137    best.map(|(_, _, span)| span).ok_or(IcoError::NoImages)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    /// Build an `.ico` with `entries` of `(width, height, bit_count, payload)`.
145    fn ico(entries: &[(u8, u8, u16, &[u8])]) -> Vec<u8> {
146        let count = entries.len();
147        let mut out = Vec::new();
148        out.extend_from_slice(&0u16.to_le_bytes()); // reserved
149        out.extend_from_slice(&TYPE_ICON.to_le_bytes());
150        out.extend_from_slice(&(count as u16).to_le_bytes());
151
152        let mut offset = ICONDIR_LEN + count * ICONDIRENTRY_LEN;
153        let mut payloads = Vec::new();
154        for (w, h, bits, data) in entries {
155            out.push(*w);
156            out.push(*h);
157            out.push(0); // colour count
158            out.push(0); // reserved
159            out.extend_from_slice(&1u16.to_le_bytes()); // planes
160            out.extend_from_slice(&bits.to_le_bytes());
161            out.extend_from_slice(&(data.len() as u32).to_le_bytes());
162            out.extend_from_slice(&(offset as u32).to_le_bytes());
163            offset += data.len();
164            payloads.push(*data);
165        }
166        for data in payloads {
167            out.extend_from_slice(data);
168        }
169        out
170    }
171
172    #[test]
173    fn a_single_image_is_found() {
174        let bytes = ico(&[(32, 32, 32, b"IMAGEDATA")]);
175        let span = best_image(&bytes).expect("valid icon");
176        assert_eq!(&bytes[span.offset..span.offset + span.len], b"IMAGEDATA");
177    }
178
179    #[test]
180    fn the_largest_image_wins() {
181        let bytes = ico(&[
182            (16, 16, 32, b"small"),
183            (64, 64, 32, b"BIGGEST"),
184            (32, 32, 32, b"mid"),
185        ]);
186        let span = best_image(&bytes).unwrap();
187        assert_eq!(&bytes[span.offset..span.offset + span.len], b"BIGGEST");
188    }
189
190    /// A zero width/height byte means 256, not 0 — the field cannot hold 256.
191    #[test]
192    fn a_zero_dimension_means_256_and_therefore_wins() {
193        let bytes = ico(&[(64, 64, 32, b"sixtyfour"), (0, 0, 32, b"TWOFIFTYSIX")]);
194        let span = best_image(&bytes).unwrap();
195        assert_eq!(
196            &bytes[span.offset..span.offset + span.len],
197            b"TWOFIFTYSIX",
198            "a 0 dimension byte encodes 256 and should outrank 64x64"
199        );
200    }
201
202    #[test]
203    fn colour_depth_breaks_a_size_tie() {
204        let bytes = ico(&[(32, 32, 4, b"shallow"), (32, 32, 32, b"DEEPEST")]);
205        let span = best_image(&bytes).unwrap();
206        assert_eq!(&bytes[span.offset..span.offset + span.len], b"DEEPEST");
207    }
208
209    #[test]
210    fn empty_input_is_truncated_not_a_panic() {
211        assert_eq!(best_image(&[]), Err(IcoError::Truncated));
212        assert_eq!(best_image(&[0, 0, 1]), Err(IcoError::Truncated));
213    }
214
215    #[test]
216    fn a_non_icon_is_refused() {
217        // Right length, wrong type: this is a cursor.
218        let mut bytes = ico(&[(32, 32, 32, b"data")]);
219        bytes[2] = 2;
220        assert_eq!(best_image(&bytes), Err(IcoError::NotAnIcon));
221
222        // Non-zero reserved field.
223        let mut bytes = ico(&[(32, 32, 32, b"data")]);
224        bytes[0] = 9;
225        assert_eq!(best_image(&bytes), Err(IcoError::NotAnIcon));
226    }
227
228    #[test]
229    fn a_directory_claiming_no_images_is_refused() {
230        let bytes = ico(&[]);
231        assert_eq!(best_image(&bytes), Err(IcoError::NoImages));
232    }
233
234    /// The check that matters: a length running past the buffer must be
235    /// refused, not handed to the OS to read.
236    #[test]
237    fn an_entry_running_past_the_buffer_is_refused() {
238        let mut bytes = ico(&[(32, 32, 32, b"data")]);
239        let len_at = ICONDIR_LEN + 8;
240        bytes[len_at..len_at + 4].copy_from_slice(&0xFFFF_u32.to_le_bytes());
241        assert_eq!(best_image(&bytes), Err(IcoError::EntryOutOfBounds));
242    }
243
244    #[test]
245    fn an_entry_offset_past_the_buffer_is_refused() {
246        let mut bytes = ico(&[(32, 32, 32, b"data")]);
247        let offset_at = ICONDIR_LEN + 12;
248        bytes[offset_at..offset_at + 4].copy_from_slice(&0xFFFF_u32.to_le_bytes());
249        assert_eq!(best_image(&bytes), Err(IcoError::EntryOutOfBounds));
250    }
251
252    /// An offset pointing back into the directory would make the image
253    /// overlap its own metadata — malformed, and a way to confuse a decoder.
254    #[test]
255    fn an_offset_inside_the_directory_is_refused() {
256        let mut bytes = ico(&[(32, 32, 32, b"data")]);
257        let offset_at = ICONDIR_LEN + 12;
258        bytes[offset_at..offset_at + 4].copy_from_slice(&2u32.to_le_bytes());
259        assert_eq!(best_image(&bytes), Err(IcoError::EntryOutOfBounds));
260    }
261
262    #[test]
263    fn a_zero_length_entry_is_refused() {
264        let mut bytes = ico(&[(32, 32, 32, b"data")]);
265        let len_at = ICONDIR_LEN + 8;
266        bytes[len_at..len_at + 4].copy_from_slice(&0u32.to_le_bytes());
267        assert_eq!(best_image(&bytes), Err(IcoError::EntryOutOfBounds));
268    }
269
270    /// A count larger than the data is the classic overflow lure.
271    #[test]
272    fn a_count_exceeding_the_buffer_is_truncated() {
273        let mut bytes = ico(&[(32, 32, 32, b"data")]);
274        bytes[4..6].copy_from_slice(&1000u16.to_le_bytes());
275        assert_eq!(best_image(&bytes), Err(IcoError::Truncated));
276    }
277
278    /// Arbitrary bytes must never panic — this runs the parser over many
279    /// malformed inputs derived from a valid one.
280    #[test]
281    fn corrupted_icons_never_panic() {
282        let original = ico(&[(16, 16, 32, b"aa"), (32, 32, 32, b"bbbb")]);
283        for index in 0..original.len() {
284            for replacement in [0u8, 0xFF, 0x7F] {
285                let mut bytes = original.clone();
286                bytes[index] = replacement;
287                // Any outcome is acceptable; a panic is not.
288                let _ = best_image(&bytes);
289            }
290            // Truncation at every length, too.
291            let _ = best_image(&original[..index]);
292        }
293    }
294}