OXYGENHUB

Coverage / crates/flow/src/outputs.rs 95.1%

255 of 268 lines run, 33 of 66 functions · run #197 on main · run missed

1
//! A job's artifacts (spec §5.2): read after the job has stopped, from
2
//! its disk images with debugfs (tier 2, so the runner needs no mount
3
//! rights) or from its directories (tier 1).
4
5
use anyhow::{bail, Context, Result};
6
use flow_core::{Job, MountSource};
7
use std::path::{Path, PathBuf};
8
9
/// Largest artifact a runner takes: 512 MiB.
10
pub const MAX_ARTIFACT_BYTES: u64 = 512 << 20;
11
12
/// Where guest path `path` lives on the host: the disk image, and the path
13
/// inside its file system. The workspace is `/src`; each `ephemeral://disk`
14
/// mount is `mount-N.ext4` in `scratch`, numbered as `microvm::run` does.
154×
pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
164×
    locate_in(job, workspace, path, |i| scratch.join(format!("mount-{i}.ext4")))
174×
}
18
19
/// [`locate`] for the syscall tier, where the workspace and each disk
20
/// mount are directories (`crate::syscall::disk_dir`).
211×
pub fn locate_dir(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
221×
    locate_in(job, workspace, path, |i| crate::syscall::disk_dir(scratch, i))
231×
}
24
255×
fn locate_in(job: &Job, workspace: &Path, path: &str, disk: impl Fn(usize) -> PathBuf) -> (PathBuf, String) {
2613×
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
278×
        if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) {
283×
            return (disk(i), format!("/{rest}"));
295×
        }
30
    }
312×
    (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string())
325×
}
33
34
/// Read regular file `inner` beneath directory `base`, at most
35
/// [`MAX_ARTIFACT_BYTES`]. The job wrote that tree, so no link is
36
/// followed on the way: each directory is opened with `O_NOFOLLOW`, and
37
/// so is the file.
38
#[cfg(unix)]
395×
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
40
    use std::ffi::CString;
41
    use std::os::fd::FromRawFd;
425×
    let parts = clean_parts(inner)?;
4310×
    let c = |s: &str| CString::new(s).context("a path holds NUL");
444×
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
454×
    if dir < 0 {
460×
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
474×
    }
484×
    let (last, dirs) = parts.split_last().unwrap();
494×
    for d in dirs {
503×
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
513×
        let err = std::io::Error::last_os_error();
523×
        unsafe { libc::close(dir) };
533×
        if next < 0 {
541×
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
552×
        }
562×
        dir = next;
57
    }
583×
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
593×
    let err = std::io::Error::last_os_error();
603×
    unsafe { libc::close(dir) };
613×
    if fd < 0 {
621×
        bail!("{inner} is not a regular file in the job's files ({err})");
632×
    }
642×
    read_open(unsafe { std::fs::File::from_raw_fd(fd) }, inner)
655×
}
66
67
/// [`read_dir_file`] on Windows (oxygenflow#83), where a WASM job's files
68
/// are a directory: each step is checked to be a directory and not a link
69
/// (nor a junction), then the file to be a regular file, before it is read.
70
#[cfg(not(unix))]
71
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
72
    let parts = clean_parts(inner)?;
73
    let mut at = base.to_path_buf();
74
    let (last, dirs) = parts.split_last().unwrap();
75
    for d in dirs {
76
        at.push(d);
77
        let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner}: {d} is not in the job's files"))?;
78
        if !md.is_dir() || md.file_type().is_symlink() {
79
            bail!("{inner}: {d} is not a directory in the job's files");
80
        }
81
    }
82
    at.push(last);
83
    let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner} is not in the job's files"))?;
84
    if !md.is_file() || md.file_type().is_symlink() {
85
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
86
    }
87
    read_open(std::fs::File::open(&at)?, inner)
88
}
89
90
/// `inner`'s parts, refusing an empty path, `.` and `..`.
915×
fn clean_parts(inner: &str) -> Result<Vec<&str>> {
9216×
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
939×
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
941×
        bail!("{inner} is not a clean path");
954×
    }
964×
    Ok(parts)
975×
}
98
99
/// Read an opened artifact file, at most [`MAX_ARTIFACT_BYTES`].
1002×
fn read_open(file: std::fs::File, inner: &str) -> Result<Vec<u8>> {
101
    use std::io::Read;
1022×
    let meta = file.metadata()?;
1032×
    if !meta.is_file() {
1041×
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
1051×
    }
1061×
    if meta.len() > MAX_ARTIFACT_BYTES {
1070×
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
1081×
    }
1091×
    let mut bytes = Vec::with_capacity(meta.len() as usize);
1101×
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
1111×
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
1120×
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
1131×
    }
