OXYGENHUB

Coverage / crates/flow-core/src/pipeline.rs 90.5%

991 of 1095 lines run, 141 of 228 functions · run #222 on main · run missed

1
//! `.flow/pipeline.kdl`: the M0 subset of spec §3 (docs/m0.md §1), parsed
2
//! from KDL 2.0 and validated before anything runs. Every problem is
3
//! reported with its line, and all of them at once.
4
5
use serde::{Deserialize, Serialize};
6
use std::collections::{BTreeMap, BTreeSet};
7
use std::fmt;
8
9
/// Where a pipeline lives in the state being built.
10
pub const PIPELINE_PATH: &str = ".flow/pipeline.kdl";
11
12
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
13
pub struct Pipeline {
14
    pub schema_version: u32,
15
    /// By name.
16
    pub sandboxes: BTreeMap<String, Sandbox>,
17
    /// By name.
18
    pub jobs: BTreeMap<String, Job>,
19
    /// When the pipeline's jobs run, unless a job says (oxygenflow#24);
20
    /// absent: on every push and from "Run pipeline".
21
    #[serde(default, skip_serializing_if = "Option::is_none")]
22
    pub on: Option<On>,
23
}
24
25
/// What starts a job (`on { push "main"; tag "v*"; manual; schedule "0 3 * * *" }`).
26
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
27
pub struct On {
28
    /// Bookmark globs a push runs it for; `Some(vec![])`: any bookmark.
29
    #[serde(default, skip_serializing_if = "Option::is_none")]
30
    pub push: Option<Vec<String>>,
31
    /// Tag globs a pushed tag runs it for; `Some(vec![])`: any tag.
32
    #[serde(default, skip_serializing_if = "Option::is_none")]
33
    pub tag: Option<Vec<String>>,
34
    /// From "Run pipeline" (and `oh run start`).
35
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
36
    pub manual: bool,
37
    /// Five-field cron lines, on the hub's clock, for the default bookmark.
38
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39
    pub schedule: Vec<String>,
40
}
41
42
/// Why a run started, for [`On::matches`].
43
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44
pub enum Event<'a> {
45
    /// A push moved this bookmark.
46
    Push(&'a str),
47
    /// This tag was pushed.
48
    Tag(&'a str),
49
    /// "Run pipeline", `oh run start`.
50
    Manual,
51
    /// A rerun of a scheduled run: any job with a schedule.
52
    Schedule,
53
    /// The hub's clock at a minute (UTC): the jobs with a line due then.
54
    ScheduleAt(When),
55
}
56
57
/// A minute of the hub's clock, UTC, as cron reads it.
58
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59
pub struct When {
60
    pub minute: u32,
61
    pub hour: u32,
62
    pub day: u32,
63
    pub month: u32,
64
    /// 0 is Sunday.
65
    pub weekday: u32,
66
}
67
68
impl On {
69
    /// What a job without any `on` runs for: every push, and by hand.
702×
    pub fn default_on() -> On {
712×
        On { push: Some(vec![]), tag: None, manual: true, schedule: vec![] }
722×
    }
73
7416×
    pub fn matches(&self, event: Event<'_>) -> bool {
7516×
        let any = |globs: &Option<Vec<String>>, name: &str| match globs {
762×
            None => false,
776×
            Some(g) if g.is_empty() => true,
787×
            Some(g) => g.iter().any(|p| globset::Glob::new(p).is_ok_and(|p| p.compile_matcher().is_match(name))),
798×
        };
8016×
        match event {
815×
            Event::Push(b) => any(&self.push, b),
823×
            Event::Tag(t) => any(&self.tag, t),
834×
            Event::Manual => self.manual,
841×
            Event::Schedule => !self.schedule.is_empty(),
853×
            Event::ScheduleAt(w) => self
863×
                .schedule
873×
                .iter()
883×
                .filter_map(|l| crate::cron::Cron::parse(l))
893×
                .any(|c| c.matches(w.minute, w.hour, w.day, w.month, w.weekday)),
90
        }
9116×
    }
92
}
93
94
impl Pipeline {
95
    /// When `job` runs: its own `on`, else the pipeline's, else the default.
9616×
    pub fn on_of(&self, job: &Job) -> On {
9716×
        job.on.clone().or_else(|| self.on.clone()).unwrap_or_else(On::default_on)
9816×
    }
99
}
100
101
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
102
#[serde(rename_all = "lowercase")]
103
pub enum Runtime {
104
    /// Tier 0 (spec §4.1); refused in M0.
105
    Wasm,
106
    /// Tier 1 (spec §4.2): namespaces, Landlock, seccomp, cgroups.
107
    Syscall,
108
    /// Tier 2 (spec §4.3), the default.
109
    Microvm,
110
}
111
112
impl Runtime {
11342×
    pub fn as_str(self) -> &'static str {
11442×
        match self {
1150×
            Runtime::Wasm => "wasm",
1160×
            Runtime::Syscall => "syscall",
11742×
            Runtime::Microvm => "microvm",
118
        }
11942×
    }
120
}
121
122
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
123
#[serde(rename_all = "lowercase")]
124
pub enum Network {
125
    Isolated,
126
    Loopback,
127
    Egress,
128
}
129
130
impl Network {
13142×
    pub fn as_str(self) -> &'static str {
13242×
        match self {
13339×
            Network::Isolated => "isolated",
1341×
            Network::Loopback => "loopback",
1352×
            Network::Egress => "egress",
136
        }
13742×
    }
138
}
139
140
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141
pub struct Sandbox {
142
    pub name: String,
143
    pub runtime: Runtime,
144
    /// BLAKE3 of the rootfs image, hex.
145
    pub rootfs: String,
146
    pub memory_mib: u32,
147
    pub cpus: u32,
148
    pub network: Network,
149
    /// Environment every job in this sandbox starts from; a job's own `env`
150
    /// overrides it.
151
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
152
    pub env: BTreeMap<String, String>,
153
    /// Labels a runner must have to run jobs here (`runs-on`), sorted.
154
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155
    pub runs_on: Vec<String>,
156
    /// The hosts a `network "egress"` sandbox may reach (oxygenflow#55):
157
    /// names, or `*.` and a domain for any name under it; sorted. Its jobs
158
    /// reach them through the runner's proxy, and nothing else.
159
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160
    pub egress_allow: Vec<String>,
161
    /// How long each job here may run, in seconds (`timeout "2h"`), when
162
    /// the job does not say; the hub caps it at the site's maximum.
163
    #[serde(default, skip_serializing_if = "Option::is_none")]
164
    pub timeout_s: Option<u64>,
165
    /// The architecture the jobs need (`arch "riscv64"`, spec §11.3);
166
    /// without it, the rootfs image's.
167
    #[serde(default, skip_serializing_if = "Option::is_none")]
168
    pub arch: Option<String>,
169
}
170
171
/// An architecture name as runners report it: `x86_64`, `aarch64`, `riscv64`.
1726×
pub fn arch_ok(a: &str) -> bool {
17322×
    (2..=16).contains(&a.len()) && a.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1746×
}
175
176
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
177
pub struct Job {
178
    pub name: String,
179
    pub sandbox: String,
180
    /// Glob patterns over repository paths (spec §6.2).
181
    pub scope: Vec<String>,
182
    /// Sorted.
183
    pub depends_on: Vec<String>,
184
    pub env: BTreeMap<String, String>,
185
    /// Executed as is: no shell, no expansion (spec §2.2 I2).
186
    pub exec: Vec<String>,
187
    /// Ephemeral scratch mounts, sorted by target (spec §3.1 `mount`).
188
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189
    pub mounts: Vec<Mount>,
190
    /// Files the job produces, sorted by name (spec §5.2 `output`).
191
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192
    pub outputs: Vec<Output>,
193
    /// Artifacts of jobs this one depends on, put in its workspace before it
194
    /// starts (oxygenflow#58), sorted by path. The runner checks each against
195
    /// the producing job's receipt; this job's receipt lists them as inputs.
196
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197
    pub inputs: Vec<Input>,
198
    /// Wait for a person before running (oxygenflow#57): `approval { by
199
    /// "maintain"; others-only #true; expires "24h" }`.
200
    #[serde(default, skip_serializing_if = "Option::is_none")]
201
    pub approval: Option<Approval>,
202
    /// The environment the job deploys to (oxygenflow#59), e.g. `prod`: the
203
    /// hub records what each environment runs, and the job's identity token
204
    /// names it once the job has been approved.
205
    #[serde(default, skip_serializing_if = "Option::is_none")]
206
    pub environment: Option<String>,
207
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
208
    /// supplies each by name, the runner mounts it as a file.
209
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210
    pub secrets: Vec<Secret>,
211
    /// Identities the job asks the hub for (oxygenhub#103): an OIDC token
212
    /// for an audience, mounted as a file like a secret.
213
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214
    pub identities: Vec<Identity>,
215
    /// Labels a runner must have to take this job (`runs-on`), sorted;
216
    /// with its sandbox's, what the hub matches against a runner's.
217
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218
    pub runs_on: Vec<String>,
219
    /// How long the job may run, in seconds (`timeout "90m"`); its
220
    /// sandbox's when absent, else the site's default (oxygenflow#23).
221
    #[serde(default, skip_serializing_if = "Option::is_none")]
222
    pub timeout_s: Option<u64>,
223
    /// When it runs, over the pipeline's `on` (oxygenflow#24).
224
    #[serde(default, skip_serializing_if = "Option::is_none")]
225
    pub on: Option<On>,
226
    /// `low` or `normal` (`priority "low"`, or the pipeline's): a pipeline
227
    /// may lower its jobs below the repository's level, never raise them
228
    /// (oxygenhub#244); higher levels are the hub's to give.
229
    #[serde(default, skip_serializing_if = "Option::is_none")]
230
    pub priority: Option<String>,
231
    /// Directories under /src that hold the job's progress (`checkpoint
232
    /// "/src/target"`, oxygenflow#77), sorted: saved when its runner hands
233
    /// it back, restored before the next attempt runs the same exec.
234
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
235
    pub checkpoints: Vec<String>,
236
}
237
238
impl Job {
239
    /// The job's environment on top of its sandbox's: what it runs with.
24016×
    pub fn in_sandbox(&self, sandbox: &Sandbox) -> Job {
24116×
        let mut job = self.clone();
24216×
        for (k, v) in &sandbox.env {
2432×
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
244
        }
24516×
        job.runs_on.extend(sandbox.runs_on.iter().cloned());
24616×
        job.runs_on.sort();
24716×
        job.runs_on.dedup();
24816×
        job.timeout_s = job.timeout_s.or(sandbox.timeout_s);
24916×
        job
25016×
    }
251
252
    /// The job with variables that tell it what it builds (`FLOW_STATE`,
253
    /// `FLOW_BOOKMARK` …) added to its environment; a variable the pipeline
254
    /// sets itself keeps the pipeline's value.
25525×
    pub fn with_flow_env(&self, vars: &[(String, String)]) -> Job {
25625×
        let mut job = self.clone();
25731×
        for (k, v) in vars {
25831×
            job.env.entry(k.clone()).or_insert_with(|| v.clone());
259
        }
26025×
        job
26125×
    }
262
}
263
264
/// An ephemeral scratch mount: empty at the start of every job and gone
265
/// after it, so it cannot carry anything between jobs (spec §2.2 I4) --
266
/// unless it names a `cache` (spec §5.4, #28): then a disk kept from an
267
/// earlier passing job may start it, and the receipt says which.
268
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
269
pub struct Mount {
270
    pub source: MountSource,
271
    /// An absolute path under `/src` or `/tmp`.
272
    pub target: String,
273
    pub size_mib: u32,
274
    /// `cache "name"`: keep this disk after a passing job and start later
275
    /// jobs from it (disk mounts only).
276
    #[serde(default, skip_serializing_if = "Option::is_none")]
277
    pub cache: Option<String>,
278
    /// `key "Cargo.lock" ...`: files whose content picks the cache entry.
279
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280
    pub key: Vec<String>,
281
}
282
283
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
284
#[serde(rename_all = "lowercase")]
285
pub enum MountSource {
286
    /// `ephemeral://tmpfs`: memory in the sandbox, counted against its memory-limit.
287
    Tmpfs,
288
    /// `ephemeral://disk`: a fresh sparse disk on the runner, for large build trees.
289
    Disk,
290
}
291
292
impl MountSource {
29314×
    pub fn as_str(self) -> &'static str {
29414×
        match self {
2955×
            MountSource::Tmpfs => "ephemeral://tmpfs",
2969×
            MountSource::Disk => "ephemeral://disk",
297
        }
29814×
    }
