OXYGENHUB

Runners read an unrecorded image's architecture and decline another 0077c7488e5d

An image with no recorded architecture (imported before spec §11.2, or
fetched from a hub that did not know it) matched every runner, so a
riscv64 runner could be handed an x86_64 image, run it and fail.

jet::rootfs reads an ext4 image's /bin/sh (else busybox, env or bash)
through its symlinks to the ELF header, with no mount and no privileges:
extents and the old block map, 1 KiB to 64 KiB blocks, and every bound
checked, so a malformed image answers nothing. A runner reads an image
whose manifest has no architecture once, records it, reports it with its
claims, and declines a job for another architecture instead of failing
it. Tested on real mkfs.ext4 images: a merged /usr, a full directory,
1 KiB blocks, an absolute link, a script shell and a symlink loop.

refs #53
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 00:45 UTC
Signature
✔ signed by Andy McCall
Hash
0077c7488e5d5377ff188fe854afb891b67ac12674ae2442a7f57dc5d2d2db59
Depends on
1abadd4a2fa6 59078d2b8d46 a083cc802892 b14aeaae683a e0918b887850 f53e6d47b56a fd2d4822f7f3
Depended on by
nothing yet
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
11 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/jet/src/rootfs.rs +383 −0
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 +
}
modified crates/flow/src/home.rs +27 −4
…
139 unchanged lines
140 140
        self.root.join("cas").join(format!("{hash}.json"))
141 141
    }
142 142
143 -
    /// The architecture an image was imported for, when its manifest says.
143 +
    /// The architecture an image is for: as its manifest records, else
144 +
    /// read from the image's own shell (spec §11.2) and recorded, so an
145 +
    /// image fetched or imported without one is read once and a runner
146 +
    /// declines what it cannot run instead of failing it. `None` when the
147 +
    /// image is not here or names no architecture.
144 148
    pub fn image_arch(&self, hash: &str) -> Option<String> {
145 -
        let text = std::fs::read_to_string(self.image_manifest(hash)).ok()?;
146 -
        let v: serde_json::Value = serde_json::from_str(&text).ok()?;
147 -
        v["arch"].as_str().map(String::from)
149 +
        let path = self.image_manifest(hash);
150 +
        let mut v: serde_json::Value =
151 +
            std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_else(|| serde_json::json!({}));
152 +
        if let Some(a) = v["arch"].as_str() {
153 +
            return Some(a.to_string());
154 +
        }
155 +
        let image = self.rootfs(hash);
156 +
        if v.get("arch_read_ms").is_some() || !image.is_file() {
157 +
            return None;
158 +
        }
159 +
        let arch = jet::rootfs::arch_of_file(&image);
160 +
        let o = v.as_object_mut()?;
161 +
        if let Some(a) = arch {
162 +
            o.insert("arch".into(), a.into());
163 +
        }
164 +
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
165 +
        o.insert("arch_read_ms".into(), now.into());
166 +
        let tmp = path.with_extension("json.part");
167 +
        if std::fs::write(&tmp, v.to_string()).is_ok() {
168 +
            let _ = std::fs::rename(&tmp, &path);
169 +
        }
170 +
        arch.map(String::from)
148 171
    }
149 172
150 173
    /// Every image with a known architecture (spec §11.2).
…
100 unchanged lines
modified crates/flow/src/main.rs +3 −16
…
344 unchanged lines
345 345
346 346
/// The architecture an ELF file was built for, by its header.
347 347
fn elf_arch(path: &Path) -> Option<&'static str> {
348 -
    let bytes = std::fs::read(path).ok()?;
349 -
    if bytes.len() < 20 || &bytes[..4] != b"\x7fELF" {
350 -
        return None;
351 -
    }
352 -
    let little = bytes[5] == 1;
353 -
    let machine = if little { u16::from_le_bytes([bytes[18], bytes[19]]) } else { u16::from_be_bytes([bytes[18], bytes[19]]) };
354 -
    Some(match machine {
355 -
        0x3E => "x86_64",
356 -
        0xB7 => "aarch64",
357 -
        0xF3 => "riscv64",
358 -
        0x03 => "x86",
359 -
        0x28 => "arm",
360 -
        0x15 => "powerpc64",
361 -
        0x16 => "s390x",
362 -
        _ => return None,
363 -
    })
348 +
    let mut head = [0u8; 20];
349 +
    std::io::Read::read_exact(&mut std::fs::File::open(path).ok()?, &mut head).ok()?;
350 +
    jet::rootfs::elf_arch(&head)
364 351
}
365 352
366 353
/// The architecture of an unpacked rootfs: its shell's, or the first
…
200 unchanged lines
modified crates/flow/src/runner.rs +38 −0
…
906 unchanged lines
907 907
    }
