OXYGENHUB
# 0d663e91c598 oxygenflow / crates / flow / src / outputs.rs
16.6 KiB · Rust · last changed flow builds for Windows, offering the WASM tier 10 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
#[cfg(unix)]
39
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
40
    use std::ffi::CString;
41
    use std::os::fd::FromRawFd;
42
    let parts = clean_parts(inner)?;
43
    let c = |s: &str| CString::new(s).context("a path holds NUL");
44
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
45
    if dir < 0 {
46
        bail!("open {}: {}", base.display(), std::io::Error::last_os_error());
47
    }
48
    let (last, dirs) = parts.split_last().unwrap();
49
    for d in dirs {
50
        let next = unsafe { libc::openat(dir, c(d)?.as_ptr(), libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC) };
51
        let err = std::io::Error::last_os_error();
52
        unsafe { libc::close(dir) };
53
        if next < 0 {
54
            bail!("{inner}: {d} is not a directory in the job's files ({err})");
55
        }
56
        dir = next;
57
    }
58
    let fd = unsafe { libc::openat(dir, c(last)?.as_ptr(), libc::O_NOFOLLOW | libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NONBLOCK) };
59
    let err = std::io::Error::last_os_error();
60
    unsafe { libc::close(dir) };
61
    if fd < 0 {
62
        bail!("{inner} is not a regular file in the job's files ({err})");
63
    }
64
    read_open(unsafe { std::fs::File::from_raw_fd(fd) }, inner)
65
}
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 `..`.
91
fn clean_parts(inner: &str) -> Result<Vec<&str>> {
92
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
93
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
94
        bail!("{inner} is not a clean path");
95
    }
96
    Ok(parts)
