slipcase_open/content.rs
1//! The one content check, and everything it deliberately does not do.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6//! Concept 5.1 settles that policy keys on the extension, because that is what
7//! `ShellExecuteEx`, `open` and `xdg-open` resolve a handler from and none of
8//! them reads the bytes. Sniffing a content type and checking policy against it
9//! would be checking a value with no bearing on what executes.
10//!
11//! What survives is narrow and is not policy. It reports a content file whose
12//! bytes are an executable image or a script under a name that claims neither
13//! — the shape of a phishing attachment.
14//!
15//! **[`crate::flow`] refuses on it, and that is a veto rather than a control.**
16//! Nothing here permits anything: the allowlist decides what may be opened, and
17//! all this can do is say no to something it already allowed. So it is allowed
18//! to be narrow in a way a control could not be — a content file that is
19//! exactly what it claims and still hostile passes without comment, and that
20//! is not a gap in it, because it was never the thing standing in the way.
21//!
22//! **It is a handful of magic numbers and not a type table.** A `.docx`
23//! sniffing as a ZIP is noise and goes unmentioned, so nothing here has to tell
24//! OOXML from a bare archive, which is the problem that made the sniffing
25//! design collapse in the first place.
26
27/// What the bytes are, where they are something that runs.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum Executable {
31 /// `MZ`. A Windows executable image: `.exe`, `.dll`, and the rest.
32 Pe,
33 /// `\x7fELF`. A Linux or BSD executable or shared object.
34 Elf,
35 /// A Mach-O image, in either byte order and either width.
36 MachO,
37 /// `#!`. A script naming its own interpreter, which is what makes it run.
38 Script,
39}
40
41impl Executable {
42 /// What to call it in the sentence shown to a person.
43 #[must_use]
44 pub fn describes(self) -> &'static str {
45 match self {
46 Self::Pe => "a Windows executable",
47 Self::Elf => "a Linux executable",
48 Self::MachO => "a macOS executable",
49 Self::Script => "a script",
50 }
51 }
52}
53
54/// How many bytes of a content file this needs. Four for every magic number
55/// here, and two for a shebang.
56pub const HEAD: usize = 4;
57
58/// What the leading bytes are, where they are something that runs.
59///
60/// **`cafebabe` is missing on purpose.** It is a Mach-O universal binary and it
61/// is also a Java class file, and telling them apart means reading the field
62/// after it and deciding whether it is an architecture count or a version. A
63/// false positive here is a warning shown to somebody about a content file
64/// that is fine, which costs more than missing a fat binary — and a fat binary's
65/// members are Mach-O, so the single-architecture form is the common one and is
66/// caught.
67#[must_use]
68pub fn executable(head: &[u8]) -> Option<Executable> {
69 match head {
70 [b'M', b'Z', ..] => Some(Executable::Pe),
71 [0x7f, b'E', b'L', b'F', ..] => Some(Executable::Elf),
72 // Thin Mach-O. The last byte is the width — `ce` for 32-bit, `cf` for
73 // 64 — and the two arms are the two byte orders it can be written in.
74 [0xfe, 0xed, 0xfa, 0xce | 0xcf, ..] | [0xce | 0xcf, 0xfa, 0xed, 0xfe, ..] => {
75 Some(Executable::MachO)
76 }
77 [b'#', b'!', ..] => Some(Executable::Script),
78 _ => None,
79 }
80}
81
82/// Whether the content file is something that runs while its name says
83/// otherwise.
84///
85/// `None` where the bytes are not executable, and `None` where they are and the
86/// extension already says so — a `.exe` that is a PE image is not
87/// misrepresenting itself, whatever policy goes on to decide about it.
88///
89/// The extension is the folded one from [`crate::extension::policy_key`]. An
90/// extension too exotic to fold is not on the list below and so does not
91/// suppress the report, which is the safe direction: the content file is
92/// executable and the name says something nobody can compare.
93#[must_use]
94pub fn misrepresents(head: &[u8], policy_key: Option<&str>) -> Option<Executable> {
95 let what = executable(head)?;
96 match policy_key {
97 Some(k) if EXPECTED.contains(&k) => None,
98 _ => Some(what),
99 }
100}
101
102/// Extensions where executable content is what a person would expect.
103///
104/// Not a type table and not a policy list — nothing is permitted or refused by
105/// being here. It exists so that the warning does not fire on a content file
106/// that is exactly what its name says, and it is short because it only has to
107/// cover the names people actually use for things that run. An extension
108/// missing from it costs a warning shown about an honest content file, which
109/// is the direction to err in.
110const EXPECTED: &[&str] = &[
111 // Windows
112 "exe", "dll", "com", "scr", "sys", "cpl", "ocx", "drv", "efi", // Unix
113 "so", "o", "a", "bin", "elf", "ko", // macOS
114 "dylib", "bundle", // scripts, for the shebang arm
115 "sh", "bash", "zsh", "csh", "ksh", "fish", "py", "pl", "rb", "lua", "tcl", "awk", "sed", "r",
116 "ps1",
117];
118
119#[cfg(test)]
120mod tests {
121 use super::{executable, misrepresents, Executable};
122
123 #[test]
124 fn recognises_the_four_things_that_run() {
125 assert_eq!(executable(b"MZ\x90\x00"), Some(Executable::Pe));
126 assert_eq!(executable(b"\x7fELF"), Some(Executable::Elf));
127 assert_eq!(executable(b"\xcf\xfa\xed\xfe"), Some(Executable::MachO));
128 assert_eq!(executable(b"#!/bin/sh"), Some(Executable::Script));
129 }
130
131 #[test]
132 fn mach_o_is_recognised_in_both_orders_and_both_widths() {
133 for magic in [
134 b"\xfe\xed\xfa\xce",
135 b"\xce\xfa\xed\xfe",
136 b"\xfe\xed\xfa\xcf",
137 b"\xcf\xfa\xed\xfe",
138 ] {
139 assert_eq!(executable(magic), Some(Executable::MachO), "{magic:x?}");
140 }
141 }
142
143 #[test]
144 fn a_universal_binary_is_not_reported() {
145 // `cafebabe` is a Java class file too, and a warning shown about an
146 // honest content file costs more than missing a fat binary whose
147 // members are Mach-O anyway. See the note on `executable`.
148 assert_eq!(executable(b"\xca\xfe\xba\xbe"), None);
149 }
150
151 #[test]
152 fn a_pdf_is_not_something_that_runs() {
153 assert_eq!(executable(b"%PDF"), None);
154 }
155
156 #[test]
157 fn a_zip_is_not_something_that_runs() {
158 // The case that sank the sniffing design: this is a `.docx`, an `.odt`,
159 // a `.jar` and a bare archive, and nothing here has to know which.
160 assert_eq!(executable(b"PK\x03\x04"), None);
161 }
162
163 #[test]
164 fn short_input_answers_rather_than_panicking() {
165 // A zero-length content file is conformant under SPEC 2.3, and a one-byte
166 // one is a slice every pattern here is longer than.
167 assert_eq!(executable(b""), None);
168 assert_eq!(executable(b"M"), None);
169 assert_eq!(executable(b"\x7fEL"), None);
170 // Two bytes are enough for a shebang and not for the rest.
171 assert_eq!(executable(b"#!"), Some(Executable::Script));
172 }
173
174 #[test]
175 fn an_executable_wearing_a_documents_name_is_reported() {
176 assert_eq!(
177 misrepresents(b"MZ\x90\x00", Some("pdf")),
178 Some(Executable::Pe)
179 );
180 }
181
182 #[test]
183 fn an_executable_wearing_its_own_name_is_not() {
184 assert_eq!(misrepresents(b"MZ\x90\x00", Some("exe")), None);
185 assert_eq!(misrepresents(b"\x7fELF", Some("so")), None);
186 assert_eq!(misrepresents(b"#!/bin/sh", Some("sh")), None);
187 }
188
189 #[test]
190 fn a_document_is_never_reported_whatever_it_is_called() {
191 assert_eq!(misrepresents(b"%PDF", Some("pdf")), None);
192 assert_eq!(misrepresents(b"%PDF", Some("exe")), None);
193 assert_eq!(misrepresents(b"PK\x03\x04", Some("docx")), None);
194 }
195
196 #[test]
197 fn an_extension_too_exotic_to_fold_does_not_suppress_the_report() {
198 // `policy_key` answers `None` for one that is not ASCII alphanumeric.
199 // The content file is executable and the name says something nothing
200 // can compare, which is the case to report rather than the case to
201 // excuse.
202 assert_eq!(misrepresents(b"MZ\x90\x00", None), Some(Executable::Pe));
203 }
204}