OXYGENHUB
◆ v0.5.2 oxygenflow / crates / jet / src / rootfs.rs
14.4 KiB · Rust · last changed Runners read an unrecorded image's architecture and decline another 22 days ago source blame history raw
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.
31
pub fn elf_arch(header: &[u8]) -> Option<&'static str> {
32
    if header.len() < 20 || &header[..4] != b"\x7fELF" {
33
        return None;
34
    }
35
    let wide = header[4] == 2;
36
    let machine = if header[5] == 1 { u16::from_le_bytes([header[18], header[19]]) } else { u16::from_be_bytes([header[18], header[19]]) };
37
    Some(match machine {
38
        0x3E => "x86_64",
39
        0xB7 => "aarch64",
40
        0xF3 if wide => "riscv64",
41
        0xF3 => "riscv32",
42
        0x03 => "x86",
43
        0x28 => "arm",
44
        0x15 => "powerpc64",
45
        0x16 => "s390x",
46
        _ => return None,
47
    })
48
}
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.
53
pub fn arch(read: &mut dyn FnMut(u64, &mut [u8]) -> std::io::Result<()>) -> Option<&'static str> {
54
    let fs = Fs::open(read)?;
55
    PROGRAMS.iter().find_map(|p| {
56
        let inode = fs.resolve(read, p)?;
57
        if inode.mode & 0xF000 != 0x8000 {
58
            return None;
59
        }
60
        let mut head = [0u8; 20];
61
        fs.read_file(read, &inode, &mut head)?;
62
        elf_arch(&head)
63
    })
64
}
65
66
/// The architecture of the ext4 image at `path` ([`arch`]).
67
pub fn arch_of_file(path: &Path) -> Option<&'static str> {
68
    let mut f = std::fs::File::open(path).ok()?;
69
    arch(&mut |at: u64, buf: &mut [u8]| {
70
        f.seek(SeekFrom::Start(at))?;
71
        f.read_exact(buf)
72
    })
73
}
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
92
fn u16_at(b: &[u8], at: usize) -> u16 {
93
    u16::from_le_bytes([b[at], b[at + 1]])
94
}
95
96
fn u32_at(b: &[u8], at: usize) -> u32 {
97
    u32::from_le_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]])