908 908
909 909
    #[test]
910 +
    fn a_runner_reads_an_unrecorded_images_architecture_and_declines_another() {
911 +
        // An image fetched or imported with no recorded architecture, built
912 +
        // for riscv64: the runner reads its shell, records it, and declines
913 +
        // instead of running it and failing (spec §11.2, §11.5).
914 +
        let dir = tempfile::tempdir().unwrap();
915 +
        let home = Home::locate(Some(dir.path().join("home").as_path())).unwrap();
916 +
        let tree = dir.path().join("tree");
917 +
        std::fs::create_dir_all(tree.join("bin")).unwrap();
918 +
        let mut elf = vec![0u8; 64];
919 +
        elf[..7].copy_from_slice(b"\x7fELF\x02\x01\x01");
920 +
        elf[18..20].copy_from_slice(&0xF3u16.to_le_bytes());
921 +
        std::fs::write(tree.join("bin/sh"), &elf).unwrap();
922 +
        let img = dir.path().join("rootfs.ext4");
923 +
        let made = std::process::Command::new("mkfs.ext4").args(["-q", "-F", "-d"]).arg(&tree).arg(&img).arg("8M").status();
924 +
        if !made.is_ok_and(|s| s.success()) {
925 +
            eprintln!("mkfs.ext4 not found: skipped");
926 +
            return;
927 +
        }
928 +
        let hash = crate::home::hash_file(&img).unwrap();
929 +
        std::fs::create_dir_all(home.rootfs(&hash).parent().unwrap()).unwrap();
930 +
        std::fs::rename(&img, home.rootfs(&hash)).unwrap();
931 +
        assert!(!home.image_manifest(&hash).exists());
932 +
        let sb: flow_core::Sandbox = serde_json::from_value(serde_json::json!({
933 +
            "name": "s", "runtime": "syscall", "rootfs": hash, "memory_mib": 64, "cpus": 1, "network": "isolated"
934 +
        }))
935 +
        .unwrap();
936 +
        assert_eq!(
937 +
            sandbox_problem(&home, &sb, "x86_64", None).unwrap(),
938 +
            format!("rootfs {} is for riscv64; this runner is x86_64", &hash[..12])
939 +
        );
940 +
        assert_eq!(sandbox_problem(&home, &sb, "riscv64", None), None, "a riscv64 runner takes it");
941 +
        // Learned: recorded once, and reported with the runner's claims.
942 +
        let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(home.image_manifest(&hash)).unwrap()).unwrap();
943 +
        assert_eq!(manifest["arch"], "riscv64");
944 +
        assert_eq!(home.image_archs().get(&hash).map(String::as_str), Some("riscv64"));
945 +
    }
946 +
947 +
    #[test]
910 948
    fn transient_network_errors_are_tried_again_and_others_are_not() {
911 949
        assert!(transient("fetch x: Dns Failed: resolve dns name 'oxygenhub.io:443': Temporary failure in name resolution"));
912 950
        assert!(transient("HTTP 503: the hub is restarting"));
…
185 unchanged lines
modified crates/jet/src/lib.rs +1 −0
…
5 unchanged lines
6 6
//! and shared between runners in a swarm (oxygenflow#47).
7 7
8 8
pub mod images;
9 +
pub mod rootfs;
9 10
pub mod swarm;
modified docs/spec.md +12 −2
…
462 unchanged lines
463 463
`flow rootfs import` reads the ELF header of the image's `/bin/sh` and
464 464
records its architecture in the CAS manifest beside the hash. A runner
465 465
refuses to import an image for another architecture (there is no
466 -
emulation tier). An image imported before this section has no recorded
467 -
architecture and is treated as the importing runner's.
466 +
emulation tier).
468 467
468 +
An image whose manifest records no architecture (imported before this
469 +
section, or fetched from a hub that did not know it) is read, not
470 +
guessed: `jet::rootfs` follows `/bin/sh` (else busybox, env or bash)
471 +
through the image's ext4, symlinks included, to its ELF header, with no
472 +
mount and no privileges. The runner records the answer in the manifest,
473 +
so each image is read once, reports it with its claims, and declines a
474 +
job whose image is for another architecture instead of running it and
475 +
failing (§11.5). The hub reads the images it keeps the same way
476 +
(OxygenHub, oxygenhub#173), and an image nobody can read is offered only
477 +
to the architecture its jobs have passed on before.
478 +
469 479
### 11.3 Job Requirements
470 480
471 481
Hard requirements, all of which a runner MUST satisfy to be offered the
…
51 unchanged lines