299
}
300
301
/// A file a job produces (spec §5.2): read from the job's disks when it
302
/// ends, kept in the repository as a blob, and named by its receipt.
303
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
304
pub struct Output {
305
    /// The artifact's name, unique in the job.
306
    pub name: String,
307
    /// A clean absolute path under `/src`, not on a tmpfs mount.
308
    pub path: String,
309
    /// `when "always"`: kept from a failing job too, such as a test report
310
    /// (oxygenflow#37). From a failing job it is never in the receipt.
311
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
312
    pub always: bool,
313
    /// An artifact is one file; a report is a directory with a
314
    /// `report.toml`, kept file by file with a manifest (oxygenhub#127).
315
    #[serde(default, skip_serializing_if = "OutputKind::is_artifact")]
316
    pub kind: OutputKind,
317
}
318
319
/// An artifact a job takes from a job it depends on (oxygenflow#58):
320
/// `input { artifact "tfplan" from "plan"; path "/src/tfplan" }`.
321
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
322
pub struct Input {
323
    /// The producing job's artifact.
324
    pub artifact: String,
325
    /// The producing job: one this job depends on.
326
    pub from: String,
327
    /// Where it is put: a clean absolute path under `/src`.
328
    pub path: String,
329
}
330
331
/// Who must approve a job before it runs, and for how long it waits
332
/// (oxygenflow#57).
333
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334
pub struct Approval {
335
    /// The least role that approves: `write`, `maintain` (the default) or `admin`.
336
    pub by: String,
337
    /// Not the person whose push started the run.
338
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
339
    pub others_only: bool,
340
    /// How long it waits once the jobs it depends on are done, in seconds.
341
    pub expires_s: u64,
342
    /// The approval must be signed with the approver's own key
343
    /// (`oxy seal --flow`; oxygenhub#235). Defaults to on for a job that
344
    /// names an environment.
345
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
346
    pub sealed: bool,
347
}
348
349
/// The longest a job waits for approval: 30 days.
350
const MAX_APPROVAL_S: u64 = 30 * 24 * 3600;
351
352
/// The most inputs a job takes.
353
const MAX_INPUTS: usize = 8;
354
355
/// What an [`Output`] is.
356
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
357
#[serde(rename_all = "lowercase")]
358
pub enum OutputKind {
359
    #[default]
360
    Artifact,
361
    Report,
362
}
363
364
impl OutputKind {
36516×
    pub fn is_artifact(&self) -> bool {
36616×
        *self == OutputKind::Artifact
36716×
    }
368
36940×
    pub fn word(&self) -> &'static str {
37040×
        match self {
37124×
            OutputKind::Artifact => "artifact",
37216×
            OutputKind::Report => "report",
373
        }
37440×
    }
375
}
376
377
/// A secret the job reads as a file (spec §9): never in the environment.
378
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
379
pub struct Secret {
380
    /// Which secret, as the hub names it; unique in the job.
381
    pub name: String,
382
    /// A clean absolute path under `/run/secrets/`, unique in the job;
383
    /// the file is 0400, the job's user's, on an in-memory mount.
384
    pub mount_path: String,
385
}
386
387
/// An OIDC token the hub mints for the job (oxygenhub#103), naming the
388
/// repository, bookmark, run and job, for `audience`; read as a file.
389
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
390
pub struct Identity {
391
    /// Who the token is for (`sts.amazonaws.com`, a Vault role's audience, …).
392
    pub audience: String,
393
    /// A clean absolute path under `/run/secrets/`, unique among the
394
    /// job's secrets and identities.
395
    pub mount_path: String,
396
}
397
398
/// A secret's mount path: under `/run/secrets/`, no `.` or `..`.
39917×
pub fn secret_path_ok(p: &str) -> bool {
40017×
    p.strip_prefix("/run/secrets/").is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
40117×
}
402
403
/// Artifacts a job may declare.
404
pub const MAX_OUTPUTS: usize = 16;
405
406
/// Whether `p` is a clean absolute path strictly under `/src`, with nothing
407
/// a debugfs command line would have to quote.
40846×
fn output_path_ok(p: &str) -> bool {
40946×
    p.strip_prefix("/src/").is_some_and(|rest| {
41044×
        !rest.is_empty()
41172×
            && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")
412488×
            && p.bytes().all(|b| b.is_ascii_graphic() && b != b'"' && b != b'\\')
41344×
    })
41446×
}
415
416
/// Mounts a job may declare.
417
pub const MAX_MOUNTS: usize = 4;
418
/// The most secrets a job may mount.
419
pub const MAX_SECRETS: usize = 8;
420
/// The most identities a job may ask for.
421
pub const MAX_IDENTITIES: usize = 4;
422
/// Sizes a mount may have: 16 MiB to 64 GiB.
423
pub const MIN_MOUNT_MIB: u64 = 16;
424
pub const MAX_MOUNT_MIB: u64 = 64 * 1024;
425
426
/// Whether `t` is a clean absolute path strictly under `/src` or `/tmp`.
42760×
fn mount_target_ok(t: &str) -> bool {
42860×
    let under = t.strip_prefix("/src/").or_else(|| t.strip_prefix("/tmp/"));
42960×
    under.is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != ".."))
43060×
}
431
432
/// One problem in a pipeline, at a 1-based line.
433
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
434
pub struct Problem {
435
    pub line: usize,
436
    pub message: String,
437
}
438
439
impl fmt::Display for Problem {
44022×
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44122×
        write!(f, "line {}: {}", self.line, self.message)
44222×
    }
443
}
444
445
/// Every problem found, in line order.
446
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
447
pub struct Problems(pub Vec<Problem>);
448
449
impl fmt::Display for Problems {
45017×
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45122×
        let lines: Vec<String> = self.0.iter().map(|p| p.to_string()).collect();
45217×
        f.write_str(&lines.join("\n"))
45317×
    }
454
}
455
456
const MIN_MEMORY_MIB: u64 = 64;
457
const MAX_MEMORY_MIB: u64 = 16 * 1024;
458
const MAX_CPUS: i128 = 16;
459
460
/// Nodes of the full spec that M0 recognises and refuses.
461
const LATER_TOP: &[&str] = &["publish", "secret"];
462
const LATER_JOB: &[&str] = &["attest"];
463
464
struct Ctx<'a> {
465
    src: &'a str,
