OXYGENHUB
# c59b9660678c oxygenflow / crates / flow / src / outputs.rs
15.1 KiB · Rust · last changed Environments: environment "prod" on a job 20 days ago source blame history raw
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.
15
pub fn locate(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
16
    locate_in(job, workspace, path, |i| scratch.join(format!("mount-{i}.ext4")))
17
}
18
19
/// [`locate`] for the syscall tier, where the workspace and each disk
20
/// mount are directories (`crate::syscall::disk_dir`).
21
pub fn locate_dir(job: &Job, workspace: &Path, scratch: &Path, path: &str) -> (PathBuf, String) {
22
    locate_in(job, workspace, path, |i| crate::syscall::disk_dir(scratch, i))
23
}
24
25
fn locate_in(job: &Job, workspace: &Path, path: &str, disk: impl Fn(usize) -> PathBuf) -> (PathBuf, String) {
26
    for (i, m) in job.mounts.iter().filter(|m| m.source == MountSource::Disk).enumerate() {
27
        if let Some(rest) = path.strip_prefix(&format!("{}/", m.target)) {
28
            return (disk(i), format!("/{rest}"));
29
        }
30
    }
31
    (workspace.to_path_buf(), path.strip_prefix("/src").unwrap_or(path).to_string())
32
}
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
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;
42
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
43
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
44
        bail!("{inner} is not a clean path");
45
    }
46
    let c = |s: &str| CString::new(s).context("a path holds NUL");
47
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
48
    if dir < 0 {
49
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
50
    }
51
    let (last, dirs) = parts.split_last().unwrap();
52
    for d in dirs {
53
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
54
        let err = std::io::Error::last_os_error();
55
        unsafe { libc::close(dir) };
56
        if next < 0 {
57
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
58
        }
59
        dir = next;
60
    }
61
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
62
    let err = std::io::Error::last_os_error();
63
    unsafe { libc::close(dir) };
64
    if fd < 0 {
65
        bail!("{inner} is not a regular file in the job's files ({err})");
66
    }
67
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
68
    let meta = file.metadata()?;
69
    if !meta.is_file() {
70
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
71
    }
72
    if meta.len() > MAX_ARTIFACT_BYTES {
73
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
74
    }
75
    let mut bytes = Vec::with_capacity(meta.len() as usize);
76
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
77
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
78
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
79
    }
80
    Ok(bytes)
81
}
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.
90
pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> {
91
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
92
    if !stat.contains("Type: directory") {
93
        bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml");
94
    }
95
    let dest = scratch.join("report.out");
96
    let _ = std::fs::remove_dir_all(&dest);
97
    std::fs::create_dir_all(&dest)?;
98
    debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?;
99
    let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
100
    let files = read_tree(&dest.join(base));
101
    let _ = std::fs::remove_dir_all(&dest);
102
    files
103
}
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.
108
pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> {
109
    let mut dir = base.to_path_buf();
110
    for part in inner.split('/').filter(|p| !p.is_empty()) {
111
        if part == "." || part == ".." {
112
            bail!("{inner} is not a clean path");
113
        }
114
        dir.push(part);
115
        let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?;
116
        if !meta.is_dir() {
117
            bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml");
118
        }
119
    }
120
    read_tree(&dir)
121
}
122
123
/// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path
124
/// travels in a header and as a URL as it is.
125
fn report_name_ok(n: &str) -> bool {
126
    !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
127
}
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.
133
pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
134
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
135
    let mut total = 0u64;
136
    let mut stack = vec![(root.to_path_buf(), String::new())];
137
    while let Some((dir, rel)) = stack.pop() {
138
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
139
            let entry = entry?;
140
            let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?;
141
            let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
142
            if !report_name_ok(&name) {
143
                bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'");
144
            }
145
            let meta = std::fs::symlink_metadata(entry.path())?;
146
            if meta.is_dir() {
147
                stack.push((entry.path(), path));
148
            } else if meta.is_file() {
149
                if meta.len() > MAX_ARTIFACT_BYTES {
150
                    bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
151
                }
152
                total += meta.len();
153
                if total > MAX_REPORT_BYTES {
154
                    bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20);
155
                }
156
                if out.len() == MAX_REPORT_FILES {
157
                    bail!("the report holds more than {MAX_REPORT_FILES} files");
158
                }
159
                out.push((path, std::fs::read(entry.path())?));
160
            }
161
        }
162
    }
163
    if !out.iter().any(|(p, _)| p == "report.toml") {
164
        bail!("the report directory has no report.toml at its top");
165
    }
