forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
24.6 KiB
1//! The OCI runtime spec for one job. [`build_spec`] is pure, so the hardening can be unit tested.
2//!
3//! Single mode gives the job its own user namespace, which maps container uid/gid 0 to the
4//! helper's uid/gid 0. The helper has already entered its user namespace, in which it is root
5//! (see `helper.rs`). In subuid mode the spec has no user namespace, and the job runs in the
6//! helper's, whose ids are the [`IdMap`] (the runner's uid, then the subordinate range).
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use 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
18use super::{SandboxError, SandboxRequest};
19use crate::config::Limits;
20use crate::executor::validate_env_name;
21
22/// The PATH used when the image config does not set one.
23pub const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
24/// The job's working directory inside the container.
25pub const WORKSPACE: &str = "/workspace";
26/// The container's hostname (the UTS namespace is private).
27pub const HOSTNAME: &str = "forge-job";
28/// RLIMIT_NOFILE, for both soft and hard limits.
29pub const NOFILE_LIMIT: u64 = 65_536;
30/// The CFS period used to turn `cpus` into a quota.
31pub const CPU_PERIOD_US: u64 = 100_000;
32
33/// Paths hidden by an empty bind mount, as Docker does.
34pub 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/// Paths that stay visible but read-only.
49pub const READONLY_PATHS: &[&str] = &[
50 "/proc/bus",
51 "/proc/fs",
52 "/proc/irq",
53 "/proc/sys",
54 "/proc/sysrq-trigger",
55];
56
57/// Host device nodes bind mounted into `/dev`. `tty` is left out: binding it opens the host's
58/// controlling terminal, and the runner has none (ENXIO).
59pub const DEVICES: &[&str] = &["null", "zero", "full", "random", "urandom"];
60
61/// Builds the OCI spec for `req`. `rootfs_mount` is the directory libcontainer pivots into. Single
62/// mode gives the job its own user namespace with one id. Subuid mode has no user namespace in the
63/// spec: the job runs in the helper's, which maps the subordinate range (see `build_linux`).
64pub 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/// The job env in the order the container sees it: PATH, HOME, CI, FORGE_CI from the image and
113/// defaults, then the image env, then the job env and secrets. Later entries win.
114fn 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
139fn check_nul(entry: &str) -> Result<(), SandboxError> {
140 if entry.contains('\0') {
141 // The name is safe to show, the value is not.
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/// The capabilities a subuid job's root gets, the same kind a rootless container gets. They act
149/// only inside the job's user namespace, on the ids it maps, so they can't touch the host. Package
150/// managers need them to change file ownership and to drop privileges.
151const 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/// The job's capability sets. Single mode has none. Subuid mode has [`SUBUID_CAPABILITIES`] in
168/// the bounding, effective and permitted sets. Inheritable and ambient stay empty, so an exec
169/// doesn't gain more.
170fn 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
187fn 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
199fn 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
211fn build_linux(req: &SandboxRequest) -> Result<oci_spec::runtime::Linux, SandboxError> {
212 // In subuid mode the job runs in the helper's own user namespace, which already maps the
213 // runner's uid and the subordinate range. libcontainer writes only one id range for a new
214 // user namespace, and the kernel refuses a multi-id root map written from the parent. So the
215 // job can't get its own namespace with the full range, and it uses the helper's.
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 // Single mode: container root maps to the helper's root, which is the runner's uid.
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 // No seccomp section: libcontainer's libseccomp support is off, so it would ignore one. The
251 // profile is installed by `exec::FilteredExecutor` instead.
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
270fn ns(typ: LinuxNamespaceType) -> Result<oci_spec::runtime::LinuxNamespace, SandboxError> {
271 LinuxNamespaceBuilder::default()
272 .typ(typ)
273 .build()
274 .map_err(spec_err)
275}
276
277fn 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/// Parses `4g`, `512m`, `64k`, `1024` or `10b` into bytes.
306pub 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/// Parses `cpus` such as `2` or `1.5` into a positive CPU count.
325pub 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
336fn 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 // A sysfs mount needs a network namespace owned by the user namespace, and the shared
370 // host one is not. A read-only recursive bind of the host's /sys is the fallback.
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
418fn mount(dest: &str, typ: &str, source: &str, options: &[&str]) -> Result<Mount, SandboxError> {
419 mount_path(dest, typ, Path::new(source), options)
420}
421
422fn 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
437fn spec_err(e: impl std::fmt::Display) -> SandboxError {
438 SandboxError::Setup(format!("cannot build OCI spec: {e}"))
439}
440
441#[cfg(test)]
442mod 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 // The seccomp filter is built in `seccomp.rs` and installed by `exec.rs`. A seccomp
563 // section here would be dead config that libcontainer ignores.
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 // The image PATH is used, so no default PATH is added.
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}