| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | | use anyhow::{bail, Context, Result}; |
| 13 | | use flow_core::pipeline::{Job, MountSource}; |
| 14 | | use std::path::{Path, PathBuf}; |
| 15 | | use std::time::{Duration, SystemTime, UNIX_EPOCH}; |
| 16 | |
|
| 17 | |
|
| 18 | | pub const MAX_BYTES: usize = 512 << 20; |
| 19 | |
|
| 20 | | const MAGIC: &[u8] = b"OXYFLOW-CHECKPOINT 1\n"; |
| 21 | | const DIR: u8 = 0; |
| 22 | | const FILE: u8 = 1; |
| 23 | |
|
| 24 | |
|
| 25 | | #[derive(Debug, Default, PartialEq, Eq)] |
| 26 | | pub struct Counts { |
| 27 | | pub files: usize, |
| 28 | | pub dirs: usize, |
| 29 | | pub bytes: u64, |
| 30 | | } |
| 31 | |
|
| 32 | |
|
| 33 | |
|
| 34 | |
|
| 35 | |
|
| 36 | 2× | pub fn host_dir(path: &str, job: &Job, tree: &Path, scratch: &Path, wasm: bool) -> Option<PathBuf> { |
| 37 | 2× | let under = |t: &str| path == t || path.starts_with(&format!("{}/", t.trim_end_matches('/'))); |
| 38 | 2× | let mut disks = 0; |
| 39 | 2× | let mut best: Option<(usize, PathBuf)> = None; |
| 40 | 2× | for (i, m) in job.mounts.iter().enumerate() { |
| 41 | 2× | let dir = match m.source { |
| 42 | | MountSource::Disk => { |
| 43 | 2× | disks += 1; |
| 44 | 2× | Some(crate::syscall::disk_dir(scratch, disks - 1)) |
| 45 | | } |
| 46 | 0× | MountSource::Tmpfs if wasm => Some(scratch.join(format!("tmpfs-{i}"))), |
| 47 | 0× | MountSource::Tmpfs => None, |
| 48 | | }; |
| 49 | 2× | if under(&m.target) && best.as_ref().is_none_or(|(len, _)| m.target.len() > *len) { |
| 50 | 1× | let rest = path[m.target.trim_end_matches('/').len()..].trim_start_matches('/'); |
| 51 | 1× | best = Some((m.target.len(), dir.map(|d| d.join(rest)).unwrap_or_default())); |
| 52 | 1× | } |
| 53 | | } |
| 54 | 1× | match best { |
| 55 | 1× | Some((_, d)) if d.as_os_str().is_empty() => None, |
| 56 | 1× | Some((_, d)) => Some(d), |
| 57 | 1× | None => Some(tree.join(path.trim_start_matches("/src").trim_start_matches('/'))), |
| 58 | | } |
| 59 | 2× | } |
| 60 | |
|
| 61 | |
|
| 62 | 1× | pub fn pack(dirs: &[(String, PathBuf)]) -> Result<(Vec<u8>, Counts)> { |
| 63 | 1× | let mut out = MAGIC.to_vec(); |
| 64 | 1× | let mut n = Counts::default(); |
| 65 | 1× | for (path, host) in dirs { |
| 66 | 1× | if !host.is_dir() { |
| 67 | 0× | continue; |
| 68 | 1× | } |
| 69 | 1× | let mut stack = vec![(host.clone(), path.trim_end_matches('/').to_string())]; |
| 70 | 6× | while let Some((at, name)) = stack.pop() { |
| 71 | 5× | let meta = std::fs::symlink_metadata(&at)?; |
| 72 | 5× | if meta.is_dir() { |
| 73 | 3× | entry(&mut out, DIR, &name, &meta, &[]); |
| 74 | 3× | n.dirs += 1; |
| 75 | 3× | let mut children: Vec<_> = std::fs::read_dir(&at)?.collect::<std::io::Result<_>>()?; |
| 76 | 3× | children.sort_by_key(|e| e.file_name()); |
| 77 | 4× | for c in children.into_iter().rev() { |
| 78 | 4× | let Some(child) = c.file_name().to_str().map(String::from) else { continue }; |
| 79 | 4× | stack.push((c.path(), format!("{name}/{child}"))); |
| 80 | | } |
| 81 | 2× | } else if meta.is_file() { |
| 82 | 2× | let bytes = std::fs::read(&at).with_context(|| format!("read {}", at.display()))?; |
| 83 | 2× | n.files += 1; |
| 84 | 2× | n.bytes += bytes.len() as u64; |
| 85 | 2× | entry(&mut out, FILE, &name, &meta, &bytes); |
| 86 | 0× | } |
| 87 | 5× | if out.len() > MAX_BYTES { |
| 88 | 0× | bail!("the checkpoint is over {} MiB", MAX_BYTES >> 20); |
| 89 | 5× | } |
| 90 | | } |
| 91 | | } |
| 92 | 1× | Ok((out, n)) |
| 93 | 1× | } |
| 94 | |
|
| 95 | 6× | fn entry(out: &mut Vec<u8>, kind: u8, path: &str, meta: &std::fs::Metadata, bytes: &[u8]) { |
| 96 | | use std::os::unix::fs::PermissionsExt; |
| 97 | 6× | let since = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).unwrap_or_default(); |
| 98 | 6× | out.push(kind); |
| 99 | 6× | out.extend((path.len() as u32).to_le_bytes()); |
| 100 | 6× | out.extend(path.as_bytes()); |
| 101 | 6× | out.extend(meta.permissions().mode().to_le_bytes()); |
| 102 | 6× | out.extend(since.as_secs().to_le_bytes()); |
| 103 | 6× | out.extend(since.subsec_nanos().to_le_bytes()); |
| 104 | 6× | out.extend((bytes.len() as u64).to_le_bytes()); |
| 105 | 6× | out.extend(bytes); |
| 106 | 6× | } |
| 107 | |
|
| 108 | |
|
| 109 | |
|
| 110 | 4× | pub fn unpack(bytes: &[u8], checkpoints: &[String], place: impl Fn(&str) -> Option<PathBuf>) -> Result<Counts> { |
| 111 | | use std::os::unix::fs::PermissionsExt; |
| 112 | 4× | let Some(mut rest) = bytes.strip_prefix(MAGIC) else { bail!("not a checkpoint") }; |
| 113 | 3× | let mut n = Counts::default(); |
| 114 | 56× | let take = |rest: &mut &[u8], k: usize| -> Result<Vec<u8>> { |
| 115 | 56× | if rest.len() < k { |
| 116 | 0× | bail!("the checkpoint is cut short"); |
| 117 | 56× | } |
| 118 | 56× | let (a, b) = rest.split_at(k); |
| 119 | 56× | *rest = b; |
| 120 | 56× | Ok(a.to_vec()) |
| 121 | 56× | }; |
| 122 | 8× | while !rest.is_empty() { |
| 123 | 7× | let kind = take(&mut rest, 1)?[0]; |
| 124 | 7× | let len = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap()) as usize; |
| 125 | 7× | let path = String::from_utf8(take(&mut rest, len)?).context("a path that is not UTF-8")?; |
| 126 | 7× | let mode = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap()); |
| 127 | 7× | let secs = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap()); |
| 128 | 7× | let nanos = u32::from_le_bytes(take(&mut rest, 4)?.try_into().unwrap()); |
| 129 | 7× | let size = u64::from_le_bytes(take(&mut rest, 8)?.try_into().unwrap()) as usize; |
| 130 | 7× | let data = take(&mut rest, size)?; |
| 131 | | |
| 132 | 7× | let Some(root) = checkpoints.iter().map(|c| c.trim_end_matches('/')).find(|c| path == *c || path.starts_with(&format!("{c}/"))) |
| 133 | | else { |
| 134 | 1× | bail!("{path} is not under the job's checkpoints"); |
| 135 | | }; |
| 136 | 6× | let below = path[root.len()..].trim_start_matches('/'); |
| 137 | 10× | if below.split('/').any(|p| p == "." || p == ".." || (p.is_empty() && !below.is_empty())) { |
| 138 | 1× | bail!("{path}: . or .. in a checkpoint path"); |
| 139 | 5× | } |
| 140 | 5× | let Some(base) = place(root) else { continue }; |
| 141 | 5× | let at = if below.is_empty() { base } else { base.join(below) }; |
| 142 | 5× | match kind { |
| 143 | | DIR => { |
| 144 | 3× | std::fs::create_dir_all(&at)?; |
| 145 | 3× | n.dirs += 1; |
| 146 | | } |
| 147 | | FILE => { |
| 148 | 2× | if let Some(parent) = at.parent() { |
| 149 | 2× | std::fs::create_dir_all(parent)?; |
| 150 | 0× | } |
| 151 | | |
| 152 | 2× | if std::fs::symlink_metadata(&at).is_ok_and(|m| !m.is_file()) { |
| 153 | 0× | bail!("{path}: something other than a file is in the way"); |
| 154 | 2× | } |
| 155 | 2× | std::fs::write(&at, &data)?; |
| 156 | 2× | let f = std::fs::File::options().write(true).open(&at)?; |
| 157 | 2× | f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))?; |
| 158 | 2× | f.set_modified(SystemTime::UNIX_EPOCH + Duration::new(secs, nanos))?; |
| 159 | 2× | n.files += 1; |
| 160 | 2× | n.bytes += data.len() as u64; |
| 161 | | } |
| 162 | 0× | other => bail!("{path}: unknown entry kind {other}"), |
| 163 | | } |
| 164 | | } |
| 165 | 1× | Ok(n) |
| 166 | 4× | } |
| 167 | |
|
| 168 | | #[cfg(test)] |
| 169 | | mod tests { |
| 170 | | use super::*; |
| 171 | |
|
| 172 | | #[test] |
| 173 | 1× | fn a_checkpoint_comes_back_with_its_times_and_only_where_it_belongs() { |
| 174 | 1× | let from = tempfile::tempdir().unwrap(); |
| 175 | 1× | let target = from.path().join("target"); |
| 176 | 1× | std::fs::create_dir_all(target.join("debug/deps")).unwrap(); |
| 177 | 1× | std::fs::write(target.join("debug/deps/libx.rlib"), b"built").unwrap(); |
| 178 | 1× | std::fs::write(target.join("debug/.fingerprint"), b"fp").unwrap(); |
| 179 | 1× | let old = SystemTime::UNIX_EPOCH + Duration::new(1_700_000_000, 123_000_000); |
| 180 | 1× | std::fs::File::options().write(true).open(target.join("debug/deps/libx.rlib")).unwrap().set_modified(old).unwrap(); |
| 181 | 1× | let (bytes, n) = pack(&[("/src/target".into(), target.clone())]).unwrap(); |
| 182 | 1× | assert_eq!((n.files, n.dirs, n.bytes), (2, 3, 7)); |
| 183 | |
|
| 184 | 1× | let to = tempfile::tempdir().unwrap(); |
| 185 | 5× | let place = |root: &str| (root == "/src/target").then(|| to.path().join("t")); |
| 186 | 1× | let back = unpack(&bytes, &["/src/target".into()], place).unwrap(); |
| 187 | 1× | assert_eq!(back, n); |
| 188 | 1× | let restored = to.path().join("t/debug/deps/libx.rlib"); |
| 189 | 1× | assert_eq!(std::fs::read(&restored).unwrap(), b"built"); |
| 190 | 1× | assert_eq!(std::fs::metadata(&restored).unwrap().modified().unwrap(), old, "cargo reads the times"); |
| 191 | |
|
| 192 | | |
| 193 | 1× | assert!(unpack(&bytes, &["/src/other".into()], place).is_err()); |
| 194 | 1× | let mut evil = MAGIC.to_vec(); |
| 195 | 1× | let meta = std::fs::metadata(target.join("debug/.fingerprint")).unwrap(); |
| 196 | 1× | entry(&mut evil, FILE, "/src/target/../escape", &meta, b"x"); |
| 197 | 1× | assert!(unpack(&evil, &["/src/target".into()], place).is_err()); |
| 198 | 1× | assert!(!to.path().join("escape").exists()); |
| 199 | 1× | assert!(unpack(b"junk", &["/src/target".into()], place).is_err()); |
| 200 | 1× | } |
| 201 | |
|
| 202 | | #[test] |
| 203 | 1× | fn a_checkpoint_under_a_disk_mount_is_in_the_mount_s_directory() { |
| 204 | 1× | let p = flow_core::Pipeline::parse( |
| 205 | 1× | "schema-version 1\nsandbox \"w\" {\n runtime \"wasm\"\n}\njob \"j\" {\n sandbox \"w\"\n mount {\n source \"ephemeral://disk\"\n target \"/src/target\"\n size \"1GiB\"\n }\n checkpoint \"/src/target\" \"/src/state\"\n exec \"x.wasm\"\n}\n", |
| 206 | | ) |
| 207 | 1× | .unwrap(); |
| 208 | 1× | let job = &p.jobs["j"]; |
| 209 | 1× | assert_eq!(job.checkpoints, ["/src/state", "/src/target"]); |
| 210 | 1× | let (tree, scratch) = (Path::new("/w/tree"), Path::new("/w")); |
| 211 | 1× | assert_eq!(host_dir("/src/target", job, tree, scratch, true), Some(crate::syscall::disk_dir(scratch, 0))); |
| 212 | 1× | assert_eq!(host_dir("/src/state", job, tree, scratch, true), Some(PathBuf::from("/w/tree/state"))); |
| 213 | 1× | } |
| 214 | | } |