OXYGENHUB

Coverage / crates/jet/src/rootfs.rs 85.7%

245 of 286 lines run, 40 of 49 functions · run #195 on main · run missed

1
//! What a rootfs image is for, read from its own files (OxygenFlow spec
2
//! §11.2): the ELF header of its shell. An image is an ext4 filesystem,
3
//! as `flow rootfs import` makes it; this reads just enough of one to
4
//! follow a path, symlinks included, to a file's first bytes. It needs no
5
//! mount and no privileges, so a runner can check an image it fetched and
6
//! a hub an image it keeps as chunks.
7
//!
8
//! Read only, and defensive: a malformed image answers `None`, never a
9
//! panic or an unbounded read.
10
11
use std::io::{Read, Seek, SeekFrom};
12
use std::path::Path;
13
14
/// The files whose architecture is the image's, in order: the shell's,
15
/// else the first program found.
16
const PROGRAMS: [&str; 5] = ["bin/sh", "bin/busybox", "usr/bin/env", "bin/bash", "usr/bin/sh"];
17
18
const MAGIC: u16 = 0xEF53;
19
const ROOT_INODE: u32 = 2;
20
const INCOMPAT_64BIT: u32 = 0x80;
21
const EXTENTS_FL: u32 = 0x80000;
22
const INLINE_DATA_FL: u32 = 0x1000_0000;
23
const EXTENT_MAGIC: u16 = 0xF30A;
24
/// More symlinks than this on one path is a loop.
25
const MAX_LINKS: u32 = 40;
26
/// The largest directory read, in bytes.
27
const MAX_DIR: u64 = 64 << 20;
28
29
/// The architecture an ELF header names (`x86_64`, `aarch64`, `riscv64`,
30
/// …), as `std::env::consts::ARCH` spells it; `None` for anything else.
318×
pub fn elf_arch(header: &[u8]) -> Option<&'static str> {
328×
    if header.len() < 20 || &header[..4] != b"\x7fELF" {
331×
        return None;
347×
    }
357×
    let wide = header[4] == 2;
367×
    let machine = if header[5] == 1 { u16::from_le_bytes([header[18], header[19]]) } else { u16::from_be_bytes([header[18], header[19]]) };
373×
    Some(match machine {
382×
        0x3E => "x86_64",
391×
        0xB7 => "aarch64",
403×
        0xF3 if wide => "riscv64",
410×
        0xF3 => "riscv32",
420×
        0x03 => "x86",
430×
        0x28 => "arm",
440×
        0x15 => "powerpc64",
450×
        0x16 => "s390x",
461×
        _ => return None,
47
    })
488×
}
49
50
/// The architecture of the ext4 image read through `read` (fill the
51
/// buffer from that byte offset): its shell's, else the first program of
52
/// [`PROGRAMS`] it has. `None` when it is not ext4 or has none of them.
539×
pub fn arch(read: &mut dyn FnMut(u64, &mut [u8]) -> std::io::Result<()>) -> Option<&'static str> {
549×
    let fs = Fs::open(read)?;
559×
    PROGRAMS.iter().find_map(|p| {
569×
        let inode = fs.resolve(read, p)?;
574×
        if inode.mode & 0xF000 != 0x8000 {
580×
            return None;
594×
        }
604×
        let mut head = [0u8; 20];
614×
        fs.read_file(read, &inode, &mut head)?;
623×
        elf_arch(&head)
639×
    })
649×
}
65
66
/// The architecture of the ext4 image at `path` ([`arch`]).
6710×
pub fn arch_of_file(path: &Path) -> Option<&'static str> {
6810×
    let mut f = std::fs::File::open(path).ok()?;
69334×
    arch(&mut |at: u64, buf: &mut [u8]| {
70334×
        f.seek(SeekFrom::Start(at))?;
71334×
        f.read_exact(buf)
72334×
    })
