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