OXYGENHUB

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

248 of 261 lines run, 31 of 62 functions · run #177 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.
385×
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
39
    use std::ffi::CString;
40
    use std::io::Read;
41
    use std::os::fd::FromRawFd;
4216×
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
439×
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
441×
        bail!("{inner} is not a clean path");
454×
    }
4610×
    let c = |s: &str| CString::new(s).context("a path holds NUL");
474×
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
484×
    if dir < 0 {
490×
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
504×
    }
514×
    let (last, dirs) = parts.split_last().unwrap();
524×
    for d in dirs {
533×
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
543×
        let err = std::io::Error::last_os_error();
553×
        unsafe { libc::close(dir) };
563×
        if next < 0 {
571×
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
582×
        }
592×
        dir = next;
60
    }
613×
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
623×
    let err = std::io::Error::last_os_error();
633×
    unsafe { libc::close(dir) };
643×
    if fd < 0 {
651×
        bail!("{inner} is not a regular file in the job's files ({err})");
662×
    }
672×
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
682×
    let meta = file.metadata()?;
692×
    if !meta.is_file() {
701×
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
711×
    }
721×
    if meta.len() > MAX_ARTIFACT_BYTES {
730×
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
741×
    }
751×
    let mut bytes = Vec::with_capacity(meta.len() as usize);
761×
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
771×
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
780×
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
791×
    }
801×
    Ok(bytes)
815×
}
82
83
/// Most files a report keeps (oxygenhub#127).
84
pub const MAX_REPORT_FILES: usize = 2000;
85
/// Most bytes a report's files hold together.
86
pub const MAX_REPORT_BYTES: u64 = 512 << 20;
87
88
/// Report directory `inner` of ext4 image `image`: dumped whole with
89
/// debugfs into `scratch`, then read as [`read_tree`] does.
902×
pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> {
912×
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
922×
    if !stat.contains("Type: directory") {
931×
        bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml");
941×
    }
951×
    let dest = scratch.join("report.out");
961×
    let _ = std::fs::remove_dir_all(&dest);
971×
    std::fs::create_dir_all(&dest)?;
981×
    debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?;
991×
    let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
1001×
    let files = read_tree(&dest.join(base));
1011×
    let _ = std::fs::remove_dir_all(&dest);
1021×
    files
1032×
}
104
105
/// Report directory `inner` under `base` for the syscall tier: every
106
/// component of the path must be a directory, never a link, so a job
107
/// cannot point the runner outside its own tree.
1084×
pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> {
1094×
    let mut dir = base.to_path_buf();
11012×
    for part in inner.split('/').filter(|p| !p.is_empty()) {
1118×
        if part == "." || part == ".." {
1121×
            bail!("{inner} is not a clean path");
1137×
        }
1147×
        dir.push(part);
1157×
        let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?;
1167×
        if !meta.is_dir() {
1172×
            bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml");
1185×
        }
119
    }
1201×
    read_tree(&dir)
1214×
}
122
123
/// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path
124
/// travels in a header and as a URL as it is.
12516×
fn report_name_ok(n: &str) -> bool {
12699×
    !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
12716×
}
128
129
/// The regular files under host directory `root`, by `/`-separated path
130
/// relative to it, sorted. Links and anything that is not a file or a
131
/// directory are left out, never followed. Within the report limits, and
132
/// `report.toml` must be at the top.
1334×
pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
1344×
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
1354×
    let mut total = 0u64;
1364×
    let mut stack = vec![(root.to_path_buf(), String::new())];
13710×
    while let Some((dir, rel)) = stack.pop() {
13816×
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
13916×
            let entry = entry?;
14016×
            let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?;
14116×
            let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
14216×
            if !report_name_ok(&name) {
1431×
                bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'");
14415×
            }
14515×
            let meta = std::fs::symlink_metadata(entry.path())?;
14615×
            if meta.is_dir() {
1473×
                stack.push((entry.path(), path));
14812×
            } else if meta.is_file() {
1497×
                if meta.len() > MAX_ARTIFACT_BYTES {
1500×
                    bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
1517×
                }
1527×
                total += meta.len();
1537×
                if total > MAX_REPORT_BYTES {
1540×
                    bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20);
1557×
                }
1567×
                if out.len() == MAX_REPORT_FILES {
1570×
                    bail!("the report holds more than {MAX_REPORT_FILES} files");
1587×
                }
1597×
                out.push((path, std::fs::read(entry.path())?));
1605×
            }
161
        }
162
    }
1635×
    if !out.iter().any(|(p, _)| p == "report.toml") {
1641×
        bail!("the report directory has no report.toml at its top");
1652×
    }
1662×
    out.sort();
1672×
    Ok(out)