1141×
    Ok(bytes)
1152×
}
116
117
/// Most files a report keeps (oxygenhub#127).
118
pub const MAX_REPORT_FILES: usize = 2000;
119
/// Most bytes a report's files hold together.
120
pub const MAX_REPORT_BYTES: u64 = 512 << 20;
121
122
/// Report directory `inner` of ext4 image `image`: dumped whole with
123
/// debugfs into `scratch`, then read as [`read_tree`] does.
1242×
pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> {
1252×
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
1262×
    if !stat.contains("Type: directory") {
1271×
        bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml");
1281×
    }
1291×
    let dest = scratch.join("report.out");
1301×
    let _ = std::fs::remove_dir_all(&dest);
1311×
    std::fs::create_dir_all(&dest)?;
1321×
    debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?;
1331×
    let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
1341×
    let files = read_tree(&dest.join(base));
1351×
    let _ = std::fs::remove_dir_all(&dest);
1361×
    files
1372×
}
138
139
/// Report directory `inner` under `base` for the syscall tier: every
140
/// component of the path must be a directory, never a link, so a job
141
/// cannot point the runner outside its own tree.
1424×
pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> {
1434×
    let mut dir = base.to_path_buf();
14412×
    for part in inner.split('/').filter(|p| !p.is_empty()) {
1458×
        if part == "." || part == ".." {
1461×
            bail!("{inner} is not a clean path");
1477×
        }
1487×
        dir.push(part);
1497×
        let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?;
1507×
        if !meta.is_dir() {
1512×
            bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml");
1525×
        }
153
    }
1541×
    read_tree(&dir)
1554×
}
156
157
/// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path
158
/// travels in a header and as a URL as it is.
15916×
fn report_name_ok(n: &str) -> bool {
16099×
    !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
16116×
}
162
163
/// The regular files under host directory `root`, by `/`-separated path
164
/// relative to it, sorted. Links and anything that is not a file or a
165
/// directory are left out, never followed. Within the report limits, and
166
/// `report.toml` must be at the top.
1674×
pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
1684×
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
1694×
    let mut total = 0u64;
1704×
    let mut stack = vec![(root.to_path_buf(), String::new())];
17110×
    while let Some((dir, rel)) = stack.pop() {
17216×
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
17316×
            let entry = entry?;
17416×
            let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?;
17516×
            let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
17616×
            if !report_name_ok(&name) {
1771×
                bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'");
17815×
            }
17915×
            let meta = std::fs::symlink_metadata(entry.path())?;
18015×
            if meta.is_dir() {
1813×
                stack.push((entry.path(), path));
18212×
            } else if meta.is_file() {
1837×
                if meta.len() > MAX_ARTIFACT_BYTES {
1840×
                    bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
1857×
                }
1867×
                total += meta.len();
1877×
                if total > MAX_REPORT_BYTES {
1880×
                    bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20);
1897×
                }
1907×
                if out.len() == MAX_REPORT_FILES {
1910×
                    bail!("the report holds more than {MAX_REPORT_FILES} files");
1927×
                }
1937×
                out.push((path, std::fs::read(entry.path())?));
1945×
            }
195
        }
196
    }
1975×
    if !out.iter().any(|(p, _)| p == "report.toml") {
1981×
        bail!("the report directory has no report.toml at its top");
1992×
    }
2002×
    out.sort();
2012×
    Ok(out)
2024×
}
203
2047×
fn debugfs(image: &Path, request: &str) -> Result<String> {
2057×
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
2067×
    if !out.status.success() {
2070×
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
2087×
    }
2097×
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
2107×
}
211
212
/// Read regular file `inner` of ext4 image `image`, at most
213
/// [`MAX_ARTIFACT_BYTES`]. Paths come from a checked pipeline: absolute,
214
/// clean, and free of quotes and spaces.
2153×
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
2163×
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
2173×
    if !stat.contains("Type: regular") {
2182×
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
2191×
    }
2201×
    let size: u64 = stat
2211×
        .split("Size: ")
2221×
        .nth(1)
2231×
        .and_then(|s| s.split_whitespace().next())
2241×
        .and_then(|s| s.parse().ok())
2251×
        .context("debugfs gave no size")?;