166
    out.sort();
167
    Ok(out)
168
}
169
170
fn debugfs(image: &Path, request: &str) -> Result<String> {
171
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
172
    if !out.status.success() {
173
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
174
    }
175
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
176
}
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.
181
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
182
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
183
    if !stat.contains("Type: regular") {
184
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
185
    }
186
    let size: u64 = stat
187
        .split("Size: ")
188
        .nth(1)
189
        .and_then(|s| s.split_whitespace().next())
190
        .and_then(|s| s.parse().ok())
191
        .context("debugfs gave no size")?;
192
    if size > MAX_ARTIFACT_BYTES {
193
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
194
    }
195
    let dest = scratch.join("artifact.out");
196
    let _ = std::fs::remove_file(&dest);
197
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
198
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
199
    let _ = std::fs::remove_file(&dest);
200
    if bytes.len() as u64 != size {
201
        bail!("{inner}: read {} bytes of {size}", bytes.len());
202
    }
203
    Ok(bytes)
204
}
205
206
#[cfg(test)]
207
mod tests {
208
    use super::*;
209
    use flow_core::Mount;
210
211
    fn job() -> Job {
212
        Job {
213
            name: "build".into(),
214
            sandbox: "rust".into(),
215
            scope: vec![],
216
            depends_on: vec![],
217
            env: Default::default(),
218
            exec: vec!["make".into()],
219
            secrets: vec![],
220
            identities: vec![],
221
            mounts: vec![
222
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
223
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
224
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
225
            ],
226
            outputs: vec![],
227
            inputs: vec![],
228
            approval: None,
229
            environment: None,
230
            runs_on: vec![],
231
            timeout_s: None,
232
            on: None,
233
        }
234
    }
235
236
    #[test]
237
    fn paths_are_found_on_the_disk_that_holds_them() {
238
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
239
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
240
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
241
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
242
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
243
    }
244
245
    #[test]
246
    fn files_are_read_from_directories_without_following_links() {
247
        let dir = tempfile::tempdir().unwrap();
248
        let tree = dir.path().join("tree");
249
        std::fs::create_dir_all(tree.join("release")).unwrap();
250
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
251
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
252
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
253
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
254
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
255
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
256
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
257
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
258
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
259
        let s = Path::new("/scratch");
260
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
261
    }
262
263
    #[test]
264
    fn reports_are_read_as_trees_without_following_links() {
265
        let dir = tempfile::tempdir().unwrap();
266
        let tree = dir.path().join("tree");
267
        let report = tree.join("out/report");
268
        std::fs::create_dir_all(report.join("data")).unwrap();
269
        std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap();
270
        std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap();
271
        std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap();
272
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
273
        std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap();
274
        std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap();
275
        let files = read_report_dir(&tree, "/out/report").unwrap();
276
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
277
        assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out");
278
        assert_eq!(files[0].1, b"1,2\n");
279
280
        std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap();
281
        assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory");
282
        assert!(read_report_dir(&tree, "/out/../out/report").is_err());
283
        assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file");
284
285
        std::fs::remove_file(report.join("report.toml")).unwrap();
286
        assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml"));
287
        std::fs::write(report.join("report.toml"), b"").unwrap();
288
        std::fs::write(report.join("bad name"), b"").unwrap();
289
        assert!(read_tree(&report).is_err(), "a name that cannot travel as is");
290
    }
291
292
    #[test]
293
    fn files_are_read_back_from_an_ext4_image() {
294
        let dir = tempfile::tempdir().unwrap();
295
        let tree = dir.path().join("tree");
296
        std::fs::create_dir_all(tree.join("release")).unwrap();
297
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
298
        std::fs::write(tree.join("release/tool"), &body).unwrap();
299
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
300
        let image = dir.path().join("disk.ext4");
301
        crate::workspace::image(&tree, &image).unwrap();
302
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
303
        assert!(read(&image, "/release/missing", dir.path()).is_err());
304
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
305
306
        std::fs::create_dir_all(tree.join("report/data")).unwrap();
307
        std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap();
308
        std::fs::write(tree.join("report/data/a.csv"), &body).unwrap();
309
        std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap();
310
        crate::workspace::image(&tree, &image).unwrap();
311
        let files = read_report(&image, "/report", dir.path()).unwrap();
312
        assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]);
313
        assert_eq!(files[0].1, body);
314
        assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report");
315
    }
316
}