466
    problems: Vec<Problem>,
467
}
468
469
impl Ctx<'_> {
470108×
    fn line(&self, span: SourceSpan) -> usize {
471108×
        let at = span.offset().min(self.src.len());
47228700×
        self.src[..at].bytes().filter(|b| *b == b'\n').count() + 1
473108×
    }
474
475108×
    fn err(&mut self, span: SourceSpan, message: impl Into<String>) {
476108×
        let line = self.line(span);
477108×
        self.problems.push(Problem { line, message: message.into() });
478108×
    }
479
480
    /// The node's arguments as strings; properties and other types are errors.
4811525×
    fn strings(&mut self, node: &KdlNode) -> Vec<String> {
4821525×
        let mut out = Vec::new();
4831568×
        for e in node.entries() {
4841568×
            if let Some(k) = e.name() {
4850×
                self.err(e.span(), format!("`{}` takes no property `{}`", node.name().value(), k.value()));
4860×
                continue;
4871568×
            }
4881568×
            match e.value() {
4891567×
                KdlValue::String(s) => out.push(s.clone()),
4901×
                other => self.err(e.span(), format!("`{}` takes strings, not {}", node.name().value(), kind_of(other))),
491
            }
492
        }
4931525×
        out
4941525×
    }
495
496
    /// Exactly one string argument.
4971296×
    fn one_string(&mut self, node: &KdlNode) -> Option<String> {
4981296×
        let v = self.strings(node);
4991296×
        if v.len() != 1 {
5000×
            self.err(node.span(), format!("`{}` takes one string", node.name().value()));
5010×
            return None;
5021296×
        }
5031296×
        v.into_iter().next()
5041296×
    }
505
506242×
    fn one_integer(&mut self, node: &KdlNode) -> Option<i128> {
507242×
        match node.entries() {
508242×
            [e] if e.name().is_none() => match e.value() {
509241×
                KdlValue::Integer(n) => Some(*n),
5101×
                other => {
5111×
                    self.err(e.span(), format!("`{}` takes an integer, not {}", node.name().value(), kind_of(other)));
5121×
                    None
513
                }
514
            },
515
            _ => {
5160×
                self.err(node.span(), format!("`{}` takes one integer", node.name().value()));
5170×
                None
518
            }
519
        }
520242×
    }
521
5221412×
    fn no_children(&mut self, node: &KdlNode) {
5231412×
        if node.children().is_some() {
5241×
            self.err(node.span(), format!("`{}` takes no block", node.name().value()));
5251411×
        }
5261412×
    }
527
528373×
    fn name_ok(&mut self, span: SourceSpan, what: &str, name: &str) -> bool {
5291375×
        let ok = !name.is_empty() && name.len() <= 64 && name.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
530373×
        if !ok {
5310×
            self.err(span, format!("{what} name {name:?} must be 1 to 64 of A-Z a-z 0-9 . _ -"));
532373×
        }
533373×
        ok
534373×
    }
535
}
536
537
use kdl::{KdlDocument, KdlNode, KdlValue};
538
use miette::SourceSpan;
539
5402×
fn kind_of(v: &KdlValue) -> &'static str {
5412×
    match v {
5421×
        KdlValue::String(_) => "a string",
5431×
        KdlValue::Integer(_) => "an integer",
5440×
        KdlValue::Float(_) => "a number",
5450×
        KdlValue::Bool(_) => "a boolean",
5460×
        KdlValue::Null => "null",
547
    }
5482×
}
549
550
/// `"512MiB"`, `"4GiB"`, `"65536KiB"` to MiB.
551172×
fn parse_memory(s: &str) -> Option<u64> {
552572×
    let (num, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit())?);
553172×
    let n: u64 = num.parse().ok()?;
554171×
    let mib = match unit {
555171×
        "KiB" => n.is_multiple_of(1024).then_some(n / 1024)?,
556171×
        "MiB" => n,
55746×
        "GiB" => n.checked_mul(1024)?,
5581×
        _ => return None,
559
    };
560170×
    Some(mib)
561172×
}
562
563
/// The line of the first escape KDL 2.0 does not have but shells and C
564
/// do (`\0NN`, `\e`, `\xNN`), as someone colouring output writes them.
5652×
fn foreign_escape_line(src: &str) -> Option<usize> {
5662×
    src.lines()
56717×
        .position(|l| {
56817×
            let b = l.as_bytes();
569287×
            b.windows(2).enumerate().any(|(i, w)| {
570287×
                w[0] == b'\\'
5711×
                    && (i == 0 || b[i - 1] != b'\\')
5721×
                    && (w[1] == b'e' || w[1] == b'x' || (w[1] == b'0' && b.get(i + 2).is_some_and(u8::is_ascii_digit)))
573287×
            })
57417×
        })
5752×
        .map(|i| i + 1)
5762×
}
577
578
impl Pipeline {
579
    /// Parse and validate a pipeline document.
580135×
    pub fn parse(src: &str) -> Result<Pipeline, Problems> {
581135×
        let doc = match KdlDocument::parse_v2(src) {
582133×
            Ok(d) => d,
5832×
            Err(e) => {
5842×
                let mut ctx = Ctx { src, problems: Vec::new() };
5852×
                for d in &e.diagnostics {
5862×
                    let msg = d.message.clone().or_else(|| d.label.clone()).unwrap_or_else(|| "not valid KDL 2.0".into());
5872×
                    ctx.err(d.span, msg);
588
                }
5892×
                if ctx.problems.is_empty() {
5900×
                    ctx.problems.push(Problem { line: 1, message: "not valid KDL 2.0".into() });
5912×
                }
5922×
                if let Some(line) = foreign_escape_line(src) {
5931×
                    ctx.problems.push(Problem {
5941×
                        line,
5951×
                        message: "KDL strings have no \\033, \\e or \\x escapes: write ESC (for ANSI colours) as \\u{1b}".into(),
5961×
                    });
5971×
                }
5982×
                return Err(Problems(ctx.problems));
599
            }
600
        };
601133×
        let mut ctx = Ctx { src, problems: Vec::new() };
602133×
        let mut schema = None;
603133×
        let mut sandboxes = BTreeMap::new();
604133×
        let mut jobs: BTreeMap<String, (Job, SourceSpan)> = BTreeMap::new();
605133×
        let mut on = None;
606133×
        let mut priority = None;
607445×
        for node in doc.nodes() {
608445×
            match node.name().value() {
609445×
                "priority" => {
6101×
                    if priority.is_some() {
6110×
                        ctx.err(node.span(), "`priority` is given twice");
6121×
                    }
6131×
                    priority = parse_priority(&mut ctx, node);
614
                }
615444×
                "on" => {
6164×
                    if on.is_some() {
6170×
                        ctx.err(node.span(), "`on` is given twice");
6184×
                    }
6194×
                    on = Some(parse_on(&mut ctx, node));
620
                }
621440×
                "schema-version" => {
622133×
                    ctx.no_children(node);
623133×
                    match ctx.one_integer(node) {
624132×
                        Some(1) => schema = Some(1),
6251×
                        Some(n) => ctx.err(node.span(), format!("schema-version {n} is not supported; this runner reads 1")),
6260×
                        None => {}
627
                    }
628
                }
629307×
                "sandbox" => {
630132×
                    if let Some(sb) = parse_sandbox(&mut ctx, node) {
631130×
                        if sandboxes.contains_key(&sb.name) {
6320×
                            ctx.err(node.span(), format!("sandbox {:?} is declared twice", sb.name));
633130×
                        } else {
634130×
                            sandboxes.insert(sb.name.clone(), sb);
635130×
                        }
6362×
                    }
637
                }
638175×
                "job" => {
639174×
                    if let Some(job) = parse_job(&mut ctx, node) {
640174×
                        if jobs.contains_key(&job.name) {
6410×
                            ctx.err(node.span(), format!("job {:?} is declared twice", job.name));
642174×
                        } else {
643174×
                            jobs.insert(job.name.clone(), (job, node.span()));
644174×
                        }
6450×
                    }
646
                }
6471×
                n if LATER_TOP.contains(&n) => ctx.err(node.span(), format!("`{n}` is not in M0 yet")),
6481×
                n => ctx.err(node.span(), format!("unknown node `{n}`; expected schema-version, on, priority, sandbox or job")),
649
            }
650
        }
651133×
        if schema.is_none() && !ctx.problems.iter().any(|p| p.message.contains("schema-version")) {
6520×
            ctx.problems.push(Problem { line: 1, message: "`schema-version 1` is required".into() });
653133×
        }
654133×
        if jobs.is_empty() && ctx.problems.is_empty() {
6550×
            ctx.problems.push(Problem { line: 1, message: "a pipeline needs at least one job".into() });
656133×
        }
657
        // References and cycles.
658174×
        for (job, span) in jobs.values() {
659174×
            if !job.sandbox.is_empty() && !sandboxes.contains_key(&job.sandbox) {
6603×
                ctx.err(*span, format!("job {:?} uses sandbox {:?}, which is not declared", job.name, job.sandbox));
661171×
            }
662174×
            for d in &job.depends_on {
66317×
                if !jobs.contains_key(d) {
6641×
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
66516×
                } else if d == &job.name {
6661×
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
66715×
                }
668
            }
669
            // Inputs come from a job this one depends on, which declares
670
            // that artifact (oxygenflow#58).
671174×
            for i in &job.inputs {
6726×
                let Some((producer, _)) = jobs.get(&i.from) else {
6731×
                    ctx.err(*span, format!("job {:?} takes an input from {:?}, which is not declared", job.name, i.from));
6741×
                    continue;
675
                };
6765×
                if !job.depends_on.contains(&i.from) {
6771×
                    ctx.err(*span, format!("job {:?} takes {:?} from {:?}: add depends-on \"{}\"", job.name, i.artifact, i.from, i.from));
6784×
                }
6799×
                match producer.outputs.iter().find(|o| o.name == i.artifact) {
6804×
                    Some(o) if o.kind == OutputKind::Artifact => {}
6811×
                    Some(_) => ctx.err(*span, format!("{:?} of job {:?} is a report; inputs are artifacts", i.artifact, i.from)),
6821×
                    None => ctx.err(*span, format!("job {:?} declares no artifact {:?}", i.from, i.artifact)),
683
                }
684
            }
685
        }
686133×
        let graph: BTreeMap<&str, Vec<&str>> =
687174×
            jobs.values().map(|(j, _)| (j.name.as_str(), j.depends_on.iter().map(String::as_str).collect())).collect();
688133×
        if let Some(cycle) = find_cycle(&graph) {
6891×
            let span = jobs[cycle[0]].1;
6901×
            ctx.err(span, format!("jobs depend on each other in a cycle: {}", cycle.join(" → ")));
691132×
        }
692133×
        if !ctx.problems.is_empty() {
69379×
            ctx.problems.sort_by_key(|p| p.line);
69479×
            return Err(Problems(ctx.problems));
69554×
        }
696
        // The pipeline's priority is each job's that does not say.
69754×
        let jobs = jobs
69854×
            .into_iter()
69976×
            .map(|(k, (mut j, _))| {
70076×
                if j.priority.is_none() {
70174×
                    j.priority.clone_from(&priority);
70274×
                }
70376×
                (k, j)
70476×
            })
70554×
            .collect();
70654×
        Ok(Pipeline { schema_version: 1, sandboxes, jobs, on })
707135×
    }
708
709
    /// Jobs in an order where each comes after what it depends on (ties by name).
7104×
    pub fn order(&self) -> Vec<&Job> {
7114×
        let mut done: BTreeSet<&str> = BTreeSet::new();
7124×
        let mut out = Vec::with_capacity(self.jobs.len());
71310×
        while out.len() < self.jobs.len() {
7146×
            let before = out.len();
71520×
            for j in self.jobs.values() {
71620×
                if !done.contains(j.name.as_str()) && j.depends_on.iter().all(|d| done.contains(d.as_str())) {
71716×
                    out.push(j);
71816×
                }
719
            }
72016×
            for j in &out[before..] {
72116×
                done.insert(j.name.as_str());
72216×
            }
7236×
            if out.len() == before {
7240×
                break; // validated pipelines have no cycles
7256×
            }
726
        }
7274×
        out
7284×
    }
729
730
    /// Each job's depth: 0 without dependencies, else one more than its deepest dependency.
7311×
    pub fn depths(&self) -> BTreeMap<&str, usize> {
7321×
        let mut depth: BTreeMap<&str, usize> = BTreeMap::new();
7332×
        for j in self.order() {
7342×
            let d = j.depends_on.iter().map(|x| depth.get(x.as_str()).copied().unwrap_or(0) + 1).max().unwrap_or(0);
7352×
            depth.insert(j.name.as_str(), d);
736
        }
7371×
        depth
7381×
    }
739
}
740
741133×
fn find_cycle<'a>(graph: &BTreeMap<&'a str, Vec<&'a str>>) -> Option<Vec<&'a str>> {
742
    #[derive(Clone, Copy, PartialEq)]
743
    enum Mark {
744
        Visiting,
745
        Done,
746
    }
747186×
    fn visit<'a>(
748186×
        n: &'a str,
749186×
        graph: &BTreeMap<&'a str, Vec<&'a str>>,
750186×
        marks: &mut BTreeMap<&'a str, Mark>,
751186×
        stack: &mut Vec<&'a str>,
752186×
    ) -> Option<Vec<&'a str>> {
