Skip to main content

anodizer_core/
elf.rs

1//! Minimal, dependency-free ELF inspection.
2//!
3//! Pure file reading only (no subprocess), so it is module-boundary-legal to
4//! live in core. The single probe here detects whether a binary is
5//! dynamically linked by parsing just enough of the ELF header and program
6//! headers to find a `PT_INTERP` segment.
7
8use std::path::Path;
9
10/// Little-/big-endian aware 2-byte read (shared by the path- and slice-based
11/// probes).
12fn read_u16(b: &[u8], is_le: bool) -> u16 {
13    if is_le {
14        u16::from_le_bytes([b[0], b[1]])
15    } else {
16        u16::from_be_bytes([b[0], b[1]])
17    }
18}
19
20/// Little-/big-endian aware 4-byte read.
21fn read_u32(b: &[u8], is_le: bool) -> u32 {
22    if is_le {
23        u32::from_le_bytes([b[0], b[1], b[2], b[3]])
24    } else {
25        u32::from_be_bytes([b[0], b[1], b[2], b[3]])
26    }
27}
28
29/// Little-/big-endian aware 8-byte read.
30fn read_u64(b: &[u8], is_le: bool) -> u64 {
31    if is_le {
32        u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
33    } else {
34        u64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
35    }
36}
37
38/// Extract `(program-header offset, entry size, count)` from a parsed ELF header
39/// prefix, keyed by ELF class. `hdr` must already be verified long enough for
40/// the class (≥ 58 bytes for 64-bit, ≥ 46 for 32-bit) — both probes gate on
41/// that before calling. Shared by the path- and slice-based probes so the field
42/// offsets live in exactly one place.
43fn ph_locator(hdr: &[u8], is_64bit: bool, is_le: bool) -> (u64, u16, u16) {
44    if is_64bit {
45        // 64-bit ELF: e_phoff at 32 (8 bytes), e_phentsize at 54, e_phnum at 56.
46        (
47            read_u64(&hdr[32..40], is_le),
48            read_u16(&hdr[54..56], is_le),
49            read_u16(&hdr[56..58], is_le),
50        )
51    } else {
52        // 32-bit ELF: e_phoff at 28 (4 bytes), e_phentsize at 42, e_phnum at 44.
53        (
54            read_u32(&hdr[28..32], is_le) as u64,
55            read_u16(&hdr[42..44], is_le),
56            read_u16(&hdr[44..46], is_le),
57        )
58    }
59}
60
61/// Byte-slice analogue of [`is_dynamically_linked`]: detect a `PT_INTERP`
62/// segment in an ELF image already held in memory.
63///
64/// Returns `false` for a non-ELF, an image too short to hold the program-header
65/// table for its own class, a program-header table pointing past the slice, or
66/// a statically linked ELF; `true` only when a `PT_INTERP` segment is present.
67///
68/// A caller that already holds the binary's bytes (e.g. the PyPI wheel builder,
69/// which loads each artifact to hash and repackage it) uses this rather than
70/// re-reading the file through [`is_dynamically_linked`]. All indexing is
71/// bounds-checked, so a malformed image yields `false`, never a panic.
72pub fn is_dynamically_linked_bytes(bytes: &[u8]) -> bool {
73    // Need the class/endian bytes (4, 5) plus the ELF magic.
74    if bytes.len() < 6 || &bytes[0..4] != b"\x7fELF" {
75        return false;
76    }
77    let is_64bit = bytes[4] == 2;
78    let is_le = bytes[5] == 1; // 1 = little-endian, 2 = big-endian
79
80    // A file too short to hold the program-header fields for its own class is
81    // not an ELF we can inspect — a 32-bit header ends at byte 46, a 64-bit
82    // one at 58.
83    let min_len = if is_64bit { 58 } else { 46 };
84    if bytes.len() < min_len {
85        return false;
86    }
87
88    let (ph_offset, ph_entry_size, ph_count) = ph_locator(bytes, is_64bit, is_le);
89    if ph_count == 0 || ph_entry_size == 0 {
90        return false;
91    }
92    let (ph_offset, ph_entry_size) = (ph_offset as usize, ph_entry_size as usize);
93
94    const PT_INTERP: u32 = 3;
95    for i in 0..ph_count as usize {
96        // Compute the entry's byte range with checked arithmetic: a malformed
97        // header can carry an absurd ph_offset that overflows `usize` (a
98        // debug-build panic). Any overflow means the table lies outside the
99        // image — stop, reporting "not dynamically linked". A range past the
100        // slice we hold is likewise a truncated/malformed image.
101        let field = i
102            .checked_mul(ph_entry_size)
103            .and_then(|rel| rel.checked_add(ph_offset))
104            .and_then(|start| start.checked_add(4).map(|end| start..end))
105            .and_then(|range| bytes.get(range));
106        let Some(field) = field else {
107            return false;
108        };
109        if read_u32(field, is_le) == PT_INTERP {
110            return true;
111        }
112    }
113    false
114}
115
116/// Check whether the binary at `path` is dynamically linked by reading its ELF
117/// program headers and looking for a `PT_INTERP` segment (type 3, the dynamic
118/// linker).
119///
120/// Returns `Ok(false)` for a genuinely-absent path, a non-ELF file (macOS
121/// Mach-O, Windows PE, or random bytes), a file too short to contain the header
122/// fields for its own ELF class, or a statically linked ELF; `Ok(true)` when a
123/// `PT_INTERP` segment is present.
124///
125/// Returns `Err` when a file that *does* exist cannot be read — an open failure
126/// other than not-found, or a short/failed read of a confirmed ELF. A binary a
127/// caller cannot inspect is a defect that must surface, never silently
128/// "statically linked": swallowing it would, for example, drop
129/// `autoPatchelfHook` from a Nix derivation and ship a broken install.
130pub fn is_dynamically_linked(path: &Path) -> std::io::Result<bool> {
131    use std::io::Read;
132    let mut file = match std::fs::File::open(path) {
133        Ok(f) => f,
134        // A genuinely-absent path is not our concern (callers guard on
135        // `.exists()` / only feed registered artifacts); any OTHER open
136        // failure on a file we were asked to inspect is a real error, not
137        // "statically linked".
138        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
139        Err(e) => return Err(e),
140    };
141
142    // Read up to a full 64-bit ELF header. `read` may return fewer bytes than
143    // requested; the buffer stays zero-padded beyond what was read, so the
144    // magic check below rejects anything that could not supply the first 4
145    // bytes. A read *error* (not a short read) is a defect and propagates.
146    let mut buf = [0u8; 64];
147    let n = file.read(&mut buf)?;
148
149    // Verify ELF magic: 0x7f 'E' 'L' 'F'. A read shorter than 4 bytes leaves
150    // zero-padding here and cannot match.
151    if &buf[0..4] != b"\x7fELF" {
152        return Ok(false);
153    }
154
155    let is_64bit = buf[4] == 2;
156    let is_le = buf[5] == 1; // 1 = little-endian, 2 = big-endian
157
158    // The header fields we parse differ by ELF class: a 32-bit header is read
159    // up to byte 46 (`e_phnum`), a 64-bit header up to byte 58. A file too
160    // short to hold the fields for its own class is not an ELF we can inspect;
161    // treat it as "not dynamically linked" rather than misreading zero-padding
162    // as real header data. (A valid 32-bit header is 52 bytes, a 64-bit header
163    // 64 — real binaries always satisfy this; only truncated inputs fail it.)
164    let min_len = if is_64bit { 58 } else { 46 };
165    if n < min_len {
166        return Ok(false);
167    }
168
169    // Parse program header offset, entry size, and count.
170    let (ph_offset, ph_entry_size, ph_count) = ph_locator(&buf, is_64bit, is_le);
171    let ph_entry_size = ph_entry_size as u64;
172
173    if ph_count == 0 || ph_entry_size == 0 {
174        return Ok(false);
175    }
176
177    // Read all program headers. A confirmed-ELF header pointing at program
178    // headers we cannot seek/read is a corrupt or truncated artifact — a defect
179    // that propagates rather than masquerading as "statically linked".
180    let total_size = ph_entry_size * ph_count as u64;
181    let mut ph_buf = vec![0u8; total_size as usize];
182    use std::io::Seek;
183    file.seek(std::io::SeekFrom::Start(ph_offset))?;
184    file.read_exact(&mut ph_buf)?;
185
186    // Scan for PT_INTERP (type 3), the presence of a dynamic linker.
187    const PT_INTERP: u32 = 3;
188    for i in 0..ph_count as usize {
189        let entry_start = i * ph_entry_size as usize;
190        let p_type = read_u32(&ph_buf[entry_start..entry_start + 4], is_le);
191        if p_type == PT_INTERP {
192            return Ok(true);
193        }
194    }
195
196    Ok(false)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{is_dynamically_linked, is_dynamically_linked_bytes};
202
203    /// Build a minimal 64-bit LE ELF image with a single program header of the
204    /// given `p_type`, wholly in memory (no file). Mirrors the on-disk fixtures
205    /// used by the path-based tests.
206    fn elf64_one_phdr(p_type: u32) -> Vec<u8> {
207        let phoff: u64 = 64;
208        let phentsize: u16 = 56;
209        let phnum: u16 = 1;
210        let mut bytes = Vec::with_capacity(64 + phentsize as usize);
211        bytes.extend_from_slice(b"\x7fELF");
212        bytes.push(2); // 64-bit
213        bytes.push(1); // little-endian
214        bytes.push(1); // EI_VERSION
215        bytes.extend_from_slice(&[0u8; 9]);
216        bytes.extend_from_slice(&[0u8; 2]); // e_type
217        bytes.extend_from_slice(&[0u8; 2]); // e_machine
218        bytes.extend_from_slice(&[0u8; 4]); // e_version
219        bytes.extend_from_slice(&[0u8; 8]); // e_entry
220        bytes.extend_from_slice(&phoff.to_le_bytes()); // e_phoff
221        bytes.extend_from_slice(&[0u8; 8]); // e_shoff
222        bytes.extend_from_slice(&[0u8; 4]); // e_flags
223        bytes.extend_from_slice(&[0u8; 2]); // e_ehsize
224        bytes.extend_from_slice(&phentsize.to_le_bytes()); // e_phentsize
225        bytes.extend_from_slice(&phnum.to_le_bytes()); // e_phnum
226        bytes.extend_from_slice(&[0u8; 6]); // pad to 64
227        bytes.extend_from_slice(&p_type.to_le_bytes()); // p_type
228        bytes.extend_from_slice(&vec![0u8; phentsize as usize - 4]);
229        bytes
230    }
231
232    /// The in-memory probe detects `PT_INTERP` (dynamic) and its absence
233    /// (static), agreeing with the path-based probe on the same bytes.
234    #[test]
235    fn bytes_probe_detects_dynamic_and_static() {
236        assert!(is_dynamically_linked_bytes(&elf64_one_phdr(3))); // PT_INTERP
237        assert!(!is_dynamically_linked_bytes(&elf64_one_phdr(1))); // PT_LOAD only
238    }
239
240    /// Non-ELF and truncated images are "not dynamically linked", never a panic.
241    #[test]
242    fn bytes_probe_rejects_non_elf_and_truncated() {
243        assert!(!is_dynamically_linked_bytes(b"not an ELF"));
244        assert!(!is_dynamically_linked_bytes(b""));
245        // Valid header but the program-header table is truncated away.
246        let mut short = elf64_one_phdr(3);
247        short.truncate(66); // header + 2 bytes of the phdr — p_type field cut off
248        assert!(!is_dynamically_linked_bytes(&short));
249    }
250
251    /// A malformed header with an absurd `e_phoff` (all-ones) must not panic on
252    /// the `usize` offset arithmetic — the checked math reports "not dynamically
253    /// linked" instead of overflowing.
254    #[test]
255    fn bytes_probe_absurd_ph_offset_does_not_panic() {
256        let mut evil = elf64_one_phdr(3);
257        // e_phoff lives at bytes 32..40; set it to u64::MAX.
258        evil[32..40].copy_from_slice(&u64::MAX.to_le_bytes());
259        assert!(!is_dynamically_linked_bytes(&evil));
260    }
261
262    /// A genuinely-absent path is `Ok(false)` (open() NotFound), never an
263    /// error: callers guard on existence or only feed registered artifacts.
264    #[test]
265    fn missing_file_returns_false() {
266        assert!(
267            !is_dynamically_linked(std::path::Path::new(
268                "/nonexistent/path/to/binary/that/cannot/exist"
269            ))
270            .expect("a missing file is Ok(false), not an error")
271        );
272    }
273
274    /// A file too short to hold an ELF header returns false (magic fails).
275    #[test]
276    fn short_file_returns_false() {
277        let tmp = tempfile::tempdir().unwrap();
278        let p = tmp.path().join("tiny");
279        std::fs::write(&p, b"abc").unwrap();
280        assert!(!is_dynamically_linked(&p).unwrap());
281    }
282
283    /// A path that EXISTS but errors on read is a real defect, not `Ok(false)`:
284    /// a directory opens as a File on Unix but errors (EISDIR) on read. A
285    /// silent `false` here would mask a build artifact we merely failed to
286    /// inspect and, e.g., ship a broken `nix` install.
287    #[test]
288    #[cfg(unix)]
289    fn unreadable_path_is_error() {
290        let tmp = tempfile::tempdir().unwrap();
291        assert!(is_dynamically_linked(tmp.path()).is_err());
292    }
293
294    /// A file without ELF magic (Mach-O / PE / random bytes) returns false.
295    #[test]
296    fn non_elf_returns_false() {
297        let tmp = tempfile::tempdir().unwrap();
298        let p = tmp.path().join("not-elf");
299        // 64 bytes of nonzero non-ELF data.
300        let bytes: Vec<u8> = (0..64u8).collect();
301        std::fs::write(&p, bytes).unwrap();
302        assert!(!is_dynamically_linked(&p).unwrap());
303    }
304
305    /// Hand-rolled minimal 64-bit ELF with a single PT_INTERP program header
306    /// returns true.
307    #[test]
308    fn elf64_with_pt_interp_returns_true() {
309        let tmp = tempfile::tempdir().unwrap();
310        let p = tmp.path().join("elf64-dyn");
311        // 64-byte ELF header followed by one 56-byte program header, p_type=3.
312        let phoff: u64 = 64;
313        let phentsize: u16 = 56;
314        let phnum: u16 = 1;
315        let mut bytes = Vec::with_capacity(64 + phentsize as usize);
316        bytes.extend_from_slice(b"\x7fELF"); // magic
317        bytes.push(2); // 64-bit
318        bytes.push(1); // little-endian
319        bytes.push(1); // EI_VERSION
320        bytes.extend_from_slice(&[0u8; 9]); // OSABI + padding
321        bytes.extend_from_slice(&[0u8; 2]); // e_type
322        bytes.extend_from_slice(&[0u8; 2]); // e_machine
323        bytes.extend_from_slice(&[0u8; 4]); // e_version
324        bytes.extend_from_slice(&[0u8; 8]); // e_entry
325        bytes.extend_from_slice(&phoff.to_le_bytes()); // e_phoff (32..40)
326        bytes.extend_from_slice(&[0u8; 8]); // e_shoff
327        bytes.extend_from_slice(&[0u8; 4]); // e_flags
328        bytes.extend_from_slice(&[0u8; 2]); // e_ehsize
329        bytes.extend_from_slice(&phentsize.to_le_bytes()); // e_phentsize (54..56)
330        bytes.extend_from_slice(&phnum.to_le_bytes()); // e_phnum (56..58)
331        bytes.extend_from_slice(&[0u8; 6]); // pad to 64
332        debug_assert_eq!(bytes.len(), 64);
333        // Program header: p_type=3 (PT_INTERP), 4-byte LE.
334        bytes.extend_from_slice(&3u32.to_le_bytes());
335        bytes.extend_from_slice(&vec![0u8; phentsize as usize - 4]);
336        std::fs::write(&p, &bytes).unwrap();
337        assert!(
338            is_dynamically_linked(&p).unwrap(),
339            "PT_INTERP must be detected"
340        );
341    }
342
343    /// 64-bit ELF whose only program header is PT_LOAD (1) returns false — the
344    /// file is statically linked.
345    #[test]
346    fn elf64_without_pt_interp_returns_false() {
347        let tmp = tempfile::tempdir().unwrap();
348        let p = tmp.path().join("elf64-static");
349        let phoff: u64 = 64;
350        let phentsize: u16 = 56;
351        let phnum: u16 = 1;
352        let mut bytes = Vec::with_capacity(64 + phentsize as usize);
353        bytes.extend_from_slice(b"\x7fELF");
354        bytes.push(2);
355        bytes.push(1);
356        bytes.push(1);
357        bytes.extend_from_slice(&[0u8; 9]);
358        bytes.extend_from_slice(&[0u8; 2]);
359        bytes.extend_from_slice(&[0u8; 2]);
360        bytes.extend_from_slice(&[0u8; 4]);
361        bytes.extend_from_slice(&[0u8; 8]);
362        bytes.extend_from_slice(&phoff.to_le_bytes());
363        bytes.extend_from_slice(&[0u8; 8]);
364        bytes.extend_from_slice(&[0u8; 4]);
365        bytes.extend_from_slice(&[0u8; 2]);
366        bytes.extend_from_slice(&phentsize.to_le_bytes());
367        bytes.extend_from_slice(&phnum.to_le_bytes());
368        bytes.extend_from_slice(&[0u8; 6]);
369        debug_assert_eq!(bytes.len(), 64);
370        // p_type = 1 (PT_LOAD), not 3.
371        bytes.extend_from_slice(&1u32.to_le_bytes());
372        bytes.extend_from_slice(&vec![0u8; phentsize as usize - 4]);
373        std::fs::write(&p, &bytes).unwrap();
374        assert!(!is_dynamically_linked(&p).unwrap());
375    }
376
377    /// 32-bit ELF with PT_INTERP returns true — pins the `is_64bit=false`
378    /// branch (phoff/phnum read from 32-bit offsets). The header is exactly 52
379    /// bytes, proving the class-aware min-length gate admits a valid 32-bit
380    /// ELF that the old 64-byte gate would have wrongly rejected.
381    #[test]
382    fn elf32_with_pt_interp_returns_true() {
383        let tmp = tempfile::tempdir().unwrap();
384        let p = tmp.path().join("elf32-dyn");
385        // For 32-bit ELF: e_entry is 4 bytes (24..28), e_phoff 4 bytes at
386        // 28..32, e_phentsize at 42..44, e_phnum at 44..46.
387        let phoff: u32 = 52;
388        let phentsize: u16 = 32;
389        let phnum: u16 = 1;
390        let mut bytes = Vec::with_capacity(52 + phentsize as usize);
391        bytes.extend_from_slice(b"\x7fELF"); // 0..4
392        bytes.push(1); // 32-bit class (4)
393        bytes.push(1); // little-endian (5)
394        bytes.push(1); // EI_VERSION (6)
395        bytes.extend_from_slice(&[0u8; 9]); // osabi + padding (7..16)
396        bytes.extend_from_slice(&[0u8; 2]); // e_type (16..18)
397        bytes.extend_from_slice(&[0u8; 2]); // e_machine (18..20)
398        bytes.extend_from_slice(&[0u8; 4]); // e_version (20..24)
399        bytes.extend_from_slice(&[0u8; 4]); // e_entry — 32-bit is 4 bytes (24..28)
400        bytes.extend_from_slice(&phoff.to_le_bytes()); // e_phoff (28..32)
401        bytes.extend_from_slice(&[0u8; 4]); // e_shoff (32..36)
402        bytes.extend_from_slice(&[0u8; 4]); // e_flags (36..40)
403        bytes.extend_from_slice(&[0u8; 2]); // e_ehsize (40..42)
404        bytes.extend_from_slice(&phentsize.to_le_bytes()); // e_phentsize (42..44)
405        bytes.extend_from_slice(&phnum.to_le_bytes()); // e_phnum (44..46)
406        bytes.extend_from_slice(&[0u8; 6]); // pad to 52
407        debug_assert_eq!(bytes.len(), 52);
408        bytes.extend_from_slice(&3u32.to_le_bytes()); // PT_INTERP
409        bytes.extend_from_slice(&vec![0u8; phentsize as usize - 4]);
410        std::fs::write(&p, &bytes).unwrap();
411        assert!(is_dynamically_linked(&p).unwrap());
412    }
413
414    /// Big-endian ELF with PT_INTERP returns true — exercises the
415    /// `is_le=false` branches of the byte readers.
416    #[test]
417    fn elf64_big_endian_with_pt_interp_returns_true() {
418        let tmp = tempfile::tempdir().unwrap();
419        let p = tmp.path().join("elf64-be-dyn");
420        let phoff: u64 = 64;
421        let phentsize: u16 = 56;
422        let phnum: u16 = 1;
423        let mut bytes = Vec::with_capacity(64 + phentsize as usize);
424        bytes.extend_from_slice(b"\x7fELF");
425        bytes.push(2);
426        bytes.push(2); // big-endian
427        bytes.push(1);
428        bytes.extend_from_slice(&[0u8; 9]);
429        bytes.extend_from_slice(&[0u8; 2]);
430        bytes.extend_from_slice(&[0u8; 2]);
431        bytes.extend_from_slice(&[0u8; 4]);
432        bytes.extend_from_slice(&[0u8; 8]);
433        bytes.extend_from_slice(&phoff.to_be_bytes());
434        bytes.extend_from_slice(&[0u8; 8]);
435        bytes.extend_from_slice(&[0u8; 4]);
436        bytes.extend_from_slice(&[0u8; 2]);
437        bytes.extend_from_slice(&phentsize.to_be_bytes());
438        bytes.extend_from_slice(&phnum.to_be_bytes());
439        bytes.extend_from_slice(&[0u8; 6]);
440        debug_assert_eq!(bytes.len(), 64);
441        bytes.extend_from_slice(&3u32.to_be_bytes());
442        bytes.extend_from_slice(&vec![0u8; phentsize as usize - 4]);
443        std::fs::write(&p, &bytes).unwrap();
444        assert!(is_dynamically_linked(&p).unwrap());
445    }
446}