| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | use std::collections::HashSet; |
| 9 | use std::path::{Path, PathBuf}; |
| 10 | |
| 11 | use oci_spec::runtime::{ |
| 12 | Capability, LinuxBuilder, LinuxCapabilitiesBuilder, LinuxCpuBuilder, LinuxIdMappingBuilder, |
| 13 | LinuxMemoryBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, LinuxPidsBuilder, |
| 14 | LinuxResourcesBuilder, Mount, MountBuilder, PosixRlimitBuilder, PosixRlimitType, |
| 15 | ProcessBuilder, RootBuilder, Spec, SpecBuilder, UserBuilder, |
| 16 | }; |
| 17 | |
| 18 | use super::{SandboxError, SandboxRequest}; |
| 19 | use crate::config::Limits; |
| 20 | use crate::executor::validate_env_name; |
| 21 | |
| 22 | |
| 23 | pub const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; |
| 24 | |
| 25 | pub const WORKSPACE: &str = "/workspace"; |
| 26 | |
| 27 | pub const HOSTNAME: &str = "forge-job"; |
| 28 | |
| 29 | pub const NOFILE_LIMIT: u64 = 65_536; |
| 30 | |
| 31 | pub const CPU_PERIOD_US: u64 = 100_000; |
| 32 | |
| 33 | |
| 34 | pub const MASKED_PATHS: &[&str] = &[ |
| 35 | "/proc/acpi", |
| 36 | "/proc/asound", |
| 37 | "/proc/kcore", |
| 38 | "/proc/keys", |
| 39 | "/proc/latency_stats", |
| 40 | "/proc/timer_list", |
| 41 | "/proc/timer_stats", |
| 42 | "/proc/sched_debug", |
| 43 | "/proc/scsi", |
| 44 | "/sys/firmware", |
| 45 | "/sys/devices/virtual/powercap", |
| 46 | ]; |
| 47 | |
| 48 | |
| 49 | pub const READONLY_PATHS: &[&str] = &[ |
| 50 | "/proc/bus", |
| 51 | "/proc/fs", |
| 52 | "/proc/irq", |
| 53 | "/proc/sys", |
| 54 | "/proc/sysrq-trigger", |
| 55 | ]; |
| 56 | |
| 57 | |
| 58 | |
| 59 | pub const DEVICES: &[&str] = &["null", "zero", "full", "random", "urandom"]; |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | pub fn build_spec(req: &SandboxRequest, rootfs_mount: &Path) -> Result<Spec, SandboxError> { |
| 65 | let env = build_env(req)?; |
| 66 | let args = vec![ |
| 67 | "sh".to_owned(), |
| 68 | "-eu".to_owned(), |
| 69 | "-c".to_owned(), |
| 70 | req.run.clone(), |
| 71 | ]; |
| 72 | let process = ProcessBuilder::default() |
| 73 | .terminal(false) |
| 74 | .user( |
| 75 | UserBuilder::default() |
| 76 | .uid(0u32) |
| 77 | .gid(0u32) |
| 78 | .build() |
| 79 | .map_err(spec_err)?, |
| 80 | ) |
| 81 | .args(args) |
| 82 | .env(env) |
| 83 | .cwd(PathBuf::from(WORKSPACE)) |
| 84 | .capabilities(job_capabilities(req)?) |
| 85 | .rlimits(vec![ |
| 86 | rlimit(PosixRlimitType::RlimitNproc, req.limits.pids.into())?, |
| 87 | rlimit(PosixRlimitType::RlimitNofile, NOFILE_LIMIT)?, |
| 88 | ]) |
| 89 | .no_new_privileges(true) |
| 90 | .build() |
| 91 | .map_err(spec_err)?; |
| 92 | |
| 93 | let root = RootBuilder::default() |
| 94 | .path(rootfs_mount) |
| 95 | .readonly(false) |
| 96 | .build() |
| 97 | .map_err(spec_err)?; |
| 98 | |
| 99 | let linux = build_linux(req)?; |
| 100 | |
| 101 | SpecBuilder::default() |
| 102 | .version("1.0.2") |
| 103 | .root(root) |
| 104 | .hostname(HOSTNAME) |
| 105 | .process(process) |
| 106 | .mounts(build_mounts(req)?) |
| 107 | .linux(linux) |
| 108 | .build() |
| 109 | .map_err(spec_err) |
| 110 | } |
| 111 | |
| 112 | |
| 113 | |
| 114 | fn build_env(req: &SandboxRequest) -> Result<Vec<String>, SandboxError> { |
| 115 | let mut out: Vec<String> = Vec::new(); |
| 116 | for entry in &req.image_env { |
| 117 | let (name, _) = entry |
| 118 | .split_once('=') |
| 119 | .ok_or_else(|| SandboxError::Invalid(format!("image env without '=': {entry:?}")))?; |
| 120 | validate_env_name(name).map_err(|e| SandboxError::Invalid(e.to_string()))?; |
| 121 | check_nul(entry)?; |
| 122 | out.push(entry.clone()); |
| 123 | } |
| 124 | if !req.image_env.iter().any(|e| e.starts_with("PATH=")) { |
| 125 | out.push(format!("PATH={DEFAULT_PATH}")); |
| 126 | } |
| 127 | out.push("HOME=/root".to_owned()); |
| 128 | out.push("CI=true".to_owned()); |
| 129 | out.push("FORGE_CI=true".to_owned()); |
| 130 | for (name, value) in &req.env { |
| 131 | validate_env_name(name).map_err(|e| SandboxError::Invalid(e.to_string()))?; |
| 132 | let entry = format!("{name}={value}"); |
| 133 | check_nul(&entry)?; |
| 134 | out.push(entry); |
| 135 | } |
| 136 | Ok(out) |
| 137 | } |
| 138 | |
| 139 | fn check_nul(entry: &str) -> Result<(), SandboxError> { |
| 140 | if entry.contains('\0') { |
| 141 | |
| 142 | let name = entry.split('=').next().unwrap_or_default(); |
| 143 | return Err(SandboxError::Invalid(format!("env {name} contains NUL"))); |
| 144 | } |
| 145 | Ok(()) |
| 146 | } |
| 147 | |
| 148 | |
| 149 | |
| 150 | |
| 151 | const SUBUID_CAPABILITIES: [Capability; 13] = [ |
| 152 | Capability::Chown, |
| 153 | Capability::DacOverride, |
| 154 | Capability::Fowner, |
| 155 | Capability::Fsetid, |
| 156 | Capability::Kill, |
| 157 | Capability::Setgid, |
| 158 | Capability::Setuid, |
| 159 | Capability::Setpcap, |
| 160 | Capability::Setfcap, |
| 161 | Capability::NetBindService, |
| 162 | Capability::SysChroot, |
| 163 | Capability::AuditWrite, |
| 164 | Capability::Mknod, |
| 165 | ]; |
| 166 | |
| 167 | |
| 168 | |
| 169 | |
| 170 | fn job_capabilities( |
| 171 | req: &SandboxRequest, |
| 172 | ) -> Result<oci_spec::runtime::LinuxCapabilities, SandboxError> { |
| 173 | if !req.idmap.is_subuid() { |
| 174 | return no_capabilities(); |
| 175 | } |
| 176 | let set: HashSet<Capability> = SUBUID_CAPABILITIES.into_iter().collect(); |
| 177 | LinuxCapabilitiesBuilder::default() |
| 178 | .bounding(set.clone()) |
| 179 | .effective(set.clone()) |
| 180 | .permitted(set) |
| 181 | .inheritable(HashSet::new()) |
| 182 | .ambient(HashSet::new()) |
| 183 | .build() |
| 184 | .map_err(spec_err) |
| 185 | } |
| 186 | |
| 187 | fn no_capabilities() -> Result<oci_spec::runtime::LinuxCapabilities, SandboxError> { |
| 188 | let empty: HashSet<Capability> = HashSet::new(); |
| 189 | LinuxCapabilitiesBuilder::default() |
| 190 | .bounding(empty.clone()) |
| 191 | .effective(empty.clone()) |
| 192 | .inheritable(empty.clone()) |
| 193 | .permitted(empty.clone()) |
| 194 | .ambient(empty) |
| 195 | .build() |
| 196 | .map_err(spec_err) |
| 197 | } |
| 198 | |
| 199 | fn rlimit( |
| 200 | typ: PosixRlimitType, |
| 201 | value: u64, |
| 202 | ) -> Result<oci_spec::runtime::PosixRlimit, SandboxError> { |
| 203 | PosixRlimitBuilder::default() |
| 204 | .typ(typ) |
| 205 | .soft(value) |
| 206 | .hard(value) |
| 207 | .build() |
| 208 | .map_err(spec_err) |
| 209 | } |
| 210 | |
| 211 | fn build_linux(req: &SandboxRequest) -> Result<oci_spec::runtime::Linux, SandboxError> { |
| 212 | |
| 213 | |
| 214 | |
| 215 | |
| 216 | let subuid = req.idmap.is_subuid(); |
| 217 | let mut namespaces = Vec::new(); |
| 218 | if !subuid { |
| 219 | namespaces.push(ns(LinuxNamespaceType::User)?); |
| 220 | } |
| 221 | namespaces.extend([ |
| 222 | ns(LinuxNamespaceType::Mount)?, |
| 223 | ns(LinuxNamespaceType::Pid)?, |
| 224 | ns(LinuxNamespaceType::Ipc)?, |
| 225 | ns(LinuxNamespaceType::Uts)?, |
| 226 | ns(LinuxNamespaceType::Cgroup)?, |
| 227 | ]); |
| 228 | if !req.network { |
| 229 | namespaces.push(ns(LinuxNamespaceType::Network)?); |
| 230 | } |
| 231 | let mut linux = LinuxBuilder::default().namespaces(namespaces); |
| 232 | if !subuid { |
| 233 | |
| 234 | let uid_map = LinuxIdMappingBuilder::default() |
| 235 | .container_id(0u32) |
| 236 | .host_id(0u32) |
| 237 | .size(1u32) |
| 238 | .build() |
| 239 | .map_err(spec_err)?; |
| 240 | let gid_map = LinuxIdMappingBuilder::default() |
| 241 | .container_id(0u32) |
| 242 | .host_id(0u32) |
| 243 | .size(1u32) |
| 244 | .build() |
| 245 | .map_err(spec_err)?; |
| 246 | linux = linux |
| 247 | .uid_mappings(vec![uid_map]) |
| 248 | .gid_mappings(vec![gid_map]); |
| 249 | } |
| 250 | |
| 251 | |
| 252 | linux |
| 253 | .resources(build_resources(&req.limits)?) |
| 254 | .masked_paths( |
| 255 | MASKED_PATHS |
| 256 | .iter() |
| 257 | .map(|s| (*s).to_owned()) |
| 258 | .collect::<Vec<_>>(), |
| 259 | ) |
| 260 | .readonly_paths( |
| 261 | READONLY_PATHS |
| 262 | .iter() |
| 263 | .map(|s| (*s).to_owned()) |
| 264 | .collect::<Vec<_>>(), |
| 265 | ) |
| 266 | .build() |
| 267 | .map_err(spec_err) |
| 268 | } |
| 269 | |
| 270 | fn ns(typ: LinuxNamespaceType) -> Result<oci_spec::runtime::LinuxNamespace, SandboxError> { |
| 271 | LinuxNamespaceBuilder::default() |
| 272 | .typ(typ) |
| 273 | .build() |
| 274 | .map_err(spec_err) |
| 275 | } |
| 276 | |
| 277 | fn build_resources(limits: &Limits) -> Result<oci_spec::runtime::LinuxResources, SandboxError> { |
| 278 | let memory = parse_bytes(&limits.memory)?; |
| 279 | let cpus = parse_cpus(&limits.cpus)?; |
| 280 | let quota = (cpus * CPU_PERIOD_US as f64).round() as i64; |
| 281 | LinuxResourcesBuilder::default() |
| 282 | .memory( |
| 283 | LinuxMemoryBuilder::default() |
| 284 | .limit(memory) |
| 285 | .build() |
| 286 | .map_err(spec_err)?, |
| 287 | ) |
| 288 | .cpu( |
| 289 | LinuxCpuBuilder::default() |
| 290 | .quota(quota) |
| 291 | .period(CPU_PERIOD_US) |
| 292 | .build() |
| 293 | .map_err(spec_err)?, |
| 294 | ) |
| 295 | .pids( |
| 296 | LinuxPidsBuilder::default() |
| 297 | .limit(i64::from(limits.pids)) |
| 298 | .build() |
| 299 | .map_err(spec_err)?, |
| 300 | ) |
| 301 | .build() |
| 302 | .map_err(spec_err) |
| 303 | } |
| 304 | |
| 305 | |
| 306 | pub fn parse_bytes(text: &str) -> Result<i64, SandboxError> { |
| 307 | let bad = || SandboxError::Invalid(format!("bad memory limit {text:?}")); |
| 308 | let lower = text.trim().to_ascii_lowercase(); |
| 309 | let (digits, multiplier) = match lower.chars().last() { |
| 310 | Some('k') => (&lower[..lower.len() - 1], 1024_i64), |
| 311 | Some('m') => (&lower[..lower.len() - 1], 1024 * 1024), |
| 312 | Some('g') => (&lower[..lower.len() - 1], 1024 * 1024 * 1024), |
| 313 | Some('b') => (&lower[..lower.len() - 1], 1), |
| 314 | _ => (lower.as_str(), 1), |
| 315 | }; |
| 316 | let n: i64 = digits.parse().map_err(|_| bad())?; |
| 317 | let bytes = n.checked_mul(multiplier).ok_or_else(bad)?; |
| 318 | if bytes <= 0 { |
| 319 | return Err(bad()); |
| 320 | } |
| 321 | Ok(bytes) |
| 322 | } |
| 323 | |
| 324 | |
| 325 | pub fn parse_cpus(text: &str) -> Result<f64, SandboxError> { |
| 326 | let value: f64 = text |
| 327 | .trim() |
| 328 | .parse() |
| 329 | .map_err(|_| SandboxError::Invalid(format!("bad cpus limit {text:?}")))?; |
| 330 | if !value.is_finite() || value <= 0.0 { |
| 331 | return Err(SandboxError::Invalid(format!("bad cpus limit {text:?}"))); |
| 332 | } |
| 333 | Ok(value) |
| 334 | } |
| 335 | |
| 336 | fn build_mounts(req: &SandboxRequest) -> Result<Vec<Mount>, SandboxError> { |
| 337 | let mut mounts = vec![ |
| 338 | mount("/proc", "proc", "proc", &["nosuid", "noexec", "nodev"])?, |
| 339 | mount( |
| 340 | "/dev", |
| 341 | "tmpfs", |
| 342 | "tmpfs", |
| 343 | &["nosuid", "strictatime", "mode=755", "size=65536k"], |
| 344 | )?, |
| 345 | mount( |
| 346 | "/dev/pts", |
| 347 | "devpts", |
| 348 | "devpts", |
| 349 | &[ |
| 350 | "nosuid", |
| 351 | "noexec", |
| 352 | "newinstance", |
| 353 | "ptmxmode=0666", |
| 354 | "mode=0620", |
| 355 | ], |
| 356 | )?, |
| 357 | mount( |
| 358 | "/dev/shm", |
| 359 | "tmpfs", |
| 360 | "shm", |
| 361 | &["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"], |
| 362 | )?, |
| 363 | ]; |
| 364 | for dev in DEVICES { |
| 365 | let host = format!("/dev/{dev}"); |
| 366 | mounts.push(mount(&host, "bind", &host, &["bind", "nosuid", "noexec"])?); |
| 367 | } |
| 368 | if req.network { |
| 369 | |
| 370 | |
| 371 | mounts.push(mount( |
| 372 | "/sys", |
| 373 | "bind", |
| 374 | "/sys", |
| 375 | &["rbind", "nosuid", "noexec", "nodev", "ro"], |
| 376 | )?); |
| 377 | mounts.push(mount( |
| 378 | "/etc/resolv.conf", |
| 379 | "bind", |
| 380 | "/etc/resolv.conf", |
| 381 | &["bind", "ro"], |
| 382 | )?); |
| 383 | } else { |
| 384 | mounts.push(mount( |
| 385 | "/sys", |
| 386 | "sysfs", |
| 387 | "sysfs", |
| 388 | &["nosuid", "noexec", "nodev", "ro"], |
| 389 | )?); |
| 390 | } |
| 391 | mounts.push(mount_path( |
| 392 | WORKSPACE, |
| 393 | "bind", |
| 394 | &req.workspace, |
| 395 | &["rbind", "rw", "nosuid", "nodev"], |
| 396 | )?); |
| 397 | mounts.push(mount( |
| 398 | "/tmp", |
| 399 | "tmpfs", |
| 400 | "tmpfs", |
| 401 | &["nosuid", "nodev", "mode=1777"], |
| 402 | )?); |
| 403 | mounts.push(mount_path( |
| 404 | "/etc/hosts", |
| 405 | "bind", |
| 406 | &req.job_dir.join("etc/hosts"), |
| 407 | &["bind", "ro"], |
| 408 | )?); |
| 409 | mounts.push(mount_path( |
| 410 | "/etc/hostname", |
| 411 | "bind", |
| 412 | &req.job_dir.join("etc/hostname"), |
| 413 | &["bind", "ro"], |
| 414 | )?); |
| 415 | Ok(mounts) |
| 416 | } |
| 417 | |
| 418 | fn mount(dest: &str, typ: &str, source: &str, options: &[&str]) -> Result<Mount, SandboxError> { |
| 419 | mount_path(dest, typ, Path::new(source), options) |
| 420 | } |
| 421 | |
| 422 | fn mount_path( |
| 423 | dest: &str, |
| 424 | typ: &str, |
| 425 | source: &Path, |
| 426 | options: &[&str], |
| 427 | ) -> Result<Mount, SandboxError> { |
| 428 | MountBuilder::default() |
| 429 | .destination(PathBuf::from(dest)) |
| 430 | .typ(typ) |
| 431 | .source(source) |
| 432 | .options(options.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>()) |
| 433 | .build() |
| 434 | .map_err(spec_err) |
| 435 | } |
| 436 | |
| 437 | fn spec_err(e: impl std::fmt::Display) -> SandboxError { |
| 438 | SandboxError::Setup(format!("cannot build OCI spec: {e}")) |
| 439 | } |
| 440 | |
| 441 | #[cfg(test)] |
| 442 | mod tests { |
| 443 | use super::*; |
| 444 | use crate::sandbox::idmap::IdMap; |
| 445 | use std::collections::HashSet; |
| 446 | |
| 447 | fn request(network: bool) -> SandboxRequest { |
| 448 | SandboxRequest { |
| 449 | job_id: "job_01".to_owned(), |
| 450 | rootfs: PathBuf::from("/var/lib/forge/rootfs"), |
| 451 | job_dir: PathBuf::from("/var/lib/forge/jobs/job_01"), |
| 452 | workspace: PathBuf::from("/var/lib/forge/jobs/job_01/ws"), |
| 453 | image_env: vec!["PATH=/opt/bin:/bin".to_owned(), "LANG=C.UTF-8".to_owned()], |
| 454 | env: vec![ |
| 455 | ("BUILD".to_owned(), "1".to_owned()), |
| 456 | ("TOKEN".to_owned(), "s3cret-token".to_owned()), |
| 457 | ], |
| 458 | run: "echo hi".to_owned(), |
| 459 | network, |
| 460 | limits: Limits { |
| 461 | memory: "2g".to_owned(), |
| 462 | cpus: "1.5".to_owned(), |
| 463 | pids: 512, |
| 464 | image_max_gb: 20, |
| 465 | }, |
| 466 | idmap: IdMap::single(), |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | fn spec_for(network: bool) -> Spec { |
| 471 | build_spec(&request(network), Path::new("/rootfs")).expect("spec") |
| 472 | } |
| 473 | |
| 474 | fn namespace_types(spec: &Spec) -> HashSet<String> { |
| 475 | spec.linux() |
| 476 | .as_ref() |
| 477 | .and_then(|l| l.namespaces().as_ref()) |
| 478 | .map(|list| list.iter().map(|n| n.typ().to_string()).collect()) |
| 479 | .unwrap_or_default() |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn namespaces_include_network_only_when_disabled() { |
| 484 | let isolated = namespace_types(&spec_for(false)); |
| 485 | for expected in ["user", "mnt", "pid", "ipc", "uts", "cgroup", "net"] { |
| 486 | assert!(isolated.contains(expected), "missing {expected}"); |
| 487 | } |
| 488 | let shared = namespace_types(&spec_for(true)); |
| 489 | assert!(!shared.contains("net")); |
| 490 | assert!(shared.contains("user")); |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn single_mode_maps_only_container_root() { |
| 495 | let spec = spec_for(false); |
| 496 | let linux = spec.linux().as_ref().expect("linux"); |
| 497 | let uid = &linux.uid_mappings().as_ref().expect("uid map")[0]; |
| 498 | assert_eq!((uid.container_id(), uid.host_id(), uid.size()), (0, 0, 1)); |
| 499 | let gid = &linux.gid_mappings().as_ref().expect("gid map")[0]; |
| 500 | assert_eq!((gid.container_id(), gid.host_id(), gid.size()), (0, 0, 1)); |
| 501 | let user = spec.process().as_ref().expect("process").user(); |
| 502 | assert_eq!((user.uid(), user.gid()), (0, 0)); |
| 503 | } |
| 504 | |
| 505 | #[test] |
| 506 | fn subuid_mode_runs_in_the_helpers_user_namespace() { |
| 507 | let mut req = request(false); |
| 508 | req.idmap = IdMap { |
| 509 | uid: 1000, |
| 510 | gid: 1000, |
| 511 | sub_uid: Some((524_288, 65_536)), |
| 512 | sub_gid: Some((524_288, 65_536)), |
| 513 | }; |
| 514 | let spec = build_spec(&req, Path::new("/rootfs")).expect("spec"); |
| 515 | assert!(!namespace_types(&spec).contains("user")); |
| 516 | let linux = spec.linux().as_ref().expect("linux"); |
| 517 | assert!(linux.uid_mappings().is_none()); |
| 518 | assert!(linux.gid_mappings().is_none()); |
| 519 | let caps = spec |
| 520 | .process() |
| 521 | .as_ref() |
| 522 | .and_then(|p| p.capabilities().clone()) |
| 523 | .expect("capabilities"); |
| 524 | let effective = caps.effective().clone().unwrap_or_default(); |
| 525 | assert!(effective.contains(&Capability::Chown)); |
| 526 | assert!(!effective.contains(&Capability::SysAdmin)); |
| 527 | } |
| 528 | |
| 529 | #[test] |
| 530 | fn no_capabilities_in_any_set() { |
| 531 | let spec = spec_for(false); |
| 532 | let caps = spec |
| 533 | .process() |
| 534 | .as_ref() |
| 535 | .and_then(|p| p.capabilities().clone()) |
| 536 | .expect("capabilities"); |
| 537 | for set in [ |
| 538 | caps.bounding(), |
| 539 | caps.effective(), |
| 540 | caps.inheritable(), |
| 541 | caps.permitted(), |
| 542 | caps.ambient(), |
| 543 | ] { |
| 544 | assert!(set.as_ref().is_some_and(HashSet::is_empty)); |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn no_new_privileges_is_set() { |
| 550 | let spec = spec_for(false); |
| 551 | assert_eq!( |
| 552 | spec.process() |
| 553 | .as_ref() |
| 554 | .expect("process") |
| 555 | .no_new_privileges(), |
| 556 | Some(true) |
| 557 | ); |
| 558 | } |
| 559 | |
| 560 | #[test] |
| 561 | fn spec_has_no_seccomp_section() { |
| 562 | |
| 563 | |
| 564 | let spec = spec_for(false); |
| 565 | assert!(spec.linux().as_ref().expect("linux").seccomp().is_none()); |
| 566 | } |
| 567 | |
| 568 | #[test] |
| 569 | fn mounts_cover_the_standard_layout_and_the_workspace() { |
| 570 | let spec = spec_for(false); |
| 571 | let dests: Vec<String> = spec |
| 572 | .mounts() |
| 573 | .as_ref() |
| 574 | .expect("mounts") |
| 575 | .iter() |
| 576 | .map(|m| m.destination().display().to_string()) |
| 577 | .collect(); |
| 578 | for expected in [ |
| 579 | "/proc", |
| 580 | "/dev", |
| 581 | "/dev/pts", |
| 582 | "/dev/shm", |
| 583 | "/dev/null", |
| 584 | "/sys", |
| 585 | "/workspace", |
| 586 | "/tmp", |
| 587 | "/etc/hosts", |
| 588 | "/etc/hostname", |
| 589 | ] { |
| 590 | assert!( |
| 591 | dests.iter().any(|d| d == expected), |
| 592 | "missing mount {expected}" |
| 593 | ); |
| 594 | } |
| 595 | assert!(!dests.iter().any(|d| d == "/etc/resolv.conf")); |
| 596 | let ws = spec |
| 597 | .mounts() |
| 598 | .as_ref() |
| 599 | .and_then(|m| m.iter().find(|m| m.destination() == Path::new(WORKSPACE))) |
| 600 | .expect("workspace mount"); |
| 601 | assert_eq!( |
| 602 | ws.source().as_deref(), |
| 603 | Some(Path::new("/var/lib/forge/jobs/job_01/ws")) |
| 604 | ); |
| 605 | } |
| 606 | |
| 607 | #[test] |
| 608 | fn resolv_conf_is_mounted_only_with_network() { |
| 609 | let spec = spec_for(true); |
| 610 | let dests: Vec<String> = spec |
| 611 | .mounts() |
| 612 | .as_ref() |
| 613 | .expect("mounts") |
| 614 | .iter() |
| 615 | .map(|m| m.destination().display().to_string()) |
| 616 | .collect(); |
| 617 | assert!(dests.iter().any(|d| d == "/etc/resolv.conf")); |
| 618 | } |
| 619 | |
| 620 | #[test] |
| 621 | fn resource_limits_are_applied() { |
| 622 | let spec = spec_for(false); |
| 623 | let res = spec |
| 624 | .linux() |
| 625 | .as_ref() |
| 626 | .and_then(|l| l.resources().clone()) |
| 627 | .expect("resources"); |
| 628 | assert_eq!( |
| 629 | res.memory().as_ref().and_then(|m| m.limit()), |
| 630 | Some(2 * 1024 * 1024 * 1024) |
| 631 | ); |
| 632 | let cpu = res.cpu().as_ref().expect("cpu"); |
| 633 | assert_eq!(cpu.period(), Some(CPU_PERIOD_US)); |
| 634 | assert_eq!(cpu.quota(), Some(150_000)); |
| 635 | assert_eq!(res.pids().as_ref().map(|p| p.limit()), Some(512)); |
| 636 | let rlimits = spec |
| 637 | .process() |
| 638 | .as_ref() |
| 639 | .expect("process") |
| 640 | .rlimits() |
| 641 | .clone() |
| 642 | .expect("rlimits"); |
| 643 | let nproc = rlimits |
| 644 | .iter() |
| 645 | .find(|r| r.typ() == PosixRlimitType::RlimitNproc) |
| 646 | .expect("nproc"); |
| 647 | assert_eq!((nproc.soft(), nproc.hard()), (512, 512)); |
| 648 | let nofile = rlimits |
| 649 | .iter() |
| 650 | .find(|r| r.typ() == PosixRlimitType::RlimitNofile) |
| 651 | .expect("nofile"); |
| 652 | assert_eq!(nofile.soft(), NOFILE_LIMIT); |
| 653 | } |
| 654 | |
| 655 | #[test] |
| 656 | fn env_order_is_image_then_defaults_then_job_then_secrets() { |
| 657 | let spec = spec_for(false); |
| 658 | let env = spec |
| 659 | .process() |
| 660 | .as_ref() |
| 661 | .expect("process") |
| 662 | .env() |
| 663 | .clone() |
| 664 | .expect("env"); |
| 665 | let pos = |needle: &str| env.iter().position(|e| e == needle).expect(needle); |
| 666 | assert!(pos("PATH=/opt/bin:/bin") < pos("HOME=/root")); |
| 667 | assert!(pos("LANG=C.UTF-8") < pos("HOME=/root")); |
| 668 | assert!(pos("FORGE_CI=true") < pos("BUILD=1")); |
| 669 | assert!(pos("BUILD=1") < pos("TOKEN=s3cret-token")); |
| 670 | |
| 671 | assert_eq!(env.iter().filter(|e| e.starts_with("PATH=")).count(), 1); |
| 672 | } |
| 673 | |
| 674 | #[test] |
| 675 | fn default_path_is_used_when_the_image_has_none() { |
| 676 | let mut req = request(false); |
| 677 | req.image_env.clear(); |
| 678 | let spec = build_spec(&req, Path::new("/rootfs")).expect("spec"); |
| 679 | let env = spec |
| 680 | .process() |
| 681 | .as_ref() |
| 682 | .expect("process") |
| 683 | .env() |
| 684 | .clone() |
| 685 | .expect("env"); |
| 686 | assert!(env.iter().any(|e| e == &format!("PATH={DEFAULT_PATH}"))); |
| 687 | } |
| 688 | |
| 689 | #[test] |
| 690 | fn secrets_never_appear_in_args() { |
| 691 | let spec = spec_for(false); |
| 692 | let args = spec |
| 693 | .process() |
| 694 | .as_ref() |
| 695 | .expect("process") |
| 696 | .args() |
| 697 | .clone() |
| 698 | .expect("args"); |
| 699 | assert_eq!(args[..3], ["sh", "-eu", "-c"]); |
| 700 | assert!(!args.iter().any(|a| a.contains("s3cret-token"))); |
| 701 | assert_eq!(args[3], "echo hi"); |
| 702 | } |
| 703 | |
| 704 | #[test] |
| 705 | fn cwd_is_the_workspace_and_no_tty() { |
| 706 | let spec = spec_for(false); |
| 707 | let process = spec.process().as_ref().expect("process"); |
| 708 | assert_eq!(process.cwd(), Path::new(WORKSPACE)); |
| 709 | assert_eq!(process.terminal(), Some(false)); |
| 710 | } |
| 711 | |
| 712 | #[test] |
| 713 | fn masked_and_readonly_paths_are_set() { |
| 714 | let spec = spec_for(false); |
| 715 | let linux = spec.linux().as_ref().expect("linux"); |
| 716 | let masked = linux.masked_paths().as_ref().expect("masked"); |
| 717 | assert!(masked.iter().any(|p| p == "/proc/kcore")); |
| 718 | let ro = linux.readonly_paths().as_ref().expect("readonly"); |
| 719 | assert!(ro.iter().any(|p| p == "/proc/sys")); |
| 720 | } |
| 721 | |
| 722 | #[test] |
| 723 | fn parse_bytes_accepts_suffixes_and_rejects_junk() { |
| 724 | assert_eq!(parse_bytes("4g").expect("g"), 4 * 1024 * 1024 * 1024); |
| 725 | assert_eq!(parse_bytes("512M").expect("m"), 512 * 1024 * 1024); |
| 726 | assert_eq!(parse_bytes("64k").expect("k"), 64 * 1024); |
| 727 | assert_eq!(parse_bytes("1024").expect("plain"), 1024); |
| 728 | assert!(parse_bytes("lots").is_err()); |
| 729 | assert!(parse_bytes("0g").is_err()); |
| 730 | assert!(parse_bytes("-1").is_err()); |
| 731 | } |
| 732 | |
| 733 | #[test] |
| 734 | fn parse_cpus_rejects_zero_and_junk() { |
| 735 | assert_eq!(parse_cpus("2").expect("2"), 2.0); |
| 736 | assert!(parse_cpus("0").is_err()); |
| 737 | assert!(parse_cpus("nan").is_err()); |
| 738 | assert!(parse_cpus("two").is_err()); |
| 739 | } |
| 740 | |
| 741 | #[test] |
| 742 | fn bad_env_names_are_rejected() { |
| 743 | let mut req = request(false); |
| 744 | req.env.push(("A=B".to_owned(), "x".to_owned())); |
| 745 | assert!(build_spec(&req, Path::new("/rootfs")).is_err()); |
| 746 | } |
| 747 | |
| 748 | #[test] |
| 749 | fn nul_in_env_value_is_rejected() { |
| 750 | let mut req = request(false); |
| 751 | req.env.push(("OK".to_owned(), "a\0b".to_owned())); |
| 752 | assert!(build_spec(&req, Path::new("/rootfs")).is_err()); |
| 753 | } |
| 754 | } |