753186×
        match marks.get(n) {
75412×
            Some(Mark::Done) => return None,
755
            Some(Mark::Visiting) => {
7561×
                let at = stack.iter().position(|s| *s == n).unwrap_or(0);
7571×
                let mut c = stack[at..].to_vec();
7581×
                c.push(n);
7591×
                return Some(c);
760
            }
761173×
            None => {}
762
        }
763173×
        marks.insert(n, Mark::Visiting);
764173×
        stack.push(n);
765173×
        for d in graph.get(n).into_iter().flatten() {
76616×
            if graph.contains_key(d) && *d != n {
76715×
                if let Some(c) = visit(d, graph, marks, stack) {
7683×
                    return Some(c);
76912×
                }
7701×
            }
771
        }
772170×
        stack.pop();
773170×
        marks.insert(n, Mark::Done);
774170×
        None
775186×
    }
776133×
    let mut marks = BTreeMap::new();
777171×
    for n in graph.keys() {
778171×
        let mut stack = Vec::new();
779171×
        if let Some(c) = visit(n, graph, &mut marks, &mut stack) {
7801×
            return Some(c);
781170×
        }
782
    }
783132×
    None
784133×
}
785
786486×
fn block<'a>(ctx: &mut Ctx, node: &'a KdlNode) -> &'a [KdlNode] {
787486×
    match node.children() {
788485×
        Some(d) => d.nodes(),
789
        None => {
7901×
            ctx.err(node.span(), format!("`{}` needs a block {{ … }}", node.name().value()));
7911×
            &[]
792
        }
793
    }
794486×
}
795
796132×
fn parse_sandbox(ctx: &mut Ctx, node: &KdlNode) -> Option<Sandbox> {
797132×
    let name = ctx.one_string(node)?;
798132×
    let named = ctx.name_ok(node.span(), "sandbox", &name);
799132×
    let (mut runtime, mut rootfs, mut memory, mut cpus, mut network) =
800132×
        (Some(Runtime::Microvm), None, Some(1024u32), Some(1u32), Some(Network::Isolated));
801132×
    let mut seen = BTreeSet::new();
802132×
    let mut env = BTreeMap::new();
803132×
    let mut runs_on = Vec::new();
804132×
    let mut timeout_s = None;
805132×
    let mut arch: Option<String> = None;
806132×
    let mut egress_allow = Vec::new();
807594×
    for c in block(ctx, node) {
808594×
        let key = c.name().value();
809594×
        if key != "runs-on" && !seen.insert(key.to_string()) {
8100×
            ctx.err(c.span(), format!("`{key}` is given twice"));
811594×
        }
812594×
        if key == "env" {
8132×
            parse_env(ctx, c, &mut env);
8142×
            continue;
815592×
        }
816592×
        if key == "runs-on" {
8171×
            ctx.no_children(c);
8181×
            parse_labels(ctx, c, &mut runs_on);
8191×
            continue;
820591×
        }
821591×
        if key != "network" {
822469×
            ctx.no_children(c);
823469×
        }
824591×
        match key {
825591×
            "runtime" => {
826115×
                runtime = match ctx.one_string(c).as_deref() {
827115×
                    Some("microvm") => Some(Runtime::Microvm),
8287×
                    Some("syscall") | Some("gvisor") => Some(Runtime::Syscall),
829
                    // A WASI module run by wasmtime (spec §4.1, oxygenflow#75).
8306×
                    Some("wasm") => Some(Runtime::Wasm),
8310×
                    Some(other) => {
8320×
                        ctx.err(c.span(), format!("runtime {other:?}: expected \"microvm\", \"syscall\" or \"wasm\""));
8330×
                        None
834
                    }
8350×
                    None => None,
836
                }
837
            }
838476×
            "rootfs" => {
839127×
                rootfs = match ctx.one_string(c) {
840127×
                    Some(r) => match r.strip_prefix("cas://blob/") {
8418064×
                        Some(h) if h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) => {
842126×
                            Some(h.to_string())
843
                        }
844
                        _ => {
8451×
                            ctx.err(c.span(), format!("rootfs {r:?}: expected \"cas://blob/\" and 64 lower-case hex digits"));
8461×
                            None
847
                        }
848
                    },
8490×
                    None => None,
850
                }
851
            }
852349×
            "arch" => {
8532×
                arch = match ctx.one_string(c) {
8542×
                    Some(a) if arch_ok(&a) => Some(a),
8551×
                    Some(a) => {
8561×
                        ctx.err(c.span(), format!("arch {a:?} is not an architecture name such as x86_64, aarch64 or riscv64"));
8571×
                        None
858
                    }
8590×
                    None => None,
860
                }
861
            }
862347×
            "memory-limit" => {
863113×
                memory = match ctx.one_string(c) {
864113×
                    Some(m) => match parse_memory(&m) {
865112×
                        Some(mib) if (MIN_MEMORY_MIB..=MAX_MEMORY_MIB).contains(&mib) => Some(mib as u32),
866
                        Some(_) => {
8670×
                            ctx.err(c.span(), format!("memory-limit {m:?} must be from 64MiB to 16GiB"));
8680×
                            None
869
                        }
870
                        None => {
8711×
                            ctx.err(c.span(), format!("memory-limit {m:?}: expected a whole number of KiB, MiB or GiB, as \"4GiB\""));
8721×
                            None
873
                        }
874
                    },
8750×
                    None => None,
876
                }
877
            }
878234×
            "cpu-limit" => {
879109×
                cpus = match ctx.one_integer(c) {
880108×
                    Some(n) if (1..=MAX_CPUS).contains(&n) => Some(n as u32),
8810×
                    Some(n) => {
8820×
                        ctx.err(c.span(), format!("cpu-limit {n} must be from 1 to {MAX_CPUS}"));
8830×
                        None
884
                    }
8851×
                    None => None,
886
                }
887
            }
888125×
            "network" => {
889122×
                let kind = ctx.one_string(c);
890122×
                if kind.as_deref() != Some("egress") {
891113×
                    ctx.no_children(c);
892113×
                }
893122×
                network = match kind.as_deref() {
894122×
                    Some("isolated") => Some(Network::Isolated),
895
                    // Loopback only: the guest's `lo` comes up, and it still has
896
                    // no device that reaches anything outside it.
89713×
                    Some("loopback") => Some(Network::Loopback),
898
                    // Allow-listed egress (oxygenflow#55): still no network
899
                    // device; a proxy in the guest relays to the runner, which
900
                    // lets through only the hosts named here.
90110×
                    Some("egress") => {
9029×
                        egress_allow = parse_egress(ctx, c);
9039×
                        Some(Network::Egress)
904
                    }
9051×
                    Some(other) => {
9061×
                        ctx.err(c.span(), format!("network {other:?}: expected \"isolated\", \"loopback\" or \"egress\""));
9071×
                        None
908
                    }
9090×
                    None => None,
910
                }
911
            }
9123×
            "timeout" => timeout_s = parse_timeout(ctx, c),
9130×
            other => ctx.err(c.span(), format!("unknown sandbox setting `{other}`")),
914
        }
915
    }
