Skip to main content

jan_cli/
spec_load.rs

1//! YAML spec loading with `include` links and host-OS filtering.
2
3use std::collections::{BTreeMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use anyhow::{bail, Context, Result};
7use serde::Deserialize;
8use std::borrow::Cow;
9
10use crate::inputs::InputDef;
11use crate::remote::{self, FetchOpts};
12use crate::{
13    deserialize_string_or_seq, CommandNode, EnvSpec, ExecSpec, IncludeLink, IncludeLinkKind,
14    IncludeRef, LocalInclude, Metadata, RemoteInclude, RootSpec,
15};
16
17/// Which platform string to use when filtering `os:` lists on commands.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct HostPlatform {
20    /// Normalized id: `linux`, `macos`, `windows`, or another `std::env::consts::OS` value.
21    pub id: Cow<'static, str>,
22}
23
24impl HostPlatform {
25    /// Resolve the process host, honoring `JAN_OS` when set (for tests and overrides).
26    pub fn detect() -> Self {
27        if let Ok(v) = std::env::var("JAN_OS") {
28            let s = v.trim().to_ascii_lowercase();
29            if !s.is_empty() {
30                return Self::from_normalized(&s);
31            }
32        }
33        Self::from_normalized(std::env::consts::OS)
34    }
35
36    fn from_normalized(os: &str) -> Self {
37        let id = match os {
38            "darwin" | "macos" => Cow::Borrowed("macos"),
39            "linux" => Cow::Borrowed("linux"),
40            "windows" => Cow::Borrowed("windows"),
41            other => Cow::Owned(other.to_string()),
42        };
43        Self { id }
44    }
45}
46
47fn normalize_os_token(tok: &str) -> String {
48    match tok.trim().to_ascii_lowercase().as_str() {
49        "darwin" => "macos".to_string(),
50        s => s.to_string(),
51    }
52}
53
54fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
55    if os_list.is_empty() {
56        return true;
57    }
58    os_list.iter().any(|o| normalize_os_token(o) == platform)
59}
60
61#[derive(Debug, Deserialize)]
62struct RawRootSpec {
63    metadata: Option<Metadata>,
64    #[serde(default)]
65    include: Vec<IncludeRef>,
66    #[serde(default)]
67    commands: BTreeMap<String, RawCommandNode>,
68}
69
70#[derive(Debug, Deserialize)]
71struct RawCommandNode {
72    #[serde(default)]
73    os: Vec<String>,
74    #[serde(default)]
75    about: String,
76    path: Option<String>,
77    #[serde(default)]
78    dependencies: Vec<String>,
79    #[serde(default)]
80    requires: Vec<String>,
81    #[serde(default)]
82    env: EnvSpec,
83    #[serde(default)]
84    inputs: BTreeMap<String, InputDef>,
85    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
86    cron: Vec<String>,
87    #[serde(default)]
88    packages: crate::PackagesSpec,
89    #[serde(default)]
90    tests: BTreeMap<String, crate::CommandTest>,
91    #[serde(default)]
92    aliases: crate::AliasesSpec,
93    #[serde(default)]
94    config: crate::ConfigSpec,
95    include: Option<IncludeRef>,
96    #[serde(default)]
97    commands: BTreeMap<String, RawCommandNode>,
98    exec: Option<ExecSpec>,
99}
100
101#[derive(Clone)]
102struct LoadCtx {
103    /// Canonical root of the directory selected by `jan use`.
104    ///
105    /// Local includes are interpreted relative to this directory. Canonicalization
106    /// also prevents symlink escapes.
107    use_root: PathBuf,
108}
109
110impl LoadCtx {
111    fn read_local_bytes(&self, local: &LocalInclude) -> Result<(PathBuf, Vec<u8>)> {
112        let path = resolve_under_use_root(&self.use_root, &local.path)?;
113        let bytes = std::fs::read(&path)
114            .with_context(|| format!("read included file {}", path.display()))?;
115        if let Some(hash) = local
116            .sha256
117            .as_deref()
118            .map(str::trim)
119            .filter(|s| !s.is_empty())
120        {
121            let expected = remote::normalize_sha256(hash)?;
122            let got = remote::sha256_hex(&bytes);
123            if got != expected {
124                bail!(
125                    "SHA256 mismatch for include `{}`: expected {expected}, got {got}",
126                    local.path
127                );
128            }
129        }
130        Ok((path, bytes))
131    }
132
133    fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
134        match inc {
135            IncludeRef::Local(local) => {
136                let p = resolve_under_use_root(&self.use_root, &local.path)?;
137                Ok(p.to_string_lossy().to_string())
138            }
139            IncludeRef::Remote(_) => Ok(inc.cycle_token()),
140        }
141    }
142}
143
144fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
145    let mut opts = FetchOpts::new();
146    if let Some(ttl) = r.ttl {
147        opts = opts.with_ttl(ttl);
148    }
149    remote::fetch_verified_text(&r.url, &r.sha256, &opts)
150        .with_context(|| format!("fetch remote include {}", r.url))
151}
152
153/// Resolve `rel` under `use_root`, rejecting escapes. Allows files or directories.
154pub(crate) fn resolve_under_use_root_any(use_root: &Path, rel: &str) -> Result<PathBuf> {
155    let rel = rel.trim();
156    if rel.is_empty() {
157        bail!("empty path");
158    }
159    let p = Path::new(rel);
160    if p.is_absolute() {
161        bail!("path must be relative to the jan use root: {rel}");
162    }
163    if p.components()
164        .any(|c| matches!(c, std::path::Component::ParentDir))
165    {
166        bail!("path must not contain `..`: {rel}");
167    }
168
169    let full = use_root.join(p);
170    let resolved = full
171        .canonicalize()
172        .with_context(|| format!("path not found: {}", full.display()))?;
173    if !resolved.starts_with(use_root) {
174        bail!(
175            "path escapes jan use root: {} (root: {})",
176            resolved.display(),
177            use_root.display()
178        );
179    }
180    Ok(resolved)
181}
182
183/// Resolve `rel` under `use_root`, rejecting escapes and non-files.
184pub(crate) fn resolve_under_use_root(use_root: &Path, rel: &str) -> Result<PathBuf> {
185    let resolved = resolve_under_use_root_any(use_root, rel)?;
186    if !resolved.is_file() {
187        bail!("include is not a file: {}", resolved.display());
188    }
189    Ok(resolved)
190}
191
192fn include_link_for(inc: &IncludeRef, kind: IncludeLinkKind) -> IncludeLink {
193    match inc {
194        IncludeRef::Local(local) => IncludeLink {
195            kind,
196            path: Some(local.path.clone()),
197            url: None,
198            sha256: local.sha256.clone(),
199        },
200        IncludeRef::Remote(r) => IncludeLink {
201            kind,
202            path: None,
203            url: Some(r.url.clone()),
204            sha256: Some(r.sha256.clone()),
205        },
206    }
207}
208
209fn apply_wrapper_overlays(raw: &mut RawCommandNode, node: &mut CommandNode) -> Result<()> {
210    node.os = merge_os_filters(&raw.os, &node.os)?;
211    node.about = overlay_about(&raw.about, std::mem::take(&mut node.about));
212    if !raw.cron.is_empty() {
213        node.cron = std::mem::take(&mut raw.cron);
214    }
215    if !raw.env.is_empty() {
216        node.env.merge_from(std::mem::take(&mut raw.env));
217    }
218    for (k, v) in std::mem::take(&mut raw.inputs) {
219        node.inputs.insert(k, v);
220    }
221    if raw.path.is_some() {
222        node.path = raw.path.take();
223    }
224    if !raw.dependencies.is_empty() {
225        node.dependencies = std::mem::take(&mut raw.dependencies);
226    }
227    if !raw.requires.is_empty() {
228        node.requires = std::mem::take(&mut raw.requires);
229    }
230    if !raw.packages.is_empty() {
231        node.packages.merge_from(std::mem::take(&mut raw.packages));
232    }
233    for (k, v) in std::mem::take(&mut raw.tests) {
234        node.tests.insert(k, v);
235    }
236    if !raw.aliases.is_empty() {
237        node.aliases.merge_from(std::mem::take(&mut raw.aliases));
238    }
239    if !raw.config.is_empty() {
240        node.config.merge_from(std::mem::take(&mut raw.config));
241    }
242    Ok(())
243}
244
245fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
246    match (outer.is_empty(), inner.is_empty()) {
247        (true, true) => Ok(vec![]),
248        (true, false) => Ok(inner.to_vec()),
249        (false, true) => Ok(outer.to_vec()),
250        (false, false) => {
251            let merged: Vec<String> = outer
252                .iter()
253                .filter(|o| {
254                    let n = normalize_os_token(o);
255                    inner.iter().any(|i| normalize_os_token(i) == n)
256                })
257                .cloned()
258                .collect();
259            if merged.is_empty() {
260                bail!(
261                    "conflicting `os:` filters between include wrapper and included file \
262                     (no platform appears in both lists)"
263                );
264            }
265            Ok(merged)
266        }
267    }
268}
269
270fn overlay_about(overlay: &str, base: String) -> String {
271    let o = overlay.trim();
272    if o.is_empty() {
273        base
274    } else {
275        o.to_string()
276    }
277}
278
279fn resolve_raw_command_node(
280    raw: RawCommandNode,
281    ctx: &LoadCtx,
282    visited: &mut HashSet<String>,
283) -> Result<CommandNode> {
284    if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
285        bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
286    }
287
288    let mut raw = raw;
289    if let Some(inc) = raw.include.take() {
290        let token = ctx.visit_token(&inc)?;
291        if !visited.insert(token.clone()) {
292            bail!("include cycle detected at `{token}`");
293        }
294        let label = inc.cycle_token();
295        let mut node = match &inc {
296            IncludeRef::Local(local) if !local.is_yaml() => {
297                if local.path.trim().is_empty() {
298                    bail!("empty include path");
299                }
300                let (_path, _bytes) = ctx.read_local_bytes(local)?;
301                CommandNode {
302                    about: String::new(),
303                    exec: Some(ExecSpec {
304                        argv: local.argv.clone(),
305                        passthrough: local.passthrough,
306                        file: Some(local.path.clone()),
307                        sha256: local.sha256.clone(),
308                        ..Default::default()
309                    }),
310                    source: Some(include_link_for(&inc, IncludeLinkKind::Script)),
311                    ..Default::default()
312                }
313            }
314            IncludeRef::Local(local) => {
315                if !local.argv.is_empty() || local.passthrough {
316                    bail!(
317                        "include `{label}`: `argv` / `passthrough` are only valid for script files, not YAML"
318                    );
319                }
320                let (_path, bytes) = ctx.read_local_bytes(local)?;
321                let text = String::from_utf8(bytes)
322                    .with_context(|| format!("include `{label}` is not valid UTF-8"))?;
323                let inner: RawCommandNode = serde_yaml::from_str(&text)
324                    .with_context(|| format!("parse include `{label}`"))?;
325                let mut node = resolve_raw_command_node(inner, ctx, visited)?;
326                node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
327                node
328            }
329            IncludeRef::Remote(r) => {
330                let text = fetch_remote_include(r)?;
331                let inner: RawCommandNode = serde_yaml::from_str(&text)
332                    .with_context(|| format!("parse include `{label}`"))?;
333                let mut node = resolve_raw_command_node(inner, ctx, visited)?;
334                node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
335                node
336            }
337        };
338        visited.remove(&token);
339        apply_wrapper_overlays(&mut raw, &mut node)?;
340        return Ok(node);
341    }
342
343    let mut commands = BTreeMap::new();
344    for (name, child) in raw.commands {
345        commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
346    }
347
348    Ok(CommandNode {
349        os: raw.os,
350        about: raw.about,
351        path: raw.path,
352        dependencies: raw.dependencies,
353        requires: raw.requires,
354        env: raw.env,
355        inputs: raw.inputs,
356        cron: raw.cron,
357        packages: raw.packages,
358        tests: raw.tests,
359        aliases: raw.aliases,
360        config: raw.config,
361        commands,
362        exec: raw.exec,
363        source: None,
364    })
365}
366
367fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
368    let mut merged = BTreeMap::new();
369    for inc in &root.include {
370        let text = match inc {
371            IncludeRef::Local(local) => {
372                if !local.is_yaml() {
373                    bail!(
374                        "root-level include `{}` must be a YAML file (.yaml / .yml)",
375                        local.path
376                    );
377                }
378                if !local.argv.is_empty() || local.passthrough {
379                    bail!(
380                        "root-level include `{}`: `argv` / `passthrough` are not valid here",
381                        local.path
382                    );
383                }
384                let path = resolve_under_use_root(use_root, &local.path)?;
385                let bytes = std::fs::read(&path)
386                    .with_context(|| format!("read root include {}", path.display()))?;
387                if let Some(hash) = local
388                    .sha256
389                    .as_deref()
390                    .map(str::trim)
391                    .filter(|s| !s.is_empty())
392                {
393                    let expected = remote::normalize_sha256(hash)?;
394                    let got = remote::sha256_hex(&bytes);
395                    if got != expected {
396                        bail!(
397                            "SHA256 mismatch for root include `{}`: expected {expected}, got {got}",
398                            local.path
399                        );
400                    }
401                }
402                String::from_utf8(bytes).with_context(|| {
403                    format!("root include {} is not valid UTF-8", path.display())
404                })?
405            }
406            IncludeRef::Remote(r) => fetch_remote_include(r)?,
407        };
408        let label = inc.cycle_token();
409        let fragment: RawRootSpec =
410            serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
411        let mut expanded = merge_root_includes(fragment, use_root)?;
412        merged.append(&mut expanded.commands);
413    }
414    merged.append(&mut root.commands);
415    root.commands = merged;
416    root.include.clear();
417    Ok(root)
418}
419
420fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
421    let mut visited = HashSet::new();
422    let mut commands = BTreeMap::new();
423    for (name, node) in raw.commands {
424        commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
425    }
426    Ok(RootSpec {
427        metadata: raw.metadata,
428        commands,
429    })
430}
431
432fn validate_root(spec: &RootSpec) -> Result<()> {
433    for (name, node) in &spec.commands {
434        node.validate(name)?;
435    }
436    Ok(())
437}
438
439pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
440    let spec_path = spec_path
441        .canonicalize()
442        .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
443    let text = std::fs::read_to_string(&spec_path)
444        .with_context(|| format!("read spec file {}", spec_path.display()))?;
445    let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
446    let use_root = spec_path
447        .parent()
448        .unwrap_or_else(|| Path::new("."))
449        .to_path_buf();
450    let raw = merge_root_includes(raw, &use_root)?;
451    let ctx = LoadCtx { use_root };
452    let mut spec = materialize_root(raw, &ctx)?;
453    // Validate structure before OS filtering: filtering can drop nested nodes (e.g. empty
454    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
455    validate_root(&spec)?;
456    filter_spec_for_platform(&mut spec, platform.id.as_ref());
457    Ok(spec)
458}
459
460/// Parse an in-memory spec. Every local `include` resolves relative to `use_root`.
461/// Pass `None` only when the document has no root `include` keys.
462pub fn load_spec_from_str(
463    raw: &str,
464    use_root: Option<&Path>,
465    platform: HostPlatform,
466) -> Result<RootSpec> {
467    let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
468    let has_local_root_include = raw
469        .include
470        .iter()
471        .any(|i| matches!(i, IncludeRef::Local(_)));
472    let canonical_root = use_root
473        .map(|root| {
474            root.canonicalize()
475                .with_context(|| format!("canonicalize jan use root {}", root.display()))
476        })
477        .transpose()?;
478    let raw = if let Some(root) = canonical_root.as_deref() {
479        merge_root_includes(raw, root)?
480    } else {
481        if has_local_root_include {
482            bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
483        }
484        // Remote-only root includes can merge without a local base.
485        if !raw.include.is_empty() {
486            merge_root_includes(raw, Path::new("."))?
487        } else {
488            raw
489        }
490    };
491    let ctx = LoadCtx {
492        use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
493    };
494    let mut spec = materialize_root(raw, &ctx)?;
495    validate_root(&spec)?;
496    filter_spec_for_platform(&mut spec, platform.id.as_ref());
497    Ok(spec)
498}
499
500pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
501    filter_command_map(&mut spec.commands, platform_id);
502}
503
504fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
505    map.retain(|_, node| {
506        if !node_visible_for_platform(&node.os, platform_id) {
507            return false;
508        }
509        filter_command_map(&mut node.commands, platform_id);
510        if node.exec.is_some() || !node.aliases.is_empty() || !node.config.is_empty() {
511            return true;
512        }
513        !node.commands.is_empty()
514    });
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::{AliasesSpec, ExecSpec, LocalInclude};
521    use std::fs;
522
523    #[test]
524    fn os_filter_drops_linux_only_branch() {
525        let mut spec = RootSpec {
526            metadata: None,
527            commands: BTreeMap::from([(
528                "sys".into(),
529                CommandNode {
530                    os: vec!["linux".into()],
531                    about: "linux".into(),
532                    commands: BTreeMap::from([(
533                        "ports".into(),
534                        CommandNode {
535                            exec: Some(ExecSpec {
536                                argv: vec!["echo".into(), "x".into()],
537                                ..Default::default()
538                            }),
539                            ..Default::default()
540                        },
541                    )]),
542                    ..Default::default()
543                },
544            )]),
545        };
546        filter_spec_for_platform(&mut spec, "macos");
547        assert!(spec.commands.is_empty());
548    }
549
550    #[test]
551    fn os_filter_keeps_alias_only_node() {
552        let mut spec = RootSpec {
553            metadata: None,
554            commands: BTreeMap::from([(
555                "shortcuts".into(),
556                CommandNode {
557                    aliases: AliasesSpec {
558                        shell: BTreeMap::from([("g".into(), "git".into())]),
559                        ..Default::default()
560                    },
561                    ..Default::default()
562                },
563            )]),
564        };
565        filter_spec_for_platform(&mut spec, "linux");
566        assert!(spec.commands.contains_key("shortcuts"));
567    }
568
569    #[test]
570    fn os_filter_keeps_config_only_node() {
571        let mut spec = RootSpec {
572            metadata: None,
573            commands: BTreeMap::from([(
574                "cfg".into(),
575                CommandNode {
576                    config: crate::ConfigSpec {
577                        shell: Some(crate::ConfigShell::Inline("export X=1\n".into())),
578                        ..Default::default()
579                    },
580                    ..Default::default()
581                },
582            )]),
583        };
584        filter_spec_for_platform(&mut spec, "linux");
585        assert!(spec.commands.contains_key("cfg"));
586    }
587
588    #[test]
589    fn wrapper_aliases_merge_onto_include() {
590        let tmp = tempfile::tempdir().unwrap();
591        let root = tmp.path();
592        fs::write(
593            root.join("leaf.yaml"),
594            "about: leaf\naliases: [lb]\nexec:\n  argv: [\"echo\", \"x\"]\n",
595        )
596        .unwrap();
597        fs::write(
598            root.join("scripts.spec.yaml"),
599            "commands:\n  leaf:\n    include: leaf.yaml\n    aliases:\n      g: git\n",
600        )
601        .unwrap();
602
603        let spec =
604            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
605        assert_eq!(spec.commands["leaf"].aliases.names, vec!["lb"]);
606        assert_eq!(
607            spec.commands["leaf"].aliases.shell.get("g").map(String::as_str),
608            Some("git")
609        );
610    }
611
612    #[test]
613    fn nested_include_resolves_from_use_root() {
614        let tmp = tempfile::tempdir().unwrap();
615        let root = tmp.path();
616        fs::create_dir(root.join("sub")).unwrap();
617        fs::write(
618            root.join("leaf.yaml"),
619            "about: root leaf\nexec:\n  argv: [\"echo\", \"root\"]\n",
620        )
621        .unwrap();
622        fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
623        fs::write(
624            root.join("scripts.spec.yaml"),
625            "commands:\n  outer:\n    include: sub/outer.yaml\n",
626        )
627        .unwrap();
628
629        let spec =
630            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
631        assert_eq!(
632            spec.commands["outer"].exec.as_ref().unwrap().argv,
633            vec!["echo", "root"]
634        );
635    }
636
637    #[test]
638    fn include_rejects_absolute_and_parent_paths() {
639        let tmp = tempfile::tempdir().unwrap();
640        let root = tmp.path().join("tree");
641        fs::create_dir(&root).unwrap();
642        let outside = tmp.path().join("outside.yaml");
643        fs::write(
644            &outside,
645            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
646        )
647        .unwrap();
648
649        for include in [
650            outside.to_string_lossy().into_owned(),
651            "../outside.yaml".to_string(),
652        ] {
653            fs::write(
654                root.join("scripts.spec.yaml"),
655                format!("commands:\n  escaped:\n    include: {include:?}\n"),
656            )
657            .unwrap();
658            let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
659                .unwrap_err();
660            assert!(
661                err.to_string().contains("must be relative")
662                    || err.to_string().contains("must not contain `..`"),
663                "{err:#}"
664            );
665        }
666    }
667
668    #[cfg(unix)]
669    #[test]
670    fn include_rejects_symlink_escape() {
671        use std::os::unix::fs::symlink;
672
673        let tmp = tempfile::tempdir().unwrap();
674        let root = tmp.path().join("tree");
675        fs::create_dir(&root).unwrap();
676        let outside = tmp.path().join("outside.yaml");
677        fs::write(
678            &outside,
679            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
680        )
681        .unwrap();
682        symlink(&outside, root.join("linked.yaml")).unwrap();
683        fs::write(
684            root.join("scripts.spec.yaml"),
685            "commands:\n  escaped:\n    include: linked.yaml\n",
686        )
687        .unwrap();
688
689        let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
690            .unwrap_err();
691        assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
692    }
693
694    #[test]
695    fn include_ref_deserializes_local_and_remote() {
696        let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
697        assert_eq!(
698            local,
699            IncludeRef::Local(LocalInclude::from_path("sub/a.yaml"))
700        );
701        let local_map: IncludeRef = serde_yaml::from_str(
702            "path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
703        )
704        .unwrap();
705        match local_map {
706            IncludeRef::Local(l) => {
707                assert_eq!(l.path, "scripts/x.sh");
708                assert!(l.passthrough);
709                assert_eq!(l.argv, vec!["bash"]);
710                assert!(l.sha256.is_some());
711            }
712            _ => panic!("expected local"),
713        }
714        let remote: IncludeRef = serde_yaml::from_str(
715            "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
716        )
717        .unwrap();
718        assert!(remote.is_remote());
719    }
720
721    #[test]
722    fn exec_remote_requires_sha256() {
723        let node = CommandNode {
724            exec: Some(ExecSpec {
725                url: Some("https://example.com/x.sh".into()),
726                ..Default::default()
727            }),
728            ..Default::default()
729        };
730        assert!(node.validate("x").is_err());
731    }
732}