Skip to main content

murmur_artifact/
program_needs.rs

1//! The `programs:` block of a `runtime: tool` artifact's own `murmur.yaml`: what each host program
2//! the tool runs needs from the host, named portably.
3//!
4//! ```yaml
5//! programs:
6//!   render:
7//!     needs:
8//!       - library: libexample.so.1
9//!         packages:
10//!           debian: libexample1
11//!           fedora: example-libs
12//!       - file: libexample.chk
13//!         beside: libexample.so.1
14//!       - program_folder: .
15//!       - system: fonts
16//!       - etc: fonts/fonts.conf
17//! ```
18//!
19//! A need names *what* the program needs, never *where* a host keeps it: a soname, a file beside
20//! a declared library, a path relative to the program's own folder, a named system location, or a
21//! name under `/etc`. [`parse_program_needs`] refuses every other form, naming the program, the
22//! need's index, the value and why. `mur build` (`E-BLD-008`) and staging (`E-RUN-062`) call the
23//! same parser; resolving a need against a host is the runtime's.
24
25use serde_norway::{Mapping, Value};
26use std::collections::BTreeMap;
27
28use crate::manifest::{optional_string, parse_manifest_value, yaml_type_name};
29use crate::ManifestError;
30
31/// The top-level key of the block.
32pub const PROGRAMS_KEY: &str = "programs";
33
34/// The `/etc` locations that hold credentials. No `etc:` need may name one, an entry beneath one,
35/// or a folder that holds one; the runtime moves a need that resolves to any of them to `missing`.
36/// Relative to `/etc`.
37pub const ETC_CREDENTIAL_LOCATIONS: &[&str] = &[
38    "shadow",
39    "shadow-",
40    "gshadow",
41    "gshadow-",
42    "sudoers",
43    "sudoers.d",
44    "ssh",
45    "ssl/private",
46    "pki/tls/private",
47    "krb5.keytab",
48    "security",
49    "ipsec.secrets",
50    "ipsec.d",
51    "ppp",
52    "wpa_supplicant",
53    "NetworkManager/system-connections",
54];
55
56/// The needs one artifact declares for one program, in declaration order.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ProgramNeeds {
59    /// The program key: a bare name, as `capabilities.shell.allow` spells it.
60    pub program: String,
61    pub needs: Vec<DeclaredNeed>,
62}
63
64/// One entry of a program's `needs:` list.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct DeclaredNeed {
67    pub form: NeedForm,
68    /// `packages:`, optional: an os-release `ID` to the package that provides the need on that
69    /// distribution. Empty when the entry declares none.
70    pub packages: BTreeMap<String, String>,
71}
72
73/// The one form a need takes.
74///
75/// Displayed as the form key and its value, the label every report uses: `library <soname>`,
76/// `file <name> beside <soname>`, `program_folder <path>`, `system <location>`, `etc <path>`.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum NeedForm {
79    /// A soname, resolved as the dynamic loader resolves a `DT_NEEDED` entry.
80    Library(String),
81    /// A file name, in the directory the program's `library:` entry `beside` resolved to.
82    File { file: String, beside: String },
83    /// A path relative to the folder the program resolves to; `.` is that folder.
84    ProgramFolder(String),
85    /// A named system location.
86    System(SystemLocation),
87    /// A path under `/etc`, relative to it.
88    Etc(String),
89}
90
91impl std::fmt::Display for NeedForm {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Library(soname) => write!(f, "library {soname}"),
95            Self::File { file, beside } => write!(f, "file {file} beside {beside}"),
96            Self::ProgramFolder(path) => write!(f, "program_folder {path}"),
97            Self::System(location) => write!(f, "system {location}"),
98            Self::Etc(path) => write!(f, "etc {path}"),
99        }
100    }
101}
102
103/// The system locations a `system:` need may name.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
105pub enum SystemLocation {
106    /// The host's font and fontconfig folders.
107    Fonts,
108    /// The host's TLS trust store.
109    CaBundle,
110}
111
112impl SystemLocation {
113    /// Every location, in the order a refusal lists them.
114    pub const ALL: [Self; 2] = [Self::Fonts, Self::CaBundle];
115
116    /// The location as `system:` spells it.
117    #[must_use]
118    pub fn as_str(self) -> &'static str {
119        match self {
120            Self::Fonts => "fonts",
121            Self::CaBundle => "ca_bundle",
122        }
123    }
124}
125
126impl std::fmt::Display for SystemLocation {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.write_str(self.as_str())
129    }
130}
131
132/// The keys that each make a need entry one form.
133const FORM_KEYS: [&str; 5] = ["library", "file", "program_folder", "system", "etc"];
134
135/// What a refusal says a need entry holds.
136const ONE_FORM: &str = "a need holds exactly one form: library, file (with beside), \
137                        program_folder, system or etc, and optionally packages";
138
139/// Reads the top-level `programs:` block from an artifact's bundled manifest text.
140///
141/// `Ok` with an empty list means the manifest has no `programs` key. `Err` names the dotted field
142/// at fault (`programs.<program>.needs[<index>].<key>`) and the value: the block on a `driver`,
143/// `hook` or `skill` artifact; a program key that is not a bare name; a need holding no form, two
144/// forms or a key other than a form key, `beside` and `packages`; `library` not a soname; `file`
145/// not a bare file name, or `beside` naming no `library:` entry of the same program;
146/// `program_folder` or `etc` an absolute path, a home folder, an environment expansion or a path
147/// climbing with `..`; `etc` naming a credential location ([`ETC_CREDENTIAL_LOCATIONS`]);
148/// `system` not one of [`SystemLocation::ALL`]; `packages` not a map of lowercase os-release IDs
149/// to package names; a program holding neither `needs` nor `download`, or a malformed `download`
150/// entry ([`crate::parse_program_downloads`] reads those). A program with only `download` has no
151/// needs.
152pub fn parse_program_needs(manifest_yaml: &str) -> Result<Vec<ProgramNeeds>, ManifestError> {
153    let value = parse_manifest_value(manifest_yaml)?;
154    let Some(root) = value.as_mapping() else {
155        return Ok(Vec::new());
156    };
157    let Some(block) = root.get(Value::String(PROGRAMS_KEY.to_string())) else {
158        return Ok(Vec::new());
159    };
160    let runtime = optional_string(root, "runtime").unwrap_or_default();
161    if matches!(runtime.as_str(), "driver" | "hook" | "skill") {
162        return Err(invalid(
163            PROGRAMS_KEY.to_string(),
164            format!(
165                "is only valid on an artifact whose runtime is tool; this one is runtime: \
166                 {runtime}"
167            ),
168        ));
169    }
170    let block = mapping(block, PROGRAMS_KEY)?;
171    let mut programs = Vec::with_capacity(block.len());
172    for (key, entry) in block {
173        let Some(program) = key.as_str() else {
174            return Err(ManifestError::InvalidType {
175                field: format!("{PROGRAMS_KEY} key"),
176                expected: "string".to_string(),
177                got: yaml_type_name(key),
178            });
179        };
180        let field = format!("{PROGRAMS_KEY}.{program}");
181        if program.is_empty() || program.contains('/') || program == "." || program == ".." {
182            return Err(invalid(
183                format!("{PROGRAMS_KEY} key '{program}'"),
184                "is not a bare program name; a program is named as capabilities.shell.allow \
185                 names it: no '/', not '.' or '..'"
186                    .to_string(),
187            ));
188        }
189        programs.push(ProgramNeeds {
190            program: program.to_string(),
191            needs: parse_program(program, mapping(entry, &field)?, &field)?,
192        });
193    }
194    Ok(programs)
195}
196
197fn parse_program(
198    program: &str,
199    entry: &Mapping,
200    field: &str,
201) -> Result<Vec<DeclaredNeed>, ManifestError> {
202    if let Some(extra) = entry.keys().find(|key| {
203        !matches!(
204            key.as_str(),
205            Some("needs") | Some(crate::program_download::DOWNLOAD_KEY)
206        )
207    }) {
208        return Err(invalid(
209            format!("{field}.{}", key_text(extra)),
210            "is not a key of a program; a program holds needs, download or both".to_string(),
211        ));
212    }
213    let download = entry.get(Value::String(
214        crate::program_download::DOWNLOAD_KEY.to_string(),
215    ));
216    if let Some(download) = download {
217        crate::program_download::parse_download(
218            download,
219            &format!("{field}.{}", crate::program_download::DOWNLOAD_KEY),
220        )?;
221    }
222    let needs_field = format!("{field}.needs");
223    let Some(needs) = entry.get(Value::String("needs".to_string())) else {
224        if download.is_some() {
225            return Ok(Vec::new());
226        }
227        return Err(invalid(
228            field.to_string(),
229            "holds neither needs nor download; a program holds needs, download or both".to_string(),
230        ));
231    };
232    let needs = needs
233        .as_sequence()
234        .ok_or_else(|| ManifestError::InvalidType {
235            field: needs_field.clone(),
236            expected: "sequence".to_string(),
237            got: yaml_type_name(needs),
238        })?;
239    let mut parsed = Vec::with_capacity(needs.len());
240    for (index, need) in needs.iter().enumerate() {
241        let need_field = format!("{needs_field}[{index}]");
242        parsed.push(parse_need(mapping(need, &need_field)?, &need_field)?);
243    }
244    for (index, need) in parsed.iter().enumerate() {
245        if let NeedForm::File { beside, .. } = &need.form {
246            let declared = parsed
247                .iter()
248                .any(|other| matches!(&other.form, NeedForm::Library(soname) if soname == beside));
249            if !declared {
250                return Err(invalid(
251                    format!("{needs_field}[{index}].beside"),
252                    format!(
253                        "is '{beside}', which names no library: entry of program '{program}'; \
254                         beside names the library: value whose folder holds the file"
255                    ),
256                ));
257            }
258        }
259    }
260    Ok(parsed)
261}
262
263fn parse_need(need: &Mapping, field: &str) -> Result<DeclaredNeed, ManifestError> {
264    if let Some(extra) = need.keys().find(|key| {
265        !key.as_str()
266            .is_some_and(|k| FORM_KEYS.contains(&k) || k == "beside" || k == "packages")
267    }) {
268        return Err(invalid(
269            format!("{field}.{}", key_text(extra)),
270            format!("is not a need key; {ONE_FORM}"),
271        ));
272    }
273    let forms: Vec<&str> = FORM_KEYS
274        .into_iter()
275        .filter(|key| need.contains_key(Value::String((*key).to_string())))
276        .collect();
277    let form_key = match forms.as_slice() {
278        [one] => *one,
279        [] => {
280            return Err(invalid(
281                field.to_string(),
282                format!("holds no form; {ONE_FORM}"),
283            ))
284        }
285        several => {
286            return Err(invalid(
287                field.to_string(),
288                format!("holds {}; {ONE_FORM}", several.join(" and ")),
289            ))
290        }
291    };
292    let text = |key: &str| -> Result<String, ManifestError> {
293        let value = need.get(Value::String(key.to_string())).ok_or_else(|| {
294            ManifestError::MissingField {
295                field: format!("{field}.{key}"),
296            }
297        })?;
298        value
299            .as_str()
300            .map(str::to_string)
301            .ok_or_else(|| ManifestError::InvalidType {
302                field: format!("{field}.{key}"),
303                expected: "string".to_string(),
304                got: yaml_type_name(value),
305            })
306    };
307    if form_key != "file" && need.contains_key(Value::String("beside".to_string())) {
308        return Err(invalid(
309            format!("{field}.beside"),
310            format!("goes only with file; this need's form is {form_key}"),
311        ));
312    }
313    let refuse = |key: &str, value: &str, reason: String| {
314        invalid(format!("{field}.{key}"), format!("is '{value}', {reason}"))
315    };
316    let form = match form_key {
317        "library" => {
318            let soname = text("library")?;
319            if let Some(reason) = host_path_reason(&soname) {
320                return Err(refuse("library", &soname, reason.to_string()));
321            }
322            if soname.contains('/') {
323                return Err(refuse(
324                    "library",
325                    &soname,
326                    "a path; a library is named by its soname, a file name containing .so"
327                        .to_string(),
328                ));
329            }
330            if !soname.contains(".so") {
331                return Err(refuse(
332                    "library",
333                    &soname,
334                    "not a soname; a library is named by a file name containing .so".to_string(),
335                ));
336            }
337            NeedForm::Library(soname)
338        }
339        "file" => {
340            let file = text("file")?;
341            let beside = text("beside")?;
342            for (key, value) in [("file", &file), ("beside", &beside)] {
343                if let Some(reason) = host_path_reason(value) {
344                    return Err(refuse(key, value, reason.to_string()));
345                }
346                if value.is_empty() || value.contains('/') || value == "." || value == ".." {
347                    return Err(refuse(
348                        key,
349                        value,
350                        "not a bare file name; it names a file in the folder its beside library \
351                         resolved to"
352                            .to_string(),
353                    ));
354                }
355            }
356            NeedForm::File { file, beside }
357        }
358        "program_folder" => {
359            let path = text("program_folder")?;
360            if path != "." {
361                relative_path("program_folder", &path, &refuse)?;
362            }
363            NeedForm::ProgramFolder(path)
364        }
365        "system" => {
366            let name = text("system")?;
367            let location = SystemLocation::ALL
368                .into_iter()
369                .find(|location| location.as_str() == name)
370                .ok_or_else(|| {
371                    refuse(
372                        "system",
373                        &name,
374                        format!(
375                            "which is not a system location; the known locations are: {}",
376                            SystemLocation::ALL.map(SystemLocation::as_str).join(", ")
377                        ),
378                    )
379                })?;
380            NeedForm::System(location)
381        }
382        _ => {
383            let path = text("etc")?;
384            relative_path("etc", &path, &refuse)?;
385            if let Some(location) = credential_location_of(&path) {
386                return Err(refuse(
387                    "etc",
388                    &path,
389                    format!(
390                        "a credential location: /etc/{location} is never granted, nor a folder \
391                         holding it"
392                    ),
393                ));
394            }
395            NeedForm::Etc(path)
396        }
397    };
398    Ok(DeclaredNeed {
399        form,
400        packages: parse_packages(need, field)?,
401    })
402}
403
404/// Why `value` names a host location rather than something portable, for every form: `None`
405/// when it is none of a home folder, an environment expansion, `/proc` or an absolute path.
406fn host_path_reason(value: &str) -> Option<&'static str> {
407    if value.starts_with('~') {
408        Some("a home folder; a need names what the program needs, never where a host keeps it")
409    } else if value.contains('$') {
410        Some(
411            "an environment expansion; a need names what the program needs, never where a host \
412             keeps it",
413        )
414    } else if value == "/proc" || value.starts_with("/proc/") {
415        Some("under /proc, and /proc is the runtime's rule, never a need")
416    } else if value.starts_with('/') {
417        Some(
418            "an absolute host path; a need names what the program needs, never where a host \
419             keeps it",
420        )
421    } else {
422        None
423    }
424}
425
426/// Refuses `path` unless it is a relative path of normal components.
427pub(crate) fn relative_path(
428    key: &str,
429    path: &str,
430    refuse: &impl Fn(&str, &str, String) -> ManifestError,
431) -> Result<(), ManifestError> {
432    if let Some(reason) = host_path_reason(path) {
433        return Err(refuse(key, path, reason.to_string()));
434    }
435    if path.split('/').any(|component| component == "..") {
436        return Err(refuse(
437            key,
438            path,
439            "a path climbing with '..'; it must stay inside the folder it is relative to"
440                .to_string(),
441        ));
442    }
443    if path
444        .split('/')
445        .any(|component| component.is_empty() || component == ".")
446    {
447        return Err(refuse(
448            key,
449            path,
450            "not a relative path of plain components (no empty, '.' or '..' component)".to_string(),
451        ));
452    }
453    Ok(())
454}
455
456/// The [`ETC_CREDENTIAL_LOCATIONS`] entry `path` (relative to `/etc`) equals, lies beneath, or
457/// encloses.
458fn credential_location_of(path: &str) -> Option<&'static str> {
459    let components: Vec<&str> = path.split('/').collect();
460    ETC_CREDENTIAL_LOCATIONS.iter().copied().find(|location| {
461        let location: Vec<&str> = location.split('/').collect();
462        components.starts_with(&location) || location.starts_with(&components)
463    })
464}
465
466fn parse_packages(need: &Mapping, field: &str) -> Result<BTreeMap<String, String>, ManifestError> {
467    let Some(packages) = need.get(Value::String("packages".to_string())) else {
468        return Ok(BTreeMap::new());
469    };
470    let field = format!("{field}.packages");
471    let packages = mapping(packages, &field)?;
472    let mut parsed = BTreeMap::new();
473    for (id, package) in packages {
474        let id_text = id.as_str().unwrap_or_default();
475        let valid_id = !id_text.is_empty()
476            && id_text.bytes().all(|byte| {
477                byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
478            });
479        if !valid_id {
480            return Err(invalid(
481                format!("{field}.{}", key_text(id)),
482                "is not an os-release ID; a packages key is a lowercase ID of [a-z0-9._-], as \
483                 /etc/os-release spells ID and ID_LIKE"
484                    .to_string(),
485            ));
486        }
487        let entry_field = format!("{field}.{id_text}");
488        let package = package.as_str().ok_or_else(|| ManifestError::InvalidType {
489            field: entry_field.clone(),
490            expected: "string".to_string(),
491            got: yaml_type_name(package),
492        })?;
493        if package.is_empty() || package.chars().any(char::is_whitespace) {
494            return Err(invalid(
495                entry_field,
496                format!("is '{package}', which is not a package name: non-empty, no whitespace"),
497            ));
498        }
499        parsed.insert(id_text.to_string(), package.to_string());
500    }
501    Ok(parsed)
502}
503
504pub(crate) fn mapping<'a>(value: &'a Value, field: &str) -> Result<&'a Mapping, ManifestError> {
505    value
506        .as_mapping()
507        .ok_or_else(|| ManifestError::InvalidType {
508            field: field.to_string(),
509            expected: "mapping".to_string(),
510            got: yaml_type_name(value),
511        })
512}
513
514pub(crate) fn key_text(key: &Value) -> String {
515    key.as_str()
516        .map_or_else(|| yaml_type_name(key), str::to_string)
517}
518
519pub(crate) fn invalid(field: String, message: String) -> ManifestError {
520    ManifestError::InvalidValue { field, message }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    fn tool(block: &str) -> String {
528        format!("name: needs-tool\nversion: 0.1.0\nruntime: tool\n{block}")
529    }
530
531    fn refusal(block: &str) -> String {
532        parse_program_needs(&tool(block))
533            .expect_err("the block is refused")
534            .to_string()
535    }
536
537    /// The example block of the reference page.
538    const EXAMPLE: &str = "programs:
539  chromium:
540    needs:
541      - library: libsoftokn3.so
542        packages:
543          debian: libnss3
544          fedora: nss-softokn
545      - file: libsoftokn3.chk
546        beside: libsoftokn3.so
547      - program_folder: .
548      - program_folder: lib/swiftshader
549      - system: fonts
550      - system: ca_bundle
551      - etc: fonts/fonts.conf
552";
553
554    #[test]
555    fn program_needs_absent_is_empty() {
556        assert_eq!(parse_program_needs(&tool("")).unwrap(), Vec::new());
557        assert_eq!(parse_program_needs("").unwrap(), Vec::new());
558    }
559
560    #[test]
561    fn program_needs_every_portable_form_parses() {
562        let parsed = parse_program_needs(&tool(EXAMPLE)).unwrap();
563        let none = BTreeMap::new;
564        let need = |form| DeclaredNeed {
565            form,
566            packages: none(),
567        };
568        assert_eq!(
569            parsed,
570            vec![ProgramNeeds {
571                program: "chromium".to_string(),
572                needs: vec![
573                    DeclaredNeed {
574                        form: NeedForm::Library("libsoftokn3.so".to_string()),
575                        packages: BTreeMap::from([
576                            ("debian".to_string(), "libnss3".to_string()),
577                            ("fedora".to_string(), "nss-softokn".to_string()),
578                        ]),
579                    },
580                    need(NeedForm::File {
581                        file: "libsoftokn3.chk".to_string(),
582                        beside: "libsoftokn3.so".to_string(),
583                    }),
584                    need(NeedForm::ProgramFolder(".".to_string())),
585                    need(NeedForm::ProgramFolder("lib/swiftshader".to_string())),
586                    need(NeedForm::System(SystemLocation::Fonts)),
587                    need(NeedForm::System(SystemLocation::CaBundle)),
588                    need(NeedForm::Etc("fonts/fonts.conf".to_string())),
589                ],
590            }]
591        );
592        let labels: Vec<String> = parsed[0].needs.iter().map(|n| n.form.to_string()).collect();
593        assert_eq!(
594            labels,
595            [
596                "library libsoftokn3.so",
597                "file libsoftokn3.chk beside libsoftokn3.so",
598                "program_folder .",
599                "program_folder lib/swiftshader",
600                "system fonts",
601                "system ca_bundle",
602                "etc fonts/fonts.conf",
603            ]
604        );
605    }
606
607    #[test]
608    fn program_needs_keep_program_order_and_allow_an_empty_list() {
609        let parsed = parse_program_needs(&tool(
610            "programs:\n  ls:\n    needs: []\n  cat:\n    needs:\n      - library: libm.so.6\n",
611        ))
612        .unwrap();
613        assert_eq!(parsed.len(), 2);
614        assert_eq!(
615            (parsed[0].program.as_str(), parsed[0].needs.len()),
616            ("ls", 0)
617        );
618        assert_eq!(parsed[1].program, "cat");
619    }
620
621    #[test]
622    fn program_needs_a_host_path_is_refused_naming_program_index_value_and_reason() {
623        for (need, expected) in [
624            (
625                "program_folder: /home/someone/.fonts",
626                "field 'programs.cat.needs[0].program_folder' is '/home/someone/.fonts', an \
627                 absolute host path",
628            ),
629            (
630                "library: /proc/self/maps",
631                "field 'programs.cat.needs[0].library' is '/proc/self/maps', under /proc, and \
632                 /proc is the runtime's rule",
633            ),
634            (
635                "etc: shadow",
636                "field 'programs.cat.needs[0].etc' is 'shadow', a credential location",
637            ),
638            (
639                "etc: ssl",
640                "field 'programs.cat.needs[0].etc' is 'ssl', a credential location: \
641                 /etc/ssl/private",
642            ),
643            (
644                "etc: ssh/sshd_config",
645                "field 'programs.cat.needs[0].etc' is 'ssh/sshd_config', a credential location",
646            ),
647            (
648                "etc: ../proc/cpuinfo",
649                "field 'programs.cat.needs[0].etc' is '../proc/cpuinfo', a path climbing with '..'",
650            ),
651            (
652                "library: ~/lib/libx.so",
653                "field 'programs.cat.needs[0].library' is '~/lib/libx.so', a home folder",
654            ),
655            (
656                "program_folder: $HOME/x",
657                "field 'programs.cat.needs[0].program_folder' is '$HOME/x', an environment \
658                 expansion",
659            ),
660            (
661                "path: /usr/share/fonts",
662                "field 'programs.cat.needs[0].path' is not a need key",
663            ),
664            (
665                "system: everything",
666                "field 'programs.cat.needs[0].system' is 'everything', which is not a system \
667                 location; the known locations are: fonts, ca_bundle",
668            ),
669            (
670                "library: lib/libx.so",
671                "field 'programs.cat.needs[0].library' is 'lib/libx.so', a path",
672            ),
673            (
674                "library: libx",
675                "field 'programs.cat.needs[0].library' is 'libx', not a soname",
676            ),
677            ("program_folder: share/../..", "a path climbing with '..'"),
678            (
679                "program_folder: ''",
680                "not a relative path of plain components",
681            ),
682            ("etc: .", "not a relative path of plain components"),
683            ("etc: /etc/fonts", "an absolute host path"),
684            (
685                "file: /usr/lib/x.chk\n        beside: libm.so.6",
686                "an absolute host path",
687            ),
688            (
689                "file: x.chk",
690                "missing required field 'programs.cat.needs[0].beside'",
691            ),
692        ] {
693            let message = refusal(&format!("programs:\n  cat:\n    needs:\n      - {need}\n"));
694            assert!(message.contains(expected), "{need}: {message}");
695        }
696    }
697
698    #[test]
699    fn program_needs_a_need_holds_exactly_one_form() {
700        let both = refusal(
701            "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n        etc: fonts\n",
702        );
703        assert!(
704            both.contains("field 'programs.cat.needs[0]' holds library and etc; a need holds exactly one form"),
705            "{both}"
706        );
707        let none = refusal("programs:\n  cat:\n    needs:\n      - packages: {debian: x}\n");
708        assert!(
709            none.contains("holds no form; a need holds exactly one form"),
710            "{none}"
711        );
712        let beside = refusal(
713            "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n      - file: x.chk\n        beside: libnone.so\n",
714        );
715        assert!(
716            beside.contains(
717                "field 'programs.cat.needs[1].beside' is 'libnone.so', which names no library: \
718                 entry of program 'cat'"
719            ),
720            "{beside}"
721        );
722        let stray = refusal(
723            "programs:\n  cat:\n    needs:\n      - etc: fonts\n        beside: libm.so.6\n",
724        );
725        assert!(stray.contains("goes only with file"), "{stray}");
726    }
727
728    #[test]
729    fn program_needs_program_keys_and_packages_are_checked() {
730        for (block, expected) in [
731            ("programs:\n  /usr/bin/cat:\n    needs: []\n", "is not a bare program name"),
732            ("programs:\n  '..':\n    needs: []\n", "is not a bare program name"),
733            ("programs:\n  cat:\n    needs: []\n    other: 1\n", "programs.cat.other' is not a key of a program"),
734            ("programs:\n  cat: {}\n", "field 'programs.cat' holds neither needs nor download"),
735            ("programs: [cat]\n", "field 'programs' has invalid type (expected mapping"),
736            (
737                "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n        packages: {Debian: x}\n",
738                "programs.cat.needs[0].packages.Debian' is not an os-release ID",
739            ),
740            (
741                "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n        packages: {debian: 'lib m'}\n",
742                "is 'lib m', which is not a package name",
743            ),
744            (
745                "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n        packages: {debian: ''}\n",
746                "which is not a package name",
747            ),
748        ] {
749            let message = refusal(block);
750            assert!(message.contains(expected), "{block}: {message}");
751        }
752        let accepted = parse_program_needs(&tool(
753            "programs:\n  cat:\n    needs:\n      - library: libm.so.6\n        packages: {opensuse-leap: libm6, rhel_8.1: glibc}\n",
754        ))
755        .unwrap();
756        assert_eq!(accepted[0].needs[0].packages.len(), 2);
757    }
758
759    #[test]
760    fn program_needs_the_block_is_refused_off_a_tool() {
761        for runtime in ["driver", "hook", "skill"] {
762            let message = parse_program_needs(&format!(
763                "name: x\nversion: 0.1.0\nruntime: {runtime}\nprograms:\n  cat:\n    needs: []\n"
764            ))
765            .unwrap_err()
766            .to_string();
767            assert!(
768                message.contains(&format!(
769                    "field 'programs' is only valid on an artifact whose runtime is tool; this \
770                     one is runtime: {runtime}"
771                )),
772                "{message}"
773            );
774        }
775    }
776
777    #[test]
778    fn program_needs_every_credential_location_is_refused_with_its_neighbours() {
779        for location in ETC_CREDENTIAL_LOCATIONS {
780            for path in [location.to_string(), format!("{location}/inner")] {
781                let message = refusal(&format!(
782                    "programs:\n  cat:\n    needs:\n      - etc: '{path}'\n"
783                ));
784                assert!(
785                    message.contains("a credential location"),
786                    "{path}: {message}"
787                );
788            }
789        }
790        assert!(parse_program_needs(&tool(
791            "programs:\n  cat:\n    needs:\n      - etc: ssl/certs\n      - etc: shadowsocks\n      - etc: pki/tls/certs\n"
792        ))
793        .is_ok());
794    }
795}