916
    // A WASM sandbox has no image: its module is a file of the job (its
917
    // scope or an earlier job's artifact), and it reaches no network.
918132×
    let wasm = runtime == Some(Runtime::Wasm);
919132×
    if wasm {
9206×
        if seen.contains("rootfs") {
9211×
            ctx.err(node.span(), format!("sandbox {name:?}: runtime \"wasm\" takes no rootfs; its module is a file the job has"));
9225×
        }
9236×
        if network.is_some_and(|n| n != Network::Isolated) {
9241×
            ctx.err(node.span(), format!("sandbox {name:?}: runtime \"wasm\" has no network; leave out `network` or say \"isolated\""));
9255×
        }
9266×
        rootfs = Some(String::new());
9276×
        network = Some(Network::Isolated);
928126×
    } else if rootfs.is_none() && !seen.contains("rootfs") {
9290×
        ctx.err(node.span(), format!("sandbox {name:?} needs a rootfs"));
930126×
    }
931132×
    if network == Some(Network::Egress) {
9329×
        if runtime == Some(Runtime::Syscall) {
9331×
            ctx.err(node.span(), format!("sandbox {name:?}: network \"egress\" needs runtime \"microvm\""));
9348×
        }
935
        // Only a runner that allows egress offers this label.
9369×
        if !runs_on.iter().any(|l| l == EGRESS_LABEL) {
9379×
            runs_on.push(EGRESS_LABEL.to_string());
9389×
            runs_on.sort();
9399×
        }
940123×
    }
941132×
    if !named {
9420×
        return None;
943132×
    }
944
    Some(Sandbox {
945132×
        name,
946132×
        runtime: runtime?,
947132×
        rootfs: rootfs?,
948131×
        memory_mib: memory?,
949130×
        cpus: cpus?,
950130×
        network: network?,
951130×
        env,
952130×
        runs_on,
953130×
        egress_allow,
954130×
        timeout_s,
955130×
        arch,
956
    })