1684×
}
169
1707×
fn debugfs(image: &Path, request: &str) -> Result<String> {
1717×
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
1727×
    if !out.status.success() {
1730×
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
1747×
    }
1757×
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
1767×
}
177
178
/// Read regular file `inner` of ext4 image `image`, at most
179
/// [`MAX_ARTIFACT_BYTES`]. Paths come from a checked pipeline: absolute,
180
/// clean, and free of quotes and spaces.
1813×
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
1823×
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
1833×
    if !stat.contains("Type: regular") {
1842×
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
1851×
    }
1861×
    let size: u64 = stat
1871×
        .split("Size: ")
1881×
        .nth(1)
1891×
        .and_then(|s| s.split_whitespace().next())
1901×
        .and_then(|s| s.parse().ok())
1911×
        .context("debugfs gave no size")?;
1921×
    if size > MAX_ARTIFACT_BYTES {
1930×
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
1941×
    }
1951×
    let dest = scratch.join("artifact.out");
1961×
    let _ = std::fs::remove_file(&dest);
1971×
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
1981×
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
1991×
    let _ = std::fs::remove_file(&dest);
2001×
    if bytes.len() as u64 != size {
2010×
        bail!("{inner}: read {} bytes of {size}", bytes.len());
2021×
    }
2031×
    Ok(bytes)
2043×
}
205
206
#[cfg(test)]
207
mod tests {
208
    use super::*;
209
    use flow_core::Mount;
210
2115×
    fn job() -> Job {
2125×
        Job {
2135×
            name: "build".into(),
2145×
            sandbox: "rust".into(),
2155×
            scope: vec![],
2165×
            depends_on: vec![],
2175×
            env: Default::default(),
2185×
            exec: vec!["make".into()],
2195×
            secrets: vec![],
2205×
            identities: vec![],
2215×
            mounts: vec![
2225×
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
2235×
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
2245×
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
2255×
            ],
2265×
            outputs: vec![],
2275×
            inputs: vec![],
2285×
            approval: None,
2295×
            environment: None,
2305×
            runs_on: vec![],
2315×
            timeout_s: None,
2325×
            on: None,
2335×
            priority: None,
2345×
        }
2355×
    }
236
237
    #[test]
2381×
    fn paths_are_found_on_the_disk_that_holds_them() {
2391×
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
2401×
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
2411×
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
2421×
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
2431×
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
2441×
    }
245
246
    #[test]
2471×
    fn files_are_read_from_directories_without_following_links() {
2481×
        let dir = tempfile::tempdir().unwrap();
2491×
        let tree = dir.path().join("tree");
2501×
        std::fs::create_dir_all(tree.join("release")).unwrap();
2511×
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
2521×
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
2531×
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
2541×
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
2551×
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
2561×
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
2571×
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
2581×
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
2591×
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
2601×
        let s = Path::new("/scratch");
2611×
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
2621×
    }
263
264
    #[test]
2651×
    fn reports_are_read_as_trees_without_following_links() {
2661×
        let dir = tempfile::tempdir().unwrap();
2671×
        let tree = dir.path().join("tree");
2681×
        let report = tree.join("out/report");
2691×
        std::fs::create_dir_all(report.join("data")).unwrap();
2701×
        std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap();
2711×
        std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap();
2721×
        std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap();
2731×
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
2741×
        std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap();
2751×
        std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap();
2761×
        let files = read_report_dir(&tree, "/out/report").unwrap();
2773×
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
2781×
        assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out");
2791×
        assert_eq!(files[0].1, b"1,2\n");
280
2811×
        std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap();
2821×
        assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory");
2831×
        assert!(read_report_dir(&tree, "/out/../out/report").is_err());
2841×
        assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file");
285
2861×
        std::fs::remove_file(report.join("report.toml")).unwrap();
2871×
        assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml"));
2881×
        std::fs::write(report.join("report.toml"), b"").unwrap();
2891×
        std::fs::write(report.join("bad name"), b"").unwrap();
2901×
        assert!(read_tree(&report).is_err(), "a name that cannot travel as is");
2911×
    }
292
293
    #[test]
2941×
    fn files_are_read_back_from_an_ext4_image() {
2951×
        let dir = tempfile::tempdir().unwrap();
2961×
        let tree = dir.path().join("tree");
2971×
        std::fs::create_dir_all(tree.join("release")).unwrap();
298200000×
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
2991×
        std::fs::write(tree.join("release/tool"), &body).unwrap();
3001×
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
3011×
        let image = dir.path().join("disk.ext4");
3021×
        crate::workspace::image(&tree, &image).unwrap();
3031×
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
3041×
        assert!(read(&image, "/release/missing", dir.path()).is_err());
3051×
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
306
3071×
        std::fs::create_dir_all(tree.join("report/data")).unwrap();
3081×
        std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap();
3091×
        std::fs::write(tree.join("report/data/a.csv"), &body).unwrap();
3101×
        std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap();
3111×
        crate::workspace::image(&tree, &image).unwrap();
3121×
        let files = read_report(&image, "/report", dir.path()).unwrap();
3132×
        assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]);
3141×
        assert_eq!(files[0].1, body);
3151×
        assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report");
3161×
    }
317
}