7310×
}
74
75
struct Fs {
76
    block: u64,
77
    first_data_block: u64,
78
    inodes_per_group: u32,
79
    inode_size: u64,
80
    desc_size: u64,
81
    wide: bool,
82
    groups: u32,
83
}
84
85
struct Inode {
86
    mode: u16,
87
    size: u64,
88
    flags: u32,
89
    block: [u8; 60],
90
}
91
92740×
fn u16_at(b: &[u8], at: usize) -> u16 {
93740×
    u16::from_le_bytes([b[at], b[at + 1]])
94740×
}
95
961056×
fn u32_at(b: &[u8], at: usize) -> u32 {
971056×
    u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
981056×
}
99
100
type ReadFn<'a> = dyn FnMut(u64, &mut [u8]) -> std::io::Result<()> + 'a;
101
102
impl Fs {
1039×
    fn open(read: &mut ReadFn) -> Option<Fs> {
1049×
        let mut sb = [0u8; 1024];
1059×
        read(1024, &mut sb).ok()?;
1067×
        if u16_at(&sb, 0x38) != MAGIC {
1073×
            return None;
1084×
        }
1094×
        let log = u32_at(&sb, 0x18);
1104×
        if log > 6 {
1110×
            return None;
1124×
        }
1134×
        let block = 1024u64 << log;
1144×
        let inode_size = if u32_at(&sb, 0x4C) == 0 { 128 } else { u16_at(&sb, 0x58) as u64 };
1154×
        let wide = u32_at(&sb, 0x60) & INCOMPAT_64BIT != 0;
1164×
        let desc_size = if wide { (u16_at(&sb, 0xFE) as u64).max(64) } else { 32 };
1174×
        let inodes_per_group = u32_at(&sb, 0x28);
1184×
        let inodes = u32_at(&sb, 0);
1194×
        if !(128..=1024).contains(&inode_size) || desc_size > 1024 || inodes_per_group == 0 {
1200×
            return None;
1214×
        }
1224×
        Some(Fs {
1234×
            block,
1244×
            first_data_block: u32_at(&sb, 0x14) as u64,
1254×
            inodes_per_group,
1264×
            inode_size,
1274×
            desc_size,
1284×
            wide,
1294×
            groups: inodes.div_ceil(inodes_per_group),
1304×
        })
1319×
    }
132
133128×
    fn inode(&self, read: &mut ReadFn, n: u32) -> Option<Inode> {
134128×
        let (group, index) = ((n.checked_sub(1)?) / self.inodes_per_group, (n - 1) % self.inodes_per_group);
135128×
        if group >= self.groups {
1360×
            return None;
137128×
        }
138128×
        let mut desc = [0u8; 64];
139128×
        let at = (self.first_data_block + 1) * self.block + group as u64 * self.desc_size;
140128×
        let len = if self.wide { 64 } else { 32 };
141128×
        read(at, &mut desc[..len]).ok()?;
142128×
        let hi = if self.wide { u32_at(&desc, 0x28) as u64 } else { 0 };
143128×
        let table = (hi << 32) | u32_at(&desc, 8) as u64;
144128×
        let mut raw = [0u8; 160];
145128×
        read(table * self.block + index as u64 * self.inode_size, &mut raw[..128]).ok()?;
146128×
        let mut block = [0u8; 60];
147128×
        block.copy_from_slice(&raw[0x28..0x28 + 60]);
148128×
        Some(Inode {
149128×
            mode: u16_at(&raw, 0),
150128×
            size: ((u32_at(&raw, 0x6C) as u64) << 32) | u32_at(&raw, 4) as u64,
151128×
            flags: u32_at(&raw, 0x20),
152128×
            block,
153128×
        })
154128×
    }
155
156
    /// The physical block holding logical block `n` of `inode`; `None` for
157
    /// a hole or an extent not yet written.
15867×
    fn map(&self, read: &mut ReadFn, inode: &Inode, n: u64) -> Option<u64> {
15967×
        if inode.flags & EXTENTS_FL != 0 {
16067×
            let mut node = inode.block.to_vec();
16167×
            for _ in 0..6 {
16269×
                if u16_at(&node, 0) != EXTENT_MAGIC {
1630×
                    return None;
16469×
                }
16569×
                let entries = (u16_at(&node, 2) as usize).min((node.len() - 12) / 12);
16669×
                let depth = u16_at(&node, 6);
16769×
                let entry = |i: usize| &node[12 + 12 * i..24 + 12 * i];
16869×
                if depth == 0 {
16967×
                    return (0..entries).map(entry).find_map(|e| {
17067×
                        let (first, len) = (u32_at(e, 0) as u64, u16_at(e, 4) as u64);
17167×
                        let start = ((u16_at(e, 6) as u64) << 32) | u32_at(e, 8) as u64;
17267×
                        (len <= 32768 && n >= first && n < first + len).then_some(start + (n - first))
17367×
                    });
1742×
                }
1752×
                let next = (0..entries).map(entry).take_while(|e| u32_at(e, 0) as u64 <= n).last()?;
1762×
                let child = ((u16_at(next, 8) as u64) << 32) | u32_at(next, 4) as u64;
1772×
                let mut buf = vec![0u8; self.block as usize];
1782×
                read(child * self.block, &mut buf).ok()?;
1792×
                node = buf;
180
            }
1810×
            return None;
1820×
        }
183
        // The old block map: twelve direct blocks, then one and two levels
184
        // of indirection.
1850×
        let per = self.block / 4;
1860×
        let ptr = |i: usize| u32_at(&inode.block, i * 4) as u64;
1870×
        let mut indirect = |table: u64, i: u64| -> Option<u64> {
1880×
            let mut b = [0u8; 4];
1890×
            read(table * self.block + i * 4, &mut b).ok()?;
1900×
            Some(u32::from_le_bytes(b) as u64).filter(|&p| p != 0)
1910×
        };
1920×
        let p = if n < 12 {
1930×
            ptr(n as usize)
1940×
        } else if n < 12 + per {
1950×
            indirect(ptr(12), n - 12)?
1960×
        } else if n < 12 + per + per * per {
1970×
            let k = n - 12 - per;
1980×
            let mid = indirect(ptr(13), k / per)?;
1990×
            indirect(mid, k % per)?
200
        } else {
2010×
            return None;
202
        };
2030×
        (p != 0).then_some(p)
20467×
    }
205
206
    /// Fill `buf` from the start of `inode`'s data.
2074×
    fn read_file(&self, read: &mut ReadFn, inode: &Inode, buf: &mut [u8]) -> Option<()> {
2084×
        if inode.flags & INLINE_DATA_FL != 0 || (buf.len() as u64) > inode.size.min(self.block) {
2091×
            return None;
2103×
        }
2113×
        let at = self.map(read, inode, 0)?;
2123×
        read(at * self.block, buf).ok()
2134×
    }
214
215
    /// Where a symlink points.
21643×
    fn link_target(&self, read: &mut ReadFn, inode: &Inode) -> Option<String> {
21743×
        if inode.size == 0 || inode.size > 4096 {
2180×
            return None;
21943×
        }
22043×
        let bytes = if inode.flags & (EXTENTS_FL | INLINE_DATA_FL) == 0 && inode.size < 60 {
22143×
            inode.block[..inode.size as usize].to_vec()
222
        } else {
2230×
            let mut b = vec![0u8; inode.size as usize];
2240×
            self.read_file(read, inode, &mut b)?;
2250×
            b
226
        };
22743×
        String::from_utf8(bytes).ok()
22843×
    }
229
230
    /// The inode named `name` in directory `dir`.
23164×
    fn lookup(&self, read: &mut ReadFn, dir: &Inode, name: &str) -> Option<u32> {
23264×
        if dir.mode & 0xF000 != 0x4000 || dir.flags & INLINE_DATA_FL != 0 || dir.size > MAX_DIR {
2330×
            return None;
23464×
        }
23564×
        let mut buf = vec![0u8; self.block as usize];
23664×
        for n in 0..dir.size.div_ceil(self.block) {
23764×
            let Some(at) = self.map(read, dir, n) else { continue };
23864×
            read(at * self.block, &mut buf).ok()?;
23964×
            let mut off = 0usize;
240258×
            while off + 8 <= buf.len() {
241254×
                let (ino, rec, len) = (u32_at(&buf, off), u16_at(&buf, off + 4) as usize, buf[off + 6] as usize);
242254×
                if rec < 8 || off + rec > buf.len() {
2430×
                    break;
244254×
                }
245254×
                if ino != 0 && off + 8 + len <= buf.len() && &buf[off + 8..off + 8 + len] == name.as_bytes() {
24660×
                    return Some(ino);
247194×
                }
248194×
                off += rec;
249
            }
250
        }
2514×
        None
25264×
    }
253
254
    /// The inode at `path` from the root, following every symlink.
2559×
    fn resolve(&self, read: &mut ReadFn, path: &str) -> Option<Inode> {
25620×
        let mut todo: Vec<String> = path.split('/').filter(|p| !p.is_empty()).rev().map(String::from).collect();
2579×
        let mut dirs = vec![ROOT_INODE];
2589×
        let mut links = 0;
25969×
        while let Some(name) = todo.pop() {
26065×
            match name.as_str() {
26165×
                "." => continue,
26265×
                ".." => {
2631×
                    if dirs.len() > 1 {
2641×
                        dirs.pop();
2651×
                    }
2661×
                    continue;
267
                }
26864×
                _ => {}
269
            }
27064×
            let dir = self.inode(read, *dirs.last()?)?;
27164×
            let n = self.lookup(read, &dir, &name)?;
27260×
            let inode = self.inode(read, n)?;
27360×
            if inode.mode & 0xF000 == 0xA000 {
27444×
                links += 1;
27544×
                if links > MAX_LINKS {
2761×
                    return None;
27743×
                }
27843×
                let target = self.link_target(read, &inode)?;
27943×
                if target.starts_with('/') {
2801×
                    dirs.truncate(1);
28142×
                }
28250×
                todo.extend(target.split('/').filter(|p| !p.is_empty()).rev().map(String::from));
28343×
                continue;
28416×
            }
28516×
            dirs.push(n);
286
        }
2874×
        self.inode(read, *dirs.last()?)
2889×
    }
289
}
290
291
#[cfg(test)]
292
mod tests {
293
    use super::*;
294
    use std::process::Command;
295
296
    /// The first 64 bytes of an ELF program for `machine`, 64-bit little
297
    /// endian.
2986×
    fn elf(machine: u16) -> Vec<u8> {
2996×
        let mut h = vec![0u8; 64];
3006×
        h[..4].copy_from_slice(b"\x7fELF");
3016×
        h[4] = 2;
3026×
        h[5] = 1;
3036×
        h[6] = 1;
3046×
        h[16] = 3;
3056×
        h[18..20].copy_from_slice(&machine.to_le_bytes());
3066×
        h
3076×
    }
308
309
    /// An ext4 image of `tree`, as `flow rootfs import` makes one; `None`
310
    /// where mkfs.ext4 is missing.
3113×
    fn image(tree: &Path, out: &Path, size: &str, extra: &[&str]) -> Option<()> {
3123×
        let status = Command::new("mkfs.ext4")
3133×
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0"])
3143×
            .args(extra)
3153×
            .arg("-d")
3163×
            .arg(tree)
3173×
            .arg(out)
3183×
            .arg(size)
3193×
            .status()
3203×
            .ok()?;
3213×
        assert!(status.success(), "mkfs.ext4 failed");
3223×
        Some(())
3233×
    }
324
325
    #[test]
3261×
    fn elf_headers_name_their_architecture() {
3271×
        assert_eq!(elf_arch(&elf(0x3E)), Some("x86_64"));
3281×
        assert_eq!(elf_arch(&elf(0xF3)), Some("riscv64"));
3291×
        assert_eq!(elf_arch(&elf(0xB7)), Some("aarch64"));
3301×
        assert_eq!(elf_arch(b"#!/bin/sh\necho hello\n"), None);
3311×
        assert_eq!(elf_arch(&elf(0x9999)), None);
3321×
    }
333
334
    #[test]
3351×
    fn an_images_shell_names_its_architecture_through_symlinks() {
3361×
        let dir = tempfile::tempdir().unwrap();
337
        // A merged /usr, as Debian and Ubuntu have it: bin -> usr/bin, and
338
        // sh -> dash; a riscv64 shell.
3391×
        let tree = dir.path().join("tree");
3401×
        std::fs::create_dir_all(tree.join("usr/bin")).unwrap();
3411×
        std::fs::write(tree.join("usr/bin/dash"), elf(0xF3)).unwrap();
3421×
        std::os::unix::fs::symlink("dash", tree.join("usr/bin/sh")).unwrap();
3431×
        std::os::unix::fs::symlink("usr/bin", tree.join("bin")).unwrap();
344
        // A full directory, so lookups cross blocks.
345400×
        for i in 0..400 {
346400×
            std::fs::write(tree.join(format!("usr/bin/tool-with-a-long-name-{i:04}")), b"#!/bin/sh\n").unwrap();
347400×
        }
3481×
        let img = dir.path().join("rootfs.ext4");
3491×
        if image(&tree, &img, "8M", &[]).is_none() {
3500×
            eprintln!("mkfs.ext4 not found: skipped");
3510×
            return;
3521×
        }
3531×
        assert_eq!(arch_of_file(&img), Some("riscv64"));
354
        // 1 KiB blocks, and an absolute link through a deeper directory.
3551×
        let tree2 = dir.path().join("tree2");
3561×
        std::fs::create_dir_all(tree2.join("bin")).unwrap();
3571×
        std::fs::create_dir_all(tree2.join("opt/busybox/bin")).unwrap();
3581×
        std::fs::write(tree2.join("opt/busybox/bin/busybox"), elf(0x3E)).unwrap();
3591×
        std::os::unix::fs::symlink("/opt/busybox/bin/../bin/busybox", tree2.join("bin/busybox")).unwrap();
3601×
        let img2 = dir.path().join("small.ext4");
3611×
        image(&tree2, &img2, "8M", &["-b", "1024"]).unwrap();
3621×
        assert_eq!(arch_of_file(&img2), Some("x86_64"), "no sh: busybox decides");
3631×
    }
364
365
    #[test]
3661×
    fn what_is_not_an_image_or_has_no_shell_answers_nothing() {
3671×
        let dir = tempfile::tempdir().unwrap();
3681×
        let junk = dir.path().join("junk");
3691×
        std::fs::write(&junk, vec![0x5Au8; 64 << 10]).unwrap();
3701×
        assert_eq!(arch_of_file(&junk), None);
3711×
        assert_eq!(arch_of_file(&dir.path().join("missing")), None);
3721×
        let tree = dir.path().join("tree");
3731×
        std::fs::create_dir_all(tree.join("bin")).unwrap();
3741×
        std::fs::write(tree.join("bin/sh"), b"#!/not/an/elf\n").unwrap();
3751×
        std::os::unix::fs::symlink("loop", tree.join("bin/loop")).unwrap();
3761×
        std::os::unix::fs::symlink("loop", tree.join("bin/busybox")).unwrap();
3771×
        let img = dir.path().join("rootfs.ext4");
3781×
        if image(&tree, &img, "8M", &[]).is_none() {
3790×
            return;
3801×
        }
3811×
        assert_eq!(arch_of_file(&img), None, "a script shell and a symlink loop name nothing");
3821×
    }
383
}