957132×
}
958
959
/// The label a runner offers when it allows egress, and an egress sandbox
960
/// needs (oxygenflow#55).
961
pub const EGRESS_LABEL: &str = "egress";
962
963
/// The most hosts one sandbox may name.
964
const MAX_EGRESS_HOSTS: usize = 32;
965
966
/// Whether `pattern` is a host name, or `*.` and a domain: lower-case
967
/// letters, digits, `-` and dots, at least two labels.
9688×
pub fn egress_pattern_ok(pattern: &str) -> bool {
9698×
    let name = pattern.strip_prefix("*.").unwrap_or(pattern);
9708×
    let labels: Vec<&str> = name.split('.').collect();
9718×
    name.len() <= 253
9728×
        && labels.len() >= 2
97315×
        && labels.iter().all(|l| {
97415×
            !l.is_empty()
97515×
                && l.len() <= 63
97615×
                && !l.starts_with('-')
97715×
                && !l.ends_with('-')
97886×
                && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
97915×
        })
9808×
}
981
982
/// Whether `host` is one `patterns` allow: a name exactly, or a name under
983
/// a `*.` domain (not the domain itself). Case and a final dot are ignored.
98414×
pub fn egress_allows(patterns: &[String], host: &str) -> bool {
98514×
    let host = host.trim_end_matches('.').to_ascii_lowercase();
98625×
    patterns.iter().any(|p| match p.strip_prefix("*.") {
98714×
        Some(domain) => host.len() > domain.len() + 1 && host.ends_with(domain) && host[..host.len() - domain.len()].ends_with('.'),
98811×
        None => host == *p,
98925×
    })
99014×
}
991
992
/// `network "egress" { allow "*.googleapis.com" "registry.opentofu.org" }`.
9939×
fn parse_egress(ctx: &mut Ctx, node: &KdlNode) -> Vec<String> {
9949×
    let mut out = Vec::new();
9959×
    for c in block(ctx, node) {
9968×
        ctx.no_children(c);
9978×
        if c.name().value() != "allow" {
9981×
            ctx.err(c.span(), format!("unknown egress setting `{}`; egress takes `allow \"host\"...`", c.name().value()));
9991×
            continue;
10007×
        }
10018×
        for h in ctx.strings(c) {
10028×
            if egress_pattern_ok(&h) {
10035×
                out.push(h);
10045×
            } else {
10053×
                ctx.err(
10063×
                    c.span(),
10073×
                    format!("allow {h:?}: expected a lower-case host name such as \"registry.opentofu.org\", or \"*.\" and a domain"),
10083×
                );
10093×
            }
1010
        }
1011
    }
10129×
    out.sort();
10139×
    out.dedup();
10149×
    if out.is_empty() {
10156×
        ctx.err(node.span(), "network \"egress\" needs at least one `allow \"host\"`");
10166×
    } else if out.len() > MAX_EGRESS_HOSTS {
10170×
        ctx.err(node.span(), format!("network \"egress\" may allow at most {MAX_EGRESS_HOSTS} hosts"));
10183×
    }
10199×
    out
10209×
}
1021
1022
/// An `on` block: `push` and `tag` with globs (none: any), `manual`,
1023
/// `schedule` with five-field cron lines.
102414×
fn parse_on(ctx: &mut Ctx, node: &KdlNode) -> On {
102514×
    let mut on = On::default();
102614×
    if !node.entries().is_empty() {
10270×
        ctx.err(node.span(), "`on` takes a block: on { push \"main\"; tag \"v*\"; manual }");
102814×
    }
102914×
    let globs = |ctx: &mut Ctx, c: &KdlNode| -> Vec<String> {
10306×
        let g = ctx.strings(c);
10317×
        for p in &g {
10327×
            if let Err(e) = globset::Glob::new(p) {
10330×
                ctx.err(c.span(), format!("{p:?}: {e}"));
10347×
            }
1035
        }
10366×
        g
10376×
    };
103819×
    for c in block(ctx, node) {
103919×
        ctx.no_children(c);
104019×
        match c.name().value() {
104119×
            "push" => on.push.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
104218×
            "tag" => on.tag.get_or_insert_with(Vec::new).extend(globs(ctx, c)),
104313×
            "manual" => on.manual = true,
10447×
            "schedule" => {
10456×
                for line in ctx.strings(c) {
10466×
                    if crate::cron::Cron::parse(&line).is_some() {
10475×
                        on.schedule.push(line);
10485×
                    } else {
10491×
                        ctx.err(
10501×
                            c.span(),
10511×
                            format!("schedule {line:?}: expected five cron fields (minute hour day month weekday), as \"0 3 * * *\""),
10521×
                        );
10531×
                    }
1054
                }
1055
            }
10561×
            other => ctx.err(c.span(), format!("unknown `on` event `{other}`; expected push, tag, manual or schedule")),
1057
        }
1058
    }
105914×
    if on.push.is_none() && on.tag.is_none() && !on.manual && on.schedule.is_empty() {
10603×
        ctx.err(node.span(), "`on` names no event, so nothing would run it");
106111×
    }
106214×
    on
106314×
}
1064
1065
/// The longest a pipeline may ask a job to run.
1066
pub const MAX_TIMEOUT_S: u64 = 24 * 3600;
1067
1068
/// A duration: whole hours, minutes and seconds in that order, each
1069
/// optional, as "90m", "2h", "1h30m" or "45s".
107019×
pub fn parse_duration(s: &str) -> Option<u64> {
107119×
    let (mut total, mut num, mut last) = (0u64, String::new(), 0u8);
107250×
    for ch in s.chars() {
107350×
        if ch.is_ascii_digit() {
107429×
            num.push(ch);
107529×
            continue;
107621×
        }
107721×
        let (unit, mult) = match ch {
107812×
            'h' => (1, 3600),
10795×
            'm' => (2, 60),
10801×
            's' => (3, 1),
10813×
            _ => return None,
1082
        };
1083
        // Each unit once, largest first, each with its number.
108418×
        if num.is_empty() || unit <= last {
10853×
            return None;
108615×
        }
108715×
        last = unit;
108815×
        total = total.checked_add(num.parse::<u64>().ok()?.checked_mul(mult)?)?;
108915×
        num.clear();
1090
    }
109113×
    (num.is_empty() && total > 0).then_some(total)
109219×
}
1093
1094
/// A `timeout` line: one duration, from a minute to a day.
1095
/// `priority "low"`: a pipeline may lower its jobs, never raise them
1096
/// (oxygenhub#244).
10976×
fn parse_priority(ctx: &mut Ctx, c: &KdlNode) -> Option<String> {
10986×
    ctx.no_children(c);
10996×
    let p = ctx.one_string(c)?;
11006×
    match p.as_str() {
11016×
        "low" | "normal" => Some(p),
11023×
        "high" | "urgent" => {
11032×
            ctx.err(
11042×
                c.span(),
11052×
                format!("priority {p:?}: a pipeline may lower its jobs (`low`, `normal`), not raise them; higher levels are set on the hub, and a person may expedite a run"),
1106
            );
11072×
            None
1108
        }
1109
        _ => {
11101×
            ctx.err(c.span(), format!("priority {p:?}: expected `low` or `normal`"));
11111×
            None
1112
        }
1113
    }
11146×
}
1115
11166×
fn parse_timeout(ctx: &mut Ctx, c: &KdlNode) -> Option<u64> {
11176×
    let t = ctx.one_string(c)?;
11186×
    match parse_duration(&t) {
11195×
        Some(secs) if (60..=MAX_TIMEOUT_S).contains(&secs) => Some(secs),
1120
        Some(_) => {
11211×
            ctx.err(c.span(), format!("timeout {t:?} must be from 1m to 24h"));
11221×
            None
1123
        }
1124
        None => {
11251×
            ctx.err(c.span(), format!("timeout {t:?}: expected hours, minutes and seconds, as \"90m\" or \"1h30m\""));
11261×
            None
1127
        }
1128
    }
11296×
}
1130
1131
/// The labels of a `runs-on` line: lower-case letters, digits, `-`, `_`
1132
/// and `.`, at most 64 bytes each; into `into`, sorted, once each.
11334×
fn parse_labels(ctx: &mut Ctx, c: &KdlNode, into: &mut Vec<String>) {
11344×
    let labels = ctx.strings(c);
11354×
    if labels.is_empty() {
11361×
        ctx.err(c.span(), "`runs-on` needs one or more labels, as runs-on \"riscv64\"");
11373×
    }
11384×
    for l in labels {
11394×
        let ok = !l.is_empty()
11404×
            && l.len() <= 64
114119×
            && l.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.'));
11424×
        if ok {
11433×
            into.push(l);
11443×
        } else {
11451×
            ctx.err(c.span(), format!("runs-on {l:?}: a label is lower-case letters, digits, -, _ and ., at most 64"));
11461×
        }
1147
    }
11484×
    into.sort();
11494×
    into.dedup();
11504×
}
1151
1152
/// An `env` block of `NAME "value"` lines, into `env`.
11537×
fn parse_env(ctx: &mut Ctx, c: &KdlNode, env: &mut BTreeMap<String, String>) {
11547×
    if !c.entries().is_empty() {
11550×
        ctx.err(c.span(), "`env` takes a block of NAME \"value\" lines");
11567×
    }
11578×
    for e in block(ctx, c) {
11588×
        let k = e.name().value().to_string();
11598×
        let valid =
116049×
            !k.is_empty() && !k.starts_with(|c: char| c.is_ascii_digit()) && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
11618×
        if !valid {
11621×
            ctx.err(e.span(), format!("environment name {k:?} must be letters, digits and _"));
11631×
            continue;
11647×
        }
11657×
        ctx.no_children(e);
11667×
        if let Some(v) = ctx.one_string(e) {
11677×
            if env.insert(k.clone(), v).is_some() {
11680×
                ctx.err(e.span(), format!("environment {k} is set twice"));
11697×
            }
11700×
        }
1171
    }
11727×
}
1173
1174
/// `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
117561×
fn parse_mount(ctx: &mut Ctx, node: &KdlNode) -> Option<Mount> {
117661×
    let (mut source, mut target, mut size) = (None, None, None);
117761×
    let (mut cache, mut keys) = (None, Vec::new());
117861×
    let mut seen = BTreeSet::new();
1179190×
    for c in block(ctx, node) {
1180190×
        let key = c.name().value();
1181190×
        if !seen.insert(key.to_string()) {
11821×
            ctx.err(c.span(), format!("`{key}` is given twice"));
11831×
            continue;
1184189×
        }
1185189×
        ctx.no_children(c);
1186189×
        match key {
1187189×
            "source" => {
118861×
                source = match ctx.one_string(c).as_deref() {
118961×
                    Some("ephemeral://tmpfs") => Some(MountSource::Tmpfs),
119040×
                    Some("ephemeral://disk") => Some(MountSource::Disk),
11912×
                    Some(other) => {
11922×
                        ctx.err(
11932×
                            c.span(),
11942×
                            format!("mount source {other:?} is not in M0 yet; M0 mounts \"ephemeral://tmpfs\" or \"ephemeral://disk\""),
1195
                        );
11962×
                        None
1197
                    }
11980×
                    None => None,
1199
                }
1200
            }
1201128×
            "target" => {
120260×
                target = match ctx.one_string(c) {
120360×
                    Some(t) if mount_target_ok(&t) => Some(t),
12044×
                    Some(t) => {
12054×
                        ctx.err(
12064×
                            c.span(),
12074×
                            format!("mount target {t:?} must be an absolute path under /src or /tmp, without . or .. or a trailing /"),
1208
                        );
12094×
                        None
1210
                    }
12110×
                    None => None,
1212
                }
1213
            }
121468×
            "size" => {
121559×
                size = match ctx.one_string(c) {
121659×
                    Some(m) => match parse_memory(&m) {
121758×
                        Some(mib) if (MIN_MOUNT_MIB..=MAX_MOUNT_MIB).contains(&mib) => Some(mib as u32),
1218
                        Some(_) => {
12192×
                            ctx.err(c.span(), format!("mount size {m:?} must be from 16MiB to 64GiB"));
12202×
                            None
1221
                        }
1222
                        None => {
12231×
                            ctx.err(c.span(), format!("mount size {m:?}: expected a whole number of KiB, MiB or GiB, as \"8GiB\""));
12241×
                            None
1225
                        }
1226
                    },
12270×
                    None => None,
1228
                }
1229
            }
12309×
            "cache" => {
12315×
                cache = match ctx.one_string(c) {
123217×
                    Some(n) if !n.is_empty() && n.len() <= 64 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) => {
12334×
                        Some(n)
1234
                    }
12351×
                    Some(n) => {
12361×
                        ctx.err(c.span(), format!("cache name {n:?}: letters, digits, '.', '_' and '-', at most 64"));
12371×
                        None
1238
                    }
12390×
                    None => None,
1240
                }
1241
            }
12424×
            "key" => {
12433×
                keys = ctx.strings(c);
12443×
                if keys.is_empty() {
12450×
                    ctx.err(c.span(), "`key` names one or more files, as \"Cargo.lock\"");
12463×
                }
12474×
                for k in &keys {
12484×
                    if k.starts_with('/') || k.split('/').any(|p| p.is_empty() || p == "." || p == "..") {
12491×
                        ctx.err(c.span(), format!("key file {k:?} must be a path in the repository, without . or .."));
12503×
                    }
1251
                }
1252
            }
12531×
            "readonly" | "include" => ctx.err(c.span(), format!("mount `{key}` is not in M0 yet")),
12540×
            other => ctx.err(c.span(), format!("unknown mount setting `{other}`")),
1255
        }
1256
    }
1257183×
    for (name, missing) in [("source", source.is_none()), ("target", target.is_none()), ("size", size.is_none())] {
1258183×
        if missing && !seen.contains(name) {
12593×
            ctx.err(node.span(), format!("a mount needs a {name}"));
1260180×
        }
1261
    }
126261×
    if source == Some(MountSource::Tmpfs) && cache.is_some() {
12631×
        ctx.err(node.span(), "only an ephemeral://disk mount can be a cache");
126460×
    }
126561×
    if !keys.is_empty() && cache.is_none() {
12661×
        ctx.err(node.span(), "`key` goes with `cache`");
126760×
    }
126861×
    Some(Mount { source: source?, target: target?, size_mib: size?, cache, key: keys })