98
}
99
100
type ReadFn<'a> = dyn FnMut(u64, &mut [u8]) -> std::io::Result<()> + 'a;
101
102
impl Fs {
103
    fn open(read: &mut ReadFn) -> Option<Fs> {
104
        let mut sb = [0u8; 1024];
105
        read(1024, &mut sb).ok()?;
106
        if u16_at(&sb, 0x38) != MAGIC {
107
            return None;
108
        }
109
        let log = u32_at(&sb, 0x18);
110
        if log > 6 {
111
            return None;
112
        }
113
        let block = 1024u64 << log;
114
        let inode_size = if u32_at(&sb, 0x4C) == 0 { 128 } else { u16_at(&sb, 0x58) as u64 };
115
        let wide = u32_at(&sb, 0x60) & INCOMPAT_64BIT != 0;
116
        let desc_size = if wide { (u16_at(&sb, 0xFE) as u64).max(64) } else { 32 };
117
        let inodes_per_group = u32_at(&sb, 0x28);
118
        let inodes = u32_at(&sb, 0);
119
        if !(128..=1024).contains(&inode_size) || desc_size > 1024 || inodes_per_group == 0 {
120
            return None;
121
        }
122
        Some(Fs {
123
            block,
124
            first_data_block: u32_at(&sb, 0x14) as u64,
125
            inodes_per_group,
126
            inode_size,
127
            desc_size,
128
            wide,
129
            groups: inodes.div_ceil(inodes_per_group),
130
        })
131
    }
132
133
    fn inode(&self, read: &mut ReadFn, n: u32) -> Option<Inode> {
134
        let (group, index) = ((n.checked_sub(1)?) / self.inodes_per_group, (n - 1) % self.inodes_per_group);
135
        if group >= self.groups {
136
            return None;
137
        }
138
        let mut desc = [0u8; 64];
139
        let at = (self.first_data_block + 1) * self.block + group as u64 * self.desc_size;
140
        let len = if self.wide { 64 } else { 32 };
141
        read(at, &mut desc[..len]).ok()?;
142
        let hi = if self.wide { u32_at(&desc, 0x28) as u64 } else { 0 };
143
        let table = (hi << 32) | u32_at(&desc, 8) as u64;
144
        let mut raw = [0u8; 160];
145
        read(table * self.block + index as u64 * self.inode_size, &mut raw[..128]).ok()?;
146
        let mut block = [0u8; 60];
147
        block.copy_from_slice(&raw[0x28..0x28 + 60]);
148
        Some(Inode {
149
            mode: u16_at(&raw, 0),
150
            size: ((u32_at(&raw, 0x6C) as u64) << 32) | u32_at(&raw, 4) as u64,
151
            flags: u32_at(&raw, 0x20),
152
            block,
153
        })
154
    }
155
156
    /// The physical block holding logical block `n` of `inode`; `None` for
157
    /// a hole or an extent not yet written.
158
    fn map(&self, read: &mut ReadFn, inode: &Inode, n: u64) -> Option<u64> {
159
        if inode.flags & EXTENTS_FL != 0 {
160
            let mut node = inode.block.to_vec();
161
            for _ in 0..6 {
162
                if u16_at(&node, 0) != EXTENT_MAGIC {
163
                    return None;
164
                }
165
                let entries = (u16_at(&node, 2) as usize).min((node.len() - 12) / 12);
166
                let depth = u16_at(&node, 6);
167
                let entry = |i: usize| &node[12 + 12 * i..24 + 12 * i];
168
                if depth == 0 {
169
                    return (0..entries).map(entry).find_map(|e| {
170
                        let (first, len) = (u32_at(e, 0) as u64, u16_at(e, 4) as u64);
171
                        let start = ((u16_at(e, 6) as u64) << 32) | u32_at(e, 8) as u64;
172
                        (len <= 32768 && n >= first && n < first + len).then_some(start + (n - first))
173
                    });
174
                }
175
                let next = (0..entries).map(entry).take_while(|e| u32_at(e, 0) as u64 <= n).last()?;
176
                let child = ((u16_at(next, 8) as u64) << 32) | u32_at(next, 4) as u64;
177
                let mut buf = vec![0u8; self.block as usize];
178
                read(child * self.block, &mut buf).ok()?;
179
                node = buf;
180
            }
181
            return None;
182
        }
183
        // The old block map: twelve direct blocks, then one and two levels
184
        // of indirection.
185
        let per = self.block / 4;
186
        let ptr = |i: usize| u32_at(&inode.block, i * 4) as u64;
187
        let mut indirect = |table: u64, i: u64| -> Option<u64> {
188
            let mut b = [0u8; 4];
189
            read(table * self.block + i * 4, &mut b).ok()?;
190
            Some(u32::from_le_bytes(b) as u64).filter(|&p| p != 0)
191
        };
192
        let p = if n < 12 {
193
            ptr(n as usize)
194
        } else if n < 12 + per {
195
            indirect(ptr(12), n - 12)?
196
        } else if n < 12 + per + per * per {
197
            let k = n - 12 - per;
198
            let mid = indirect(ptr(13), k / per)?;
199
            indirect(mid, k % per)?
200
        } else {
201
            return None;
202
        };
203
        (p != 0).then_some(p)
204
    }
205
206
    /// Fill `buf` from the start of `inode`'s data.
207
    fn read_file(&self, read: &mut ReadFn, inode: &Inode, buf: &mut [u8]) -> Option<()> {
208
        if inode.flags & INLINE_DATA_FL != 0 || (buf.len() as u64) > inode.size.min(self.block) {
209
            return None;
210
        }
211
        let at = self.map(read, inode, 0)?;
212
        read(at * self.block, buf).ok()
213
    }
214
215
    /// Where a symlink points.
216
    fn link_target(&self, read: &mut ReadFn, inode: &Inode) -> Option<String> {
217
        if inode.size == 0 || inode.size > 4096 {
218
            return None;
219
        }
220
        let bytes = if inode.flags & (EXTENTS_FL | INLINE_DATA_FL) == 0 && inode.size < 60 {
221
            inode.block[..inode.size as usize].to_vec()
222
        } else {
223
            let mut b = vec![0u8; inode.size as usize];
224
            self.read_file(read, inode, &mut b)?;
225
            b
226
        };
227
        String::from_utf8(bytes).ok()
228
    }
229
230
    /// The inode named `name` in directory `dir`.
231
    fn lookup(&self, read: &mut ReadFn, dir: &Inode, name: &str) -> Option<u32> {
232
        if dir.mode & 0xF000 != 0x4000 || dir.flags & INLINE_DATA_FL != 0 || dir.size > MAX_DIR {
233
            return None;
234
        }
235
        let mut buf = vec![0u8; self.block as usize];
236
        for n in 0..dir.size.div_ceil(self.block) {
237
            let Some(at) = self.map(read, dir, n) else { continue };
238
            read(at * self.block, &mut buf).ok()?;
239
            let mut off = 0usize;
240
            while off + 8 <= buf.len() {
241
                let (ino, rec, len) = (u32_at(&buf, off), u16_at(&buf, off + 4) as usize, buf[off + 6] as usize);
242
                if rec < 8 || off + rec > buf.len() {
243
                    break;
244
                }
245
                if ino != 0 && off + 8 + len <= buf.len() && &buf[off + 8..off + 8 + len] == name.as_bytes() {
246
                    return Some(ino);
247
                }
248
                off += rec;
249
            }
250
        }
251
        None
252
    }
253
254
    /// The inode at `path` from the root, following every symlink.
255
    fn resolve(&self, read: &mut ReadFn, path: &str) -> Option<Inode> {
256
        let mut todo: Vec<String> = path.split('/').filter(|p| !p.is_empty()).rev().map(String::from).collect();
257
        let mut dirs = vec![ROOT_INODE];
258
        let mut links = 0;
259
        while let Some(name) = todo.pop() {
260
            match name.as_str() {
261
                "." => continue,
262
                ".." => {
263
                    if dirs.len() > 1 {
264
                        dirs.pop();
265
                    }
266
                    continue;
267
                }
268
                _ => {}
269
            }
270
            let dir = self.inode(read, *dirs.last()?)?;
271
            let n = self.lookup(read, &dir, &name)?;
272
            let inode = self.inode(read, n)?;
273
            if inode.mode & 0xF000 == 0xA000 {
274
                links += 1;
275
                if links > MAX_LINKS {
276
                    return None;
277
                }
278
                let target = self.link_target(read, &inode)?;
279
                if target.starts_with('/') {
280
                    dirs.truncate(1);
281
                }
282
                todo.extend(target.split('/').filter(|p| !p.is_empty()).rev().map(String::from));
283
                continue;
284
            }
285
            dirs.push(n);
286
        }
287
        self.inode(read, *dirs.last()?)
288
    }
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.
298
    fn elf(machine: u16) -> Vec<u8> {
299
        let mut h = vec![0u8; 64];
300
        h[..4].copy_from_slice(b"\x7fELF");
301
        h[4] = 2;
302
        h[5] = 1;
303
        h[6] = 1;
304
        h[16] = 3;
305
        h[18..20].copy_from_slice(&machine.to_le_bytes());
306
        h
307
    }
308
309
    /// An ext4 image of `tree`, as `flow rootfs import` makes one; `None`
310
    /// where mkfs.ext4 is missing.
311
    fn image(tree: &Path, out: &Path, size: &str, extra: &[&str]) -> Option<()> {
312
        let status = Command::new("mkfs.ext4")
313
            .args(["-q", "-F", "-L", "rootfs", "-E", "root_owner=0:0"])
314
            .args(extra)
315
            .arg("-d")
316
            .arg(tree)
317
            .arg(out)
318
            .arg(size)
319
            .status()
320
            .ok()?;
321
        assert!(status.success(), "mkfs.ext4 failed");
322
        Some(())
323
    }
324
325
    #[test]
326
    fn elf_headers_name_their_architecture() {
327
        assert_eq!(elf_arch(&elf(0x3E)), Some("x86_64"));
328
        assert_eq!(elf_arch(&elf(0xF3)), Some("riscv64"));
329
        assert_eq!(elf_arch(&elf(0xB7)), Some("aarch64"));
330
        assert_eq!(elf_arch(b"#!/bin/sh\necho hello\n"), None);
331
        assert_eq!(elf_arch(&elf(0x9999)), None);
332
    }
333
334
    #[test]
335
    fn an_images_shell_names_its_architecture_through_symlinks() {
336
        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.
339
        let tree = dir.path().join("tree");
340
        std::fs::create_dir_all(tree.join("usr/bin")).unwrap();
341
        std::fs::write(tree.join("usr/bin/dash"), elf(0xF3)).unwrap();
342
        std::os::unix::fs::symlink("dash", tree.join("usr/bin/sh")).unwrap();
343
        std::os::unix::fs::symlink("usr/bin", tree.join("bin")).unwrap();
344
        // A full directory, so lookups cross blocks.
345
        for i in 0..400 {
346
            std::fs::write(tree.join(format!("usr/bin/tool-with-a-long-name-{i:04}")), b"#!/bin/sh\n").unwrap();
347
        }
348
        let img = dir.path().join("rootfs.ext4");
349
        if image(&tree, &img, "8M", &[]).is_none() {
350
            eprintln!("mkfs.ext4 not found: skipped");
351
            return;
352
        }
353
        assert_eq!(arch_of_file(&img), Some("riscv64"));
354
        // 1 KiB blocks, and an absolute link through a deeper directory.
355
        let tree2 = dir.path().join("tree2");
356
        std::fs::create_dir_all(tree2.join("bin")).unwrap();
357
        std::fs::create_dir_all(tree2.join("opt/busybox/bin")).unwrap();
358
        std::fs::write(tree2.join("opt/busybox/bin/busybox"), elf(0x3E)).unwrap();
359
        std::os::unix::fs::symlink("/opt/busybox/bin/../bin/busybox", tree2.join("bin/busybox")).unwrap();
360
        let img2 = dir.path().join("small.ext4");
361
        image(&tree2, &img2, "8M", &["-b", "1024"]).unwrap();
362
        assert_eq!(arch_of_file(&img2), Some("x86_64"), "no sh: busybox decides");
363
    }
364
365
    #[test]
366
    fn what_is_not_an_image_or_has_no_shell_answers_nothing() {
367
        let dir = tempfile::tempdir().unwrap();
368
        let junk = dir.path().join("junk");
369
        std::fs::write(&junk, vec![0x5Au8; 64 << 10]).unwrap();
370
        assert_eq!(arch_of_file(&junk), None);
371
        assert_eq!(arch_of_file(&dir.path().join("missing")), None);
372
        let tree = dir.path().join("tree");
373
        std::fs::create_dir_all(tree.join("bin")).unwrap();
374
        std::fs::write(tree.join("bin/sh"), b"#!/not/an/elf\n").unwrap();
375
        std::os::unix::fs::symlink("loop", tree.join("bin/loop")).unwrap();
376
        std::os::unix::fs::symlink("loop", tree.join("bin/busybox")).unwrap();
377
        let img = dir.path().join("rootfs.ext4");
378
        if image(&tree, &img, "8M", &[]).is_none() {
379
            return;
380
        }
381
        assert_eq!(arch_of_file(&img), None, "a script shell and a symlink loop name nothing");
382
    }
383
}