2261×
    if size > MAX_ARTIFACT_BYTES {
2270×
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
2281×
    }
2291×
    let dest = scratch.join("artifact.out");
2301×
    let _ = std::fs::remove_file(&dest);
2311×
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
2321×
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
2331×
    let _ = std::fs::remove_file(&dest);
2341×
    if bytes.len() as u64 != size {
2350×
        bail!("{inner}: read {} bytes of {size}", bytes.len());
2361×
    }
2371×
    Ok(bytes)
2383×
}
239
240
#[cfg(test)]
241
mod tests {
242
    use super::*;
243
    use flow_core::Mount;
244
2455×
    fn job() -> Job {
2465×
        Job {
2475×
            name: "build".into(),
2485×
            sandbox: "rust".into(),
2495×
            scope: vec![],
2505×
            depends_on: vec![],
2515×
            env: Default::default(),
2525×
            exec: vec!["make".into()],
2535×
            secrets: vec![],
2545×
            identities: vec![],
2555×
            mounts: vec![
2565×
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
2575×
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
2585×
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
2595×
            ],
2605×
            outputs: vec![],
2615×
            inputs: vec![],
2625×
            approval: None,
2635×
            environment: None,
2645×
            runs_on: vec![],
2655×
            timeout_s: None,
2665×
            on: None,
2675×
            priority: None,
2685×
            checkpoints: Vec::new(),
2695×
        }
2705×
    }
271
272
    #[test]
2731×
    fn paths_are_found_on_the_disk_that_holds_them() {
2741×
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
2751×
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
2761×
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
2771×
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
2781×
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
2791×
    }
280
281
    #[test]
2821×
    fn files_are_read_from_directories_without_following_links() {
2831×
        let dir = tempfile::tempdir().unwrap();
2841×
        let tree = dir.path().join("tree");
2851×
        std::fs::create_dir_all(tree.join("release")).unwrap();
2861×
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
2871×
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
2881×
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
2891×
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
2901×
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
2911×
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
2921×
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
2931×
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
2941×
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
2951×
        let s = Path::new("/scratch");
2961×
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
2971×
    }
298
299
    #[test]
3001×
    fn reports_are_read_as_trees_without_following_links() {
3011×
        let dir = tempfile::tempdir().unwrap();
3021×
        let tree = dir.path().join("tree");
3031×
        let report = tree.join("out/report");
3041×
        std::fs::create_dir_all(report.join("data")).unwrap();
3051×
        std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap();
3061×
        std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap();
3071×
        std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap();
3081×
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
3091×
        std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap();
3101×
        std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap();
3111×
        let files = read_report_dir(&tree, "/out/report").unwrap();
3123×
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
3131×
        assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out");
3141×
        assert_eq!(files[0].1, b"1,2\n");
315
3161×
        std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap();
3171×
        assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory");
3181×
        assert!(read_report_dir(&tree, "/out/../out/report").is_err());
3191×
        assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file");
320
3211×
        std::fs::remove_file(report.join("report.toml")).unwrap();
3221×
        assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml"));
3231×
        std::fs::write(report.join("report.toml"), b"").unwrap();
3241×
        std::fs::write(report.join("bad name"), b"").unwrap();
3251×
        assert!(read_tree(&report).is_err(), "a name that cannot travel as is");
3261×
    }
327
328
    #[test]
3291×
    fn files_are_read_back_from_an_ext4_image() {
3301×
        let dir = tempfile::tempdir().unwrap();
3311×
        let tree = dir.path().join("tree");
3321×
        std::fs::create_dir_all(tree.join("release")).unwrap();
333200000×
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
3341×
        std::fs::write(tree.join("release/tool"), &body).unwrap();
3351×
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
3361×
        let image = dir.path().join("disk.ext4");
3371×
        crate::workspace::image(&tree, &image).unwrap();
3381×
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
3391×
        assert!(read(&image, "/release/missing", dir.path()).is_err());
3401×
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
341
3421×
        std::fs::create_dir_all(tree.join("report/data")).unwrap();
3431×
        std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap();
3441×
        std::fs::write(tree.join("report/data/a.csv"), &body).unwrap();
3451×
        std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap();
3461×
        crate::workspace::image(&tree, &image).unwrap();
3471×
        let files = read_report(&image, "/report", dir.path()).unwrap();
3482×
        assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]);
3491×
        assert_eq!(files[0].1, body);
3501×
        assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report");
3511×
    }
352
}