126961×
}
1270
1271
/// `output { artifact "name" { path "/src/target/release/tool" } }`.
1272
/// `secret "deploy-token" { mount-path "/run/secrets/token" }` (spec §9.1).
1273
/// The hub is the vault: the secret of that name set for the repository,
1274
/// its organisation or the site. `vault-ref` is not in this slice.
127512×
fn parse_secret(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
127612×
    let Some(name) = ctx.one_string(node) else {
12770×
        ctx.err(node.span(), "`secret` takes a name and a block with mount-path");
12780×
        return;
1279
    };
128012×
    if !ctx.name_ok(node.span(), "secret", &name) {
12810×
        return;
128212×
    }
128312×
    let mut path = None;
128412×
    for c in block(ctx, node) {
128512×
        ctx.no_children(c);
128612×
        match c.name().value() {
128712×
            "mount-path" => match ctx.one_string(c) {
128811×
                Some(p) if secret_path_ok(&p) => path = Some(p),
12892×
                Some(p) => {
12902×
                    ctx.err(c.span(), format!("secret mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
1291
                }
12920×
                None => {}
1293
            },
12941×
            "vault-ref" => ctx.err(c.span(), "`vault-ref` is not in M0; the hub supplies the secret of that name"),
12950×
            other => ctx.err(c.span(), format!("unknown secret setting `{other}`")),
1296
        }
1297
    }
129812×
    let Some(mount_path) = path else {
12993×
        ctx.err(node.span(), format!("secret {name:?} needs a mount-path"));
13003×
        return;
1301
    };
13029×
    if job.secrets.iter().any(|s| s.name == name) {
13031×
        ctx.err(node.span(), format!("secret {name:?} is given twice"));
13048×
    } else if job.secrets.iter().any(|s| s.mount_path == mount_path) || job.identities.iter().any(|i| i.mount_path == mount_path) {
13052×
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
13066×
    } else if job.secrets.len() == MAX_SECRETS {
13070×
        ctx.err(node.span(), format!("a job has at most {MAX_SECRETS} secrets"));
13086×
    } else {
13096×
        job.secrets.push(Secret { name, mount_path });
13106×
    }
131112×
}
1312
1313
/// `identity "sts.amazonaws.com" { mount-path "/run/secrets/aws-token" }`:
1314
/// the hub mints an OIDC token for that audience and the runner mounts it
1315
/// as a file (oxygenhub#103).
13168×
fn parse_identity(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
13178×
    let Some(audience) = ctx.one_string(node) else {
13180×
        ctx.err(node.span(), "`identity` takes an audience and a block with mount-path");
13190×
        return;
1320
    };
132145×
    let aud_ok = !audience.is_empty() && audience.len() <= 255 && !audience.chars().any(|c| c.is_whitespace() || c.is_control());
13228×
    if !aud_ok {
13231×
        ctx.err(node.span(), format!("identity audience {audience:?} must be 1 to 255 characters without spaces"));
13241×
        return;
13257×
    }
13267×
    let mut path = None;
13277×
    for c in block(ctx, node) {
13286×
        ctx.no_children(c);
13296×
        match c.name().value() {
13306×
            "mount-path" => match ctx.one_string(c) {
13316×
                Some(p) if secret_path_ok(&p) => path = Some(p),
13321×
                Some(p) => {
13331×
                    ctx.err(c.span(), format!("identity mount-path {p:?} must be an absolute path under /run/secrets/, without . or .."))
1334
                }
13350×
                None => {}
1336
            },
13370×
            other => ctx.err(c.span(), format!("unknown identity setting `{other}`")),
1338
        }
1339
    }
13407×
    let Some(mount_path) = path else {
13412×
        ctx.err(node.span(), format!("identity {audience:?} needs a mount-path"));
13422×
        return;
1343
    };
13445×
    if job.identities.iter().any(|i| i.audience == audience) {
13451×
        ctx.err(node.span(), format!("identity {audience:?} is given twice"));
13464×
    } else if job.identities.iter().any(|i| i.mount_path == mount_path) || job.secrets.iter().any(|s| s.mount_path == mount_path) {
13470×
        ctx.err(node.span(), format!("two files mount at {mount_path:?}"));
13484×
    } else if job.identities.len() == MAX_IDENTITIES {
13490×
        ctx.err(node.span(), format!("a job has at most {MAX_IDENTITIES} identities"));
13504×
    } else {
13514×
        job.identities.push(Identity { audience, mount_path });
13524×
    }
13538×
}
1354
135523×
fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
135623×
    if !node.entries().is_empty() {
13570×
        ctx.err(node.span(), "`output` takes a block of artifacts and reports");
135823×
    }
135937×
    for a in block(ctx, node) {
136037×
        let kind = match a.name().value() {
136137×
            "artifact" => OutputKind::Artifact,
136213×
            "report" => OutputKind::Report,
13630×
            other => {
13640×
                ctx.err(
13650×
                    a.span(),
13660×
                    format!("unknown output `{other}`; outputs are `artifact \"name\" {{ path \"/src/file\" }}` and `report \"name\" {{ path \"/src/dir\" }}`"),
1367
                );
13680×
                continue;
1369
            }
1370
        };
137137×
        let word = kind.word();
137237×
        let Some(name) = ctx.one_string(a) else { continue };
137337×
        if !ctx.name_ok(a.span(), word, &name) {
13740×
            continue;
137537×
        }
137637×
        let mut path = None;
137737×
        let mut always = false;
137842×
        for c in block(ctx, a) {
137942×
            ctx.no_children(c);
138042×
            match c.name().value() {
138142×
                "path" => match ctx.one_string(c) {
138235×
                    Some(p) if output_path_ok(&p) => path = Some(p),
13832×
                    Some(p) => ctx
13842×
                        .err(c.span(), format!("{word} path {p:?} must be an absolute path under /src, without . or .., quotes or spaces")),
13850×
                    None => {}
1386
                },
13877×
                "format" if kind == OutputKind::Artifact => {
13882×
                    if ctx.one_string(c).as_deref() != Some("blob") {
13891×
                        ctx.err(c.span(), "artifact format is \"blob\"");
13901×
                    }
1391
                }
13925×
                "when" => match ctx.one_string(c).as_deref() {
13935×
                    Some("passed") => always = false,
13944×
                    Some("always") => always = true,
13951×
                    Some(_) => ctx.err(c.span(), format!("{word} `when` is \"passed\" (the default) or \"always\"")),
13960×
                    None => {}
1397
                },
13980×
                other => ctx.err(c.span(), format!("unknown {word} setting `{other}`")),
1399
            }
1400
        }
140137×
        let Some(path) = path else {
14024×
            ctx.err(a.span(), format!("{word} {name:?} needs a path"));
14034×
            continue;
1404
        };
140533×
        if job.outputs.iter().any(|o| o.name == name) {
14062×
            ctx.err(a.span(), format!("output {name:?} is given twice (artifacts and reports share names)"));
140731×
        } else if job.outputs.len() == MAX_OUTPUTS {
14080×
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports"));
140931×
        } else {
141031×
            job.outputs.push(Output { name, path, always, kind });
141131×
        }
1412
    }
141323×
}
1414
1415
/// `approval { by "maintain"; others-only #true; expires "24h" }`, each
1416
/// optional: `maintain`, anyone with the role, 24 hours.
1417
/// The approval, and whether it said `sealed` itself (else the job's
1418
/// environment decides).
141911×
fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<(Approval, bool)> {
142011×
    if !node.entries().is_empty() {
14210×
        ctx.err(node.span(), "`approval` takes a block: approval { by \"maintain\"; others-only #true; expires \"24h\" }");
142211×
    }
142311×
    let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600, sealed: false };
142411×
    let mut sealed_said = false;
142511×
    let Some(children) = node.children() else { return Some((a, false)) };
142611×
    for c in children.nodes() {
142711×
        ctx.no_children(c);
142811×
        match c.name().value() {
142911×
            "by" => match ctx.one_string(c).as_deref() {
14302×
                Some(r @ ("write" | "maintain" | "admin")) => a.by = r.to_string(),
14311×
                Some(r) => ctx.err(c.span(), format!("approval by {r:?}: expected \"write\", \"maintain\" or \"admin\"")),
14320×
                None => {}
1433
            },
14349×
            "others-only" => match c.entries() {
14352×
                [e] if e.name().is_none() => match e.value() {
14361×
                    KdlValue::Bool(b) => a.others_only = *b,
14371×
                    _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1438
                },
14390×
                _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1440
            },
14417×
            "sealed" => match c.entries() {
14424×
                [e] if e.name().is_none() => match e.value() {
14433×
                    KdlValue::Bool(b) => {
14443×
                        a.sealed = *b;
14453×
                        sealed_said = true;
14463×
                    }
14471×
                    _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1448
                },
14490×
                _ => ctx.err(c.span(), "`sealed` takes #true or #false"),
1450
            },
14513×
            "expires" => {
14522×
                let Some(t) = ctx.one_string(c) else { continue };
14532×
                match parse_duration(&t) {
14542×
                    Some(secs) if (60..=MAX_APPROVAL_S).contains(&secs) => a.expires_s = secs,
14551×
                    Some(_) => ctx.err(c.span(), format!("expires {t:?} must be from 1m to 720h")),
14560×
                    None => ctx.err(c.span(), format!("expires {t:?}: expected hours, minutes and seconds, as \"24h\"")),
1457
                }
1458
            }
14591×
            other => {
14601×
                ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only`, `expires` and `sealed`"))
1461
            }
1462
        }
1463
    }
14649×
    Some((a, sealed_said))
146511×
}
1466
1467
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
146810×
fn parse_input(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
146910×
    if !node.entries().is_empty() {
14700×
        ctx.err(node.span(), "`input` takes a block: input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\" }");
147110×
    }
147210×
    let (mut artifact, mut from, mut path) = (None, None, None);
147319×
    for c in block(ctx, node) {
147419×
        ctx.no_children(c);
147519×
        match c.name().value() {
147619×
            "artifact" => match ctx.strings(c).as_slice() {
14779×
                [name, word, producer] if word == "from" => {
14789×
                    if ctx.name_ok(c.span(), "artifact", name) && ctx.name_ok(c.span(), "job", producer) {
14799×
                        artifact = Some(name.clone());
14809×
                        from = Some(producer.clone());
14819×
                    }
1482
                }
14831×
                _ => ctx.err(c.span(), "`artifact` names the artifact and its job: artifact \"tfplan\" from \"plan\""),
1484
            },
14859×
            "path" => match ctx.one_string(c) {
14869×
                Some(p) if output_path_ok(&p) => path = Some(p),
14871×
                Some(p) => {
14881×
                    ctx.err(c.span(), format!("input path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"))
1489
                }
14900×
                None => {}
1491
            },
14920×
            other => ctx.err(c.span(), format!("unknown input setting `{other}`; an input has `artifact` and `path`")),
1493
        }
1494
    }
149510×
    match (artifact, from, path) {
14967×
        (Some(artifact), Some(from), Some(path)) => {
14977×
            if job.inputs.iter().any(|i| i.path == path) {
14981×
                ctx.err(node.span(), format!("two inputs are put at {path:?}"));
14996×
            } else if job.inputs.len() == MAX_INPUTS {
15000×
                ctx.err(node.span(), format!("a job takes at most {MAX_INPUTS} inputs"));
15016×
            } else {
15026×
                job.inputs.push(Input { artifact, from, path });
15036×
            }
1504
        }
15053×
        (a, _, p) => {
15063×
            if a.is_some() && p.is_none() {
15072×
                ctx.err(node.span(), "an input needs a `path`");
15082×
            } else if a.is_none() && p.is_some() {
15091×
                ctx.err(node.span(), "an input needs `artifact \"NAME\" from \"JOB\"`");
15101×
            }
1511
        }
1512
    }