97
}
98
99
/// Read an opened artifact file, at most [`MAX_ARTIFACT_BYTES`].
100
fn read_open(file: std::fs::File, inner: &str) -> Result<Vec<u8>> {
101
    use std::io::Read;
102
    let meta = file.metadata()?;
103
    if !meta.is_file() {
104
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
105
    }
106
    if meta.len() > MAX_ARTIFACT_BYTES {
107
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
108
    }
109
    let mut bytes = Vec::with_capacity(meta.len() as usize);
110
    file.take(MAX_ARTIFACT_BYTES + 1).read_to_end(&mut bytes)?;
111
    if bytes.len() as u64 > MAX_ARTIFACT_BYTES {
112
        bail!("{inner} grew past {} MiB while it was read", MAX_ARTIFACT_BYTES >> 20);
113
    }
114
    Ok(bytes)
115
}
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.
124
pub fn read_report(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<(String, Vec<u8>)>> {
125
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
126
    if !stat.contains("Type: directory") {
127
        bail!("{inner} is not a directory in the job's disk: a report is a directory holding report.toml");
128
    }
129
    let dest = scratch.join("report.out");
130
    let _ = std::fs::remove_dir_all(&dest);
131
    std::fs::create_dir_all(&dest)?;
132
    debugfs(image, &format!("rdump \"{inner}\" \"{}\"", dest.display()))?;
133
    let base = inner.trim_end_matches('/').rsplit('/').next().unwrap_or_default();
134
    let files = read_tree(&dest.join(base));
135
    let _ = std::fs::remove_dir_all(&dest);
136
    files
137
}
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.
142
pub fn read_report_dir(base: &Path, inner: &str) -> Result<Vec<(String, Vec<u8>)>> {
143
    let mut dir = base.to_path_buf();
144
    for part in inner.split('/').filter(|p| !p.is_empty()) {
145
        if part == "." || part == ".." {
146
            bail!("{inner} is not a clean path");
147
        }
148
        dir.push(part);
149
        let meta = std::fs::symlink_metadata(&dir).with_context(|| format!("{inner}: no such directory in the job's tree"))?;
150
        if !meta.is_dir() {
151
            bail!("{inner} is not a directory in the job's tree (a link is not followed): a report is a directory holding report.toml");
152
        }
153
    }
154
    read_tree(&dir)
155
}
156
157
/// A report file name: letters, digits, `.`, `_`, `-` and `+`, so a path
158
/// travels in a header and as a URL as it is.
159
fn report_name_ok(n: &str) -> bool {
160
    !n.is_empty() && n != "." && n != ".." && n.len() <= 255 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
161
}
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.
167
pub fn read_tree(root: &Path) -> Result<Vec<(String, Vec<u8>)>> {
168
    let mut out: Vec<(String, Vec<u8>)> = Vec::new();
169
    let mut total = 0u64;
170
    let mut stack = vec![(root.to_path_buf(), String::new())];
171
    while let Some((dir, rel)) = stack.pop() {
172
        for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
173
            let entry = entry?;
174
            let name = entry.file_name().into_string().map_err(|_| anyhow::anyhow!("a report file name is not UTF-8"))?;
175
            let path = if rel.is_empty() { name.clone() } else { format!("{rel}/{name}") };
176
            if !report_name_ok(&name) {
177
                bail!("report file {path:?}: names may hold letters, digits, '.', '_', '-' and '+'");
178
            }
179
            let meta = std::fs::symlink_metadata(entry.path())?;
180
            if meta.is_dir() {
181
                stack.push((entry.path(), path));
182
            } else if meta.is_file() {
183
                if meta.len() > MAX_ARTIFACT_BYTES {
184
                    bail!("report file {path} is {} MiB; a file is at most {} MiB", meta.len() >> 20, MAX_ARTIFACT_BYTES >> 20);
185
                }
186
                total += meta.len();
187
                if total > MAX_REPORT_BYTES {
188
                    bail!("the report holds more than {} MiB", MAX_REPORT_BYTES >> 20);
189
                }
190
                if out.len() == MAX_REPORT_FILES {
191
                    bail!("the report holds more than {MAX_REPORT_FILES} files");
192
                }
193
                out.push((path, std::fs::read(entry.path())?));
194
            }
195
        }
196
    }
197
    if !out.iter().any(|(p, _)| p == "report.toml") {
198
        bail!("the report directory has no report.toml at its top");
199
    }
200
    out.sort();
201
    Ok(out)
202
}
203
204
fn debugfs(image: &Path, request: &str) -> Result<String> {
205
    let out = std::process::Command::new("debugfs").arg("-R").arg(request).arg(image).output().context("run debugfs (e2fsprogs)")?;
206
    if !out.status.success() {
207
        bail!("debugfs {request:?}: {}", String::from_utf8_lossy(&out.stderr).trim());
208
    }
209
    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
210
}
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.
215
pub fn read(image: &Path, inner: &str, scratch: &Path) -> Result<Vec<u8>> {
216
    let stat = debugfs(image, &format!("stat \"{inner}\""))?;
217
    if !stat.contains("Type: regular") {
218
        bail!("{inner} is not a regular file in the job's disk (it may be missing or a link)");
219
    }
220
    let size: u64 = stat
221
        .split("Size: ")
222
        .nth(1)
223
        .and_then(|s| s.split_whitespace().next())
224
        .and_then(|s| s.parse().ok())
225
        .context("debugfs gave no size")?;
226
    if size > MAX_ARTIFACT_BYTES {
227
        bail!("{inner} is {} MiB; artifacts are at most {} MiB", size >> 20, MAX_ARTIFACT_BYTES >> 20);
228
    }
229
    let dest = scratch.join("artifact.out");
230
    let _ = std::fs::remove_file(&dest);
231
    debugfs(image, &format!("dump \"{inner}\" \"{}\"", dest.display()))?;
232
    let bytes = std::fs::read(&dest).with_context(|| format!("{inner}: debugfs wrote nothing"))?;
233
    let _ = std::fs::remove_file(&dest);
234
    if bytes.len() as u64 != size {
235
        bail!("{inner}: read {} bytes of {size}", bytes.len());
236
    }
237
    Ok(bytes)
238
}
239
240
#[cfg(test)]
241
mod tests {
242
    use super::*;
243
    use flow_core::Mount;
244
245
    fn job() -> Job {
246
        Job {
247
            name: "build".into(),
248
            sandbox: "rust".into(),
249
            scope: vec![],
250
            depends_on: vec![],
251
            env: Default::default(),
252
            exec: vec!["make".into()],
253
            secrets: vec![],
254
            identities: vec![],
255
            mounts: vec![
256
                Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64, cache: None, key: vec![] },
257
                Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024, cache: None, key: vec![] },
258
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
259
            ],
260
            outputs: vec![],
261
            inputs: vec![],
262
            approval: None,
263
            environment: None,
264
            runs_on: vec![],
265
            timeout_s: None,
266
            on: None,
267
            priority: None,
268
            checkpoints: Vec::new(),
269
        }
270
    }
271
272
    #[test]
273
    fn paths_are_found_on_the_disk_that_holds_them() {
274
        let (w, s) = (Path::new("/w.ext4"), Path::new("/scratch"));
275
        assert_eq!(locate(&job(), w, s, "/src/target/release/oxy"), (s.join("mount-0.ext4"), "/release/oxy".into()));
276
        assert_eq!(locate(&job(), w, s, "/src/work/a"), (s.join("mount-1.ext4"), "/a".into()));
277
        assert_eq!(locate(&job(), w, s, "/src/docs/a.1"), (w.to_path_buf(), "/docs/a.1".into()));
278
        assert_eq!(locate(&job(), w, s, "/src/targets/x"), (w.to_path_buf(), "/targets/x".into()), "a prefix is not a parent");
279
    }
280
281
    #[test]
