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