151310×
}
1514
1515174×
fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> {
1516174×
    let name = ctx.one_string(node)?;
1517174×
    let named = ctx.name_ok(node.span(), "job", &name);
1518174×
    let mut approval: Option<(Approval, bool)> = None;
1519174×
    let mut job = Job {
1520174×
        name: name.clone(),
1521174×
        sandbox: String::new(),
1522174×
        scope: vec![],
1523174×
        depends_on: vec![],
1524174×
        env: BTreeMap::new(),
1525174×
        exec: vec![],
1526174×
        mounts: vec![],
1527174×
        secrets: vec![],
1528174×
        identities: vec![],
1529174×
        outputs: vec![],
1530174×
        inputs: vec![],
1531174×
        approval: None,
1532174×
        environment: None,
1533174×
        runs_on: vec![],
1534174×
        timeout_s: None,
1535174×
        on: None,
1536174×
        priority: None,
1537174×
        checkpoints: Vec::new(),
1538174×
    };
1539174×
    let mut seen = BTreeSet::new();
1540174×
    let mut outputs_at = None;
1541522×
    for c in block(ctx, node) {
1542522×
        let key = c.name().value();
1543522×
        if key != "scope"
1544521×
            && key != "depends-on"
1545504×
            && key != "mount"
1546443×
            && key != "secret"
1547431×
            && key != "identity"
1548423×
            && key != "runs-on"
1549420×
            && key != "checkpoint"
1550419×
            && !seen.insert(key.to_string())
15511×
        {
15521×
            ctx.err(c.span(), format!("`{key}` is given twice"));
1553521×
        }
15540×
        match key {
1555522×
            "sandbox" => {
1556174×
                ctx.no_children(c);
1557174×
                job.sandbox = ctx.one_string(c).unwrap_or_default();
1558174×
            }
1559348×
            "checkpoint" => {
15601×
                ctx.no_children(c);
15612×
                for p in ctx.strings(c) {
15622×
                    if output_path_ok(&p) && p.trim_end_matches('/') != "/src" {
15632×
                        job.checkpoints.push(p);
15642×
                    } else {
15650×
                        ctx.err(c.span(), format!("checkpoint {p:?} must be a directory under /src, without . or .., quotes or spaces"));
15660×
                    }
1567
                }
1568
            }
1569347×
            "scope" => {
15701×
                ctx.no_children(c);
15712×
                for g in ctx.strings(c) {
15722×
                    match globset::Glob::new(&g) {
15732×
                        Ok(_) => job.scope.push(g),
15740×
                        Err(e) => ctx.err(c.span(), format!("scope {g:?}: {e}")),
1575
                    }
1576
                }
1577
            }
1578346×
            "depends-on" => {
157917×
                ctx.no_children(c);
158017×
                job.depends_on.extend(ctx.strings(c));
158117×
            }
1582329×
            "runs-on" => {
15833×
                ctx.no_children(c);
15843×
                parse_labels(ctx, c, &mut job.runs_on);
15853×
            }
1586326×
            "timeout" => {
15873×
                ctx.no_children(c);
15883×
                job.timeout_s = parse_timeout(ctx, c);
15893×
            }
1590323×
            "priority" => job.priority = parse_priority(ctx, c),
1591318×
            "on" => job.on = Some(parse_on(ctx, c)),
1592308×
            "env" => parse_env(ctx, c, &mut job.env),
1593303×
            "exec" => {
1594174×
                ctx.no_children(c);
1595174×
                job.exec = ctx.strings(c);
1596174×
                if job.exec.is_empty() {
15971×
                    ctx.err(c.span(), "`exec` needs a program and its arguments");
1598192×
                } else if job.exec.iter().any(|a| a.contains('\0')) {
15990×
                    ctx.err(c.span(), "`exec` arguments cannot contain NUL");
1600173×
                }
1601
            }
1602129×
            "mount" => {
160361×
                if !c.entries().is_empty() {
16040×
                    ctx.err(c.span(), "`mount` takes a block of source, target and size");
160561×
                }
160661×
                if let Some(m) = parse_mount(ctx, c) {
160751×
                    if job.mounts.len() == MAX_MOUNTS {
16081×
                        ctx.err(c.span(), format!("a job has at most {MAX_MOUNTS} mounts"));
160950×
                    } else if let Some(other) = job.mounts.iter().find(|o| {
161024×
                        o.target == m.target
161123×
                            || m.target.starts_with(&format!("{}/", o.target))
161222×
                            || o.target.starts_with(&format!("{}/", m.target))
161324×
                    }) {
16142×
                        ctx.err(c.span(), format!("mount {:?} overlaps mount {:?}", m.target, other.target));
161548×
                    } else {
161648×
                        job.mounts.push(m);
161748×
                    }
161810×
                }
1619
            }
162068×
            "output" => {
162123×
                outputs_at = Some(c.span());
162223×
                parse_output(ctx, c, &mut job);
162323×
            }
162445×
            "input" => parse_input(ctx, c, &mut job),
162535×
            "approval" => approval = parse_approval(ctx, c),
162624×
            "environment" => {
16274×
                ctx.no_children(c);
16284×
                if let Some(e) = ctx.one_string(c) {
162913×
                    let ok = !e.is_empty() && e.len() <= 32 && e.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
16304×
                    if ok {
16313×
                        job.environment = Some(e);
16323×
                    } else {
16331×
                        ctx.err(c.span(), format!("environment {e:?}: 1 to 32 of a-z 0-9 -, as \"prod\""));
16341×
                    }
16350×
                }
1636
            }
163720×
            "secret" => parse_secret(ctx, c, &mut job),
16388×
            "identity" => parse_identity(ctx, c, &mut job),
16390×
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
16400×
            other => ctx.err(c.span(), format!("unknown job setting `{other}`")),
1641
        }
1642
    }
1643
    // A job for an environment takes a sealed approval unless it says not.
1644174×
    job.approval = approval.map(|(mut a, said)| {
164511×
        if !said {
16468×
            a.sealed = job.environment.is_some();
16478×
        }
164811×
        a
164911×
    });
1650174×
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1651174×
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1652174×
    job.inputs.sort_by(|a, b| a.path.cmp(&b.path));
1653174×
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
1654174×
    job.identities.sort_by(|a, b| a.audience.cmp(&b.audience));
1655
    // A tmpfs is gone when the job ends; nothing on it can be kept.
1656174×
    for o in &job.outputs {
165731×
        if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) {
16581×
            ctx.err(
16591×
                outputs_at.unwrap_or(node.span()),
16601×
                format!(
16611×
                    "artifact {:?} is on the tmpfs mount {:?}, which is gone when the job ends; use ephemeral://disk",
16621×
                    o.name, m.target
16631×
                ),
16641×
            );
166530×
        }
1666
    }
1667174×
    if job.sandbox.is_empty() && !seen.contains("sandbox") {
16680×
        ctx.err(node.span(), format!("job {name:?} needs a sandbox"));
1669174×
    }
1670174×
    if job.exec.is_empty() && !seen.contains("exec") {
16710×
        ctx.err(node.span(), format!("job {name:?} needs an exec"));
1672174×
    }
1673174×
    job.depends_on.sort();
1674174×
    job.depends_on.dedup();
1675174×
    job.checkpoints.sort();
1676174×
    job.checkpoints.dedup();
1677
    // Kept even when incomplete, so its references are checked too; the
1678
    // problems above already make the pipeline invalid.
1679174×
    named.then_some(job)
1680174×
}