282
    fn files_are_read_from_directories_without_following_links() {
283
        let dir = tempfile::tempdir().unwrap();
284
        let tree = dir.path().join("tree");
285
        std::fs::create_dir_all(tree.join("release")).unwrap();
286
        std::fs::write(tree.join("release/tool"), b"built").unwrap();
287
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
288
        std::os::unix::fs::symlink(dir.path().join("secret"), tree.join("release/link")).unwrap();
289
        std::os::unix::fs::symlink(dir.path(), tree.join("up")).unwrap();
290
        assert_eq!(read_dir_file(&tree, "/release/tool").unwrap(), b"built");
291
        assert!(read_dir_file(&tree, "/release/link").is_err(), "a link to a host file");
292
        assert!(read_dir_file(&tree, "/up/secret").is_err(), "a linked directory");
293
        assert!(read_dir_file(&tree, "/release/../release/tool").is_err());
294
        assert!(read_dir_file(&tree, "/release").is_err(), "a directory");
295
        let s = Path::new("/scratch");
296
        assert_eq!(locate_dir(&job(), &tree, s, "/src/target/a"), (s.join("mount-0"), "/a".into()));
297
    }
298
299
    #[test]
300
    fn reports_are_read_as_trees_without_following_links() {
301
        let dir = tempfile::tempdir().unwrap();
302
        let tree = dir.path().join("tree");
303
        let report = tree.join("out/report");
304
        std::fs::create_dir_all(report.join("data")).unwrap();
305
        std::fs::write(report.join("report.toml"), b"kind = \"custom\"\n").unwrap();
306
        std::fs::write(report.join("index.html"), b"<p>hi</p>").unwrap();
307
        std::fs::write(report.join("data/frames.csv"), b"1,2\n").unwrap();
308
        std::fs::write(dir.path().join("secret"), b"runner key").unwrap();
309
        std::os::unix::fs::symlink(dir.path().join("secret"), report.join("data/leak")).unwrap();
310
        std::os::unix::fs::symlink(dir.path(), report.join("up")).unwrap();
311
        let files = read_report_dir(&tree, "/out/report").unwrap();
312
        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
313
        assert_eq!(paths, ["data/frames.csv", "index.html", "report.toml"], "sorted, links left out");
314
        assert_eq!(files[0].1, b"1,2\n");
315
316
        std::os::unix::fs::symlink(&report, tree.join("linked")).unwrap();
317
        assert!(read_report_dir(&tree, "/linked").is_err(), "a linked report directory");
318
        assert!(read_report_dir(&tree, "/out/../out/report").is_err());
319
        assert!(read_report_dir(&tree, "/out/report/index.html").is_err(), "a file");
320
321
        std::fs::remove_file(report.join("report.toml")).unwrap();
322
        assert!(format!("{:#}", read_tree(&report).unwrap_err()).contains("report.toml"));
323
        std::fs::write(report.join("report.toml"), b"").unwrap();
324
        std::fs::write(report.join("bad name"), b"").unwrap();
325
        assert!(read_tree(&report).is_err(), "a name that cannot travel as is");
326
    }
327
328
    #[test]
329
    fn files_are_read_back_from_an_ext4_image() {
330
        let dir = tempfile::tempdir().unwrap();
331
        let tree = dir.path().join("tree");
332
        std::fs::create_dir_all(tree.join("release")).unwrap();
333
        let body: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
334
        std::fs::write(tree.join("release/tool"), &body).unwrap();
335
        std::os::unix::fs::symlink("tool", tree.join("release/link")).unwrap();
336
        let image = dir.path().join("disk.ext4");
337
        crate::workspace::image(&tree, &image).unwrap();
338
        assert_eq!(read(&image, "/release/tool", dir.path()).unwrap(), body);
339
        assert!(read(&image, "/release/missing", dir.path()).is_err());
340
        assert!(read(&image, "/release/link", dir.path()).unwrap_err().to_string().contains("not a regular file"));
341
342
        std::fs::create_dir_all(tree.join("report/data")).unwrap();
343
        std::fs::write(tree.join("report/report.toml"), b"kind = \"custom\"\n").unwrap();
344
        std::fs::write(tree.join("report/data/a.csv"), &body).unwrap();
345
        std::os::unix::fs::symlink("/etc/passwd", tree.join("report/data/leak")).unwrap();
346
        crate::workspace::image(&tree, &image).unwrap();
347
        let files = read_report(&image, "/report", dir.path()).unwrap();
348
        assert_eq!(files.iter().map(|(p, _)| p.as_str()).collect::<Vec<_>>(), ["data/a.csv", "report.toml"]);
349
        assert_eq!(files[0].1, body);
350
        assert!(read_report(&image, "/release/tool", dir.path()).is_err(), "a file is not a report");
351
    }
352
}