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
61fn normalize_computer_token(tok: &str) -> String {
62    tok.trim().to_ascii_lowercase()
63}
64
65fn node_visible_for_computer(computer_list: &[String], host: Option<&str>) -> bool {
66    if computer_list.is_empty() {
67        return true;
68    }
69    let Some(host) = host else {
70        return false;
71    };
72    computer_list
73        .iter()
74        .any(|c| normalize_computer_token(c) == host)
75}
76
77/// Which computer id to use when filtering `computer:` lists on commands.
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct HostComputer {
80    /// Normalized id from registration / env / legacy file / auto-detect, or `None`.
81    pub id: Option<Cow<'static, str>>,
82}
83
84impl HostComputer {
85    /// Resolve the host computer, honoring `JAN_COMPUTER` when set (for tests and overrides).
86    pub fn detect() -> Self {
87        if let Ok(v) = std::env::var("JAN_COMPUTER") {
88            let s = v.trim();
89            if !s.is_empty() {
90                return Self {
91                    id: Some(Cow::Owned(normalize_computer_token(s))),
92                };
93            }
94        }
95        if let Ok(cfg) = crate::config::load_user_config() {
96            if let Some(id) = cfg
97                .computer_id
98                .as_deref()
99                .map(str::trim)
100                .filter(|s| !s.is_empty())
101            {
102                return Self {
103                    id: Some(Cow::Owned(normalize_computer_token(id))),
104                };
105            }
106        }
107        if let Some(home) = dirs::home_dir() {
108            let legacy = home.join(".config/jan/computer");
109            if legacy.is_file() {
110                if let Ok(text) = std::fs::read_to_string(&legacy) {
111                    let id = text.trim();
112                    if !id.is_empty() {
113                        return Self {
114                            id: Some(Cow::Owned(normalize_computer_token(id))),
115                        };
116                    }
117                }
118            }
119        }
120        Self::auto_detect()
121    }
122
123    fn auto_detect() -> Self {
124        if std::path::Path::new("/sys/devices/virtual/dmi/id/sys_vendor").is_readable() {
125            if let Ok(vendor) = std::fs::read_to_string("/sys/devices/virtual/dmi/id/sys_vendor") {
126                if vendor.to_ascii_lowercase().contains("framework") {
127                    return Self {
128                        id: Some(Cow::Borrowed("framework")),
129                    };
130                }
131            }
132        }
133        let host = hostname_short();
134        let key = format!("{}-{}", std::env::consts::OS, host);
135        let id = match key.as_str() {
136            "darwin-mac2025" | "darwin-mac2025.local" => Some("mac2025"),
137            s if s.contains("2017") => Some("mac_2017"),
138            s if s.contains("2022") => Some("mac_2022"),
139            _ => None,
140        };
141        Self {
142            id: id.map(Cow::Borrowed),
143        }
144    }
145}
146
147fn hostname_short() -> String {
148    std::process::Command::new("hostname")
149        .arg("-s")
150        .output()
151        .ok()
152        .filter(|o| o.status.success())
153        .and_then(|o| String::from_utf8(o.stdout).ok())
154        .map(|s| s.trim().to_string())
155        .filter(|s| !s.is_empty())
156        .unwrap_or_else(|| {
157            std::env::var("HOSTNAME")
158                .or_else(|_| std::env::var("HOST"))
159                .unwrap_or_default()
160                .trim()
161                .to_string()
162        })
163}
164
165trait PathReadable {
166    fn is_readable(&self) -> bool;
167}
168
169impl PathReadable for std::path::Path {
170    fn is_readable(&self) -> bool {
171        std::fs::OpenOptions::new().read(true).open(self).is_ok()
172    }
173}
174
175#[derive(Debug, Deserialize)]
176struct RawRootSpec {
177    metadata: Option<Metadata>,
178    #[serde(default)]
179    include: Vec<IncludeRef>,
180    #[serde(default)]
181    commands: BTreeMap<String, RawCommandNode>,
182}
183
184#[derive(Debug, Deserialize)]
185struct RawCommandNode {
186    #[serde(default)]
187    os: Vec<String>,
188    #[serde(default)]
189    computer: Vec<String>,
190    #[serde(default)]
191    about: String,
192    path: Option<String>,
193    #[serde(default)]
194    dependencies: Vec<String>,
195    #[serde(default)]
196    requires: Vec<String>,
197    #[serde(default)]
198    env: EnvSpec,
199    #[serde(default)]
200    inputs: BTreeMap<String, InputDef>,
201    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
202    cron: Vec<String>,
203    #[serde(default)]
204    packages: crate::PackagesSpec,
205    #[serde(default)]
206    tests: BTreeMap<String, crate::CommandTest>,
207    #[serde(default)]
208    aliases: crate::AliasesSpec,
209    #[serde(default)]
210    config: crate::ConfigSpec,
211    include: Option<IncludeRef>,
212    #[serde(default)]
213    commands: BTreeMap<String, RawCommandNode>,
214    exec: Option<ExecSpec>,
215}
216
217#[derive(Clone)]
218struct LoadCtx {
219    /// Canonical root of the directory selected by `jan use`.
220    ///
221    /// Local includes are interpreted relative to this directory. Canonicalization
222    /// also prevents symlink escapes.
223    use_root: PathBuf,
224}
225
226impl LoadCtx {
227    fn read_local_bytes(&self, local: &LocalInclude) -> Result<(PathBuf, Vec<u8>)> {
228        let path = resolve_under_use_root(&self.use_root, &local.path)?;
229        let bytes = std::fs::read(&path)
230            .with_context(|| format!("read included file {}", path.display()))?;
231        if let Some(hash) = local
232            .sha256
233            .as_deref()
234            .map(str::trim)
235            .filter(|s| !s.is_empty())
236        {
237            let expected = remote::normalize_sha256(hash)?;
238            let got = remote::sha256_hex(&bytes);
239            if got != expected {
240                bail!(
241                    "SHA256 mismatch for include `{}`: expected {expected}, got {got}",
242                    local.path
243                );
244            }
245        }
246        Ok((path, bytes))
247    }
248
249    fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
250        match inc {
251            IncludeRef::Local(local) => {
252                let p = resolve_under_use_root(&self.use_root, &local.path)?;
253                Ok(p.to_string_lossy().to_string())
254            }
255            IncludeRef::Remote(_) => Ok(inc.cycle_token()),
256        }
257    }
258}
259
260fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
261    let mut opts = FetchOpts::new();
262    if let Some(ttl) = r.ttl {
263        opts = opts.with_ttl(ttl);
264    }
265    remote::fetch_verified_text(&r.url, &r.sha256, &opts)
266        .with_context(|| format!("fetch remote include {}", r.url))
267}
268
269/// Resolve `rel` under `use_root`, rejecting escapes. Allows files or directories.
270pub(crate) fn resolve_under_use_root_any(use_root: &Path, rel: &str) -> Result<PathBuf> {
271    let rel = rel.trim();
272    if rel.is_empty() {
273        bail!("empty path");
274    }
275    let p = Path::new(rel);
276    if p.is_absolute() {
277        bail!("path must be relative to the jan use root: {rel}");
278    }
279    if p.components()
280        .any(|c| matches!(c, std::path::Component::ParentDir))
281    {
282        bail!("path must not contain `..`: {rel}");
283    }
284
285    let full = use_root.join(p);
286    let resolved = full
287        .canonicalize()
288        .with_context(|| format!("path not found: {}", full.display()))?;
289    if !resolved.starts_with(use_root) {
290        bail!(
291            "path escapes jan use root: {} (root: {})",
292            resolved.display(),
293            use_root.display()
294        );
295    }
296    Ok(resolved)
297}
298
299/// Resolve `rel` under `use_root`, rejecting escapes and non-files.
300pub(crate) fn resolve_under_use_root(use_root: &Path, rel: &str) -> Result<PathBuf> {
301    let resolved = resolve_under_use_root_any(use_root, rel)?;
302    if !resolved.is_file() {
303        bail!("include is not a file: {}", resolved.display());
304    }
305    Ok(resolved)
306}
307
308fn include_link_for(inc: &IncludeRef, kind: IncludeLinkKind) -> IncludeLink {
309    match inc {
310        IncludeRef::Local(local) => IncludeLink {
311            kind,
312            path: Some(local.path.clone()),
313            url: None,
314            sha256: local.sha256.clone(),
315        },
316        IncludeRef::Remote(r) => IncludeLink {
317            kind,
318            path: None,
319            url: Some(r.url.clone()),
320            sha256: Some(r.sha256.clone()),
321        },
322    }
323}
324
325fn apply_wrapper_overlays(raw: &mut RawCommandNode, node: &mut CommandNode) -> Result<()> {
326    node.os = merge_os_filters(&raw.os, &node.os)?;
327    node.computer = merge_computer_filters(&raw.computer, &node.computer)?;
328    node.about = overlay_about(&raw.about, std::mem::take(&mut node.about));
329    if !raw.cron.is_empty() {
330        node.cron = std::mem::take(&mut raw.cron);
331    }
332    if !raw.env.is_empty() {
333        node.env.merge_from(std::mem::take(&mut raw.env));
334    }
335    for (k, v) in std::mem::take(&mut raw.inputs) {
336        node.inputs.insert(k, v);
337    }
338    if raw.path.is_some() {
339        node.path = raw.path.take();
340    }
341    if !raw.dependencies.is_empty() {
342        node.dependencies = std::mem::take(&mut raw.dependencies);
343    }
344    if !raw.requires.is_empty() {
345        node.requires = std::mem::take(&mut raw.requires);
346    }
347    if !raw.packages.is_empty() {
348        node.packages.merge_from(std::mem::take(&mut raw.packages));
349    }
350    for (k, v) in std::mem::take(&mut raw.tests) {
351        node.tests.insert(k, v);
352    }
353    if !raw.aliases.is_empty() {
354        node.aliases.merge_from(std::mem::take(&mut raw.aliases));
355    }
356    if !raw.config.is_empty() {
357        node.config.merge_from(std::mem::take(&mut raw.config));
358    }
359    Ok(())
360}
361
362fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
363    match (outer.is_empty(), inner.is_empty()) {
364        (true, true) => Ok(vec![]),
365        (true, false) => Ok(inner.to_vec()),
366        (false, true) => Ok(outer.to_vec()),
367        (false, false) => {
368            let merged: Vec<String> = outer
369                .iter()
370                .filter(|o| {
371                    let n = normalize_os_token(o);
372                    inner.iter().any(|i| normalize_os_token(i) == n)
373                })
374                .cloned()
375                .collect();
376            if merged.is_empty() {
377                bail!(
378                    "conflicting `os:` filters between include wrapper and included file \
379                     (no platform appears in both lists)"
380                );
381            }
382            Ok(merged)
383        }
384    }
385}
386
387fn merge_computer_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
388    match (outer.is_empty(), inner.is_empty()) {
389        (true, true) => Ok(vec![]),
390        (true, false) => Ok(inner.to_vec()),
391        (false, true) => Ok(outer.to_vec()),
392        (false, false) => {
393            let merged: Vec<String> = outer
394                .iter()
395                .filter(|o| {
396                    let n = normalize_computer_token(o);
397                    inner.iter().any(|i| normalize_computer_token(i) == n)
398                })
399                .cloned()
400                .collect();
401            if merged.is_empty() {
402                bail!(
403                    "conflicting `computer:` filters between include wrapper and included file \
404                     (no computer id appears in both lists)"
405                );
406            }
407            Ok(merged)
408        }
409    }
410}
411
412fn overlay_about(overlay: &str, base: String) -> String {
413    let o = overlay.trim();
414    if o.is_empty() {
415        base
416    } else {
417        o.to_string()
418    }
419}
420
421fn resolve_raw_command_node(
422    raw: RawCommandNode,
423    ctx: &LoadCtx,
424    visited: &mut HashSet<String>,
425) -> Result<CommandNode> {
426    if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
427        bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
428    }
429
430    let mut raw = raw;
431    if let Some(inc) = raw.include.take() {
432        let token = ctx.visit_token(&inc)?;
433        if !visited.insert(token.clone()) {
434            bail!("include cycle detected at `{token}`");
435        }
436        let label = inc.cycle_token();
437        let mut node = match &inc {
438            IncludeRef::Local(local) if !local.is_yaml() => {
439                if local.path.trim().is_empty() {
440                    bail!("empty include path");
441                }
442                let (_path, _bytes) = ctx.read_local_bytes(local)?;
443                CommandNode {
444                    about: String::new(),
445                    exec: Some(ExecSpec {
446                        argv: local.argv.clone(),
447                        passthrough: local.passthrough,
448                        file: Some(local.path.clone()),
449                        sha256: local.sha256.clone(),
450                        ..Default::default()
451                    }),
452                    source: Some(include_link_for(&inc, IncludeLinkKind::Script)),
453                    ..Default::default()
454                }
455            }
456            IncludeRef::Local(local) => {
457                if !local.argv.is_empty() || local.passthrough {
458                    bail!(
459                        "include `{label}`: `argv` / `passthrough` are only valid for script files, not YAML"
460                    );
461                }
462                let (_path, bytes) = ctx.read_local_bytes(local)?;
463                let text = String::from_utf8(bytes)
464                    .with_context(|| format!("include `{label}` is not valid UTF-8"))?;
465                let inner: RawCommandNode = serde_yaml::from_str(&text)
466                    .with_context(|| format!("parse include `{label}`"))?;
467                let mut node = resolve_raw_command_node(inner, ctx, visited)?;
468                node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
469                node
470            }
471            IncludeRef::Remote(r) => {
472                let text = fetch_remote_include(r)?;
473                let inner: RawCommandNode = serde_yaml::from_str(&text)
474                    .with_context(|| format!("parse include `{label}`"))?;
475                let mut node = resolve_raw_command_node(inner, ctx, visited)?;
476                node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
477                node
478            }
479        };
480        visited.remove(&token);
481        apply_wrapper_overlays(&mut raw, &mut node)?;
482        return Ok(node);
483    }
484
485    let mut commands = BTreeMap::new();
486    for (name, child) in raw.commands {
487        commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
488    }
489
490    Ok(CommandNode {
491        os: raw.os,
492        computer: raw.computer,
493        about: raw.about,
494        path: raw.path,
495        dependencies: raw.dependencies,
496        requires: raw.requires,
497        env: raw.env,
498        inputs: raw.inputs,
499        cron: raw.cron,
500        packages: raw.packages,
501        tests: raw.tests,
502        aliases: raw.aliases,
503        config: raw.config,
504        commands,
505        exec: raw.exec,
506        source: None,
507    })
508}
509
510fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
511    let mut merged = BTreeMap::new();
512    for inc in &root.include {
513        let text = match inc {
514            IncludeRef::Local(local) => {
515                if !local.is_yaml() {
516                    bail!(
517                        "root-level include `{}` must be a YAML file (.yaml / .yml)",
518                        local.path
519                    );
520                }
521                if !local.argv.is_empty() || local.passthrough {
522                    bail!(
523                        "root-level include `{}`: `argv` / `passthrough` are not valid here",
524                        local.path
525                    );
526                }
527                let path = resolve_under_use_root(use_root, &local.path)?;
528                let bytes = std::fs::read(&path)
529                    .with_context(|| format!("read root include {}", path.display()))?;
530                if let Some(hash) = local
531                    .sha256
532                    .as_deref()
533                    .map(str::trim)
534                    .filter(|s| !s.is_empty())
535                {
536                    let expected = remote::normalize_sha256(hash)?;
537                    let got = remote::sha256_hex(&bytes);
538                    if got != expected {
539                        bail!(
540                            "SHA256 mismatch for root include `{}`: expected {expected}, got {got}",
541                            local.path
542                        );
543                    }
544                }
545                String::from_utf8(bytes).with_context(|| {
546                    format!("root include {} is not valid UTF-8", path.display())
547                })?
548            }
549            IncludeRef::Remote(r) => fetch_remote_include(r)?,
550        };
551        let label = inc.cycle_token();
552        let fragment: RawRootSpec =
553            serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
554        let mut expanded = merge_root_includes(fragment, use_root)?;
555        merged.append(&mut expanded.commands);
556    }
557    merged.append(&mut root.commands);
558    root.commands = merged;
559    root.include.clear();
560    Ok(root)
561}
562
563fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
564    let mut visited = HashSet::new();
565    let mut commands = BTreeMap::new();
566    for (name, node) in raw.commands {
567        commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
568    }
569    Ok(RootSpec {
570        metadata: raw.metadata,
571        commands,
572    })
573}
574
575fn validate_root(spec: &RootSpec) -> Result<()> {
576    for (name, node) in &spec.commands {
577        node.validate(name)?;
578    }
579    Ok(())
580}
581
582pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
583    let spec_path = spec_path
584        .canonicalize()
585        .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
586    let text = std::fs::read_to_string(&spec_path)
587        .with_context(|| format!("read spec file {}", spec_path.display()))?;
588    let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
589    let use_root = spec_path
590        .parent()
591        .unwrap_or_else(|| Path::new("."))
592        .to_path_buf();
593    let raw = merge_root_includes(raw, &use_root)?;
594    let ctx = LoadCtx { use_root };
595    let mut spec = materialize_root(raw, &ctx)?;
596    // Validate structure before OS filtering: filtering can drop nested nodes (e.g. empty
597    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
598    validate_root(&spec)?;
599    filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
600    Ok(spec)
601}
602
603/// Parse an in-memory spec. Every local `include` resolves relative to `use_root`.
604/// Pass `None` only when the document has no root `include` keys.
605pub fn load_spec_from_str(
606    raw: &str,
607    use_root: Option<&Path>,
608    platform: HostPlatform,
609) -> Result<RootSpec> {
610    let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
611    let has_local_root_include = raw
612        .include
613        .iter()
614        .any(|i| matches!(i, IncludeRef::Local(_)));
615    let canonical_root = use_root
616        .map(|root| {
617            root.canonicalize()
618                .with_context(|| format!("canonicalize jan use root {}", root.display()))
619        })
620        .transpose()?;
621    let raw = if let Some(root) = canonical_root.as_deref() {
622        merge_root_includes(raw, root)?
623    } else {
624        if has_local_root_include {
625            bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
626        }
627        // Remote-only root includes can merge without a local base.
628        if !raw.include.is_empty() {
629            merge_root_includes(raw, Path::new("."))?
630        } else {
631            raw
632        }
633    };
634    let ctx = LoadCtx {
635        use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
636    };
637    let mut spec = materialize_root(raw, &ctx)?;
638    validate_root(&spec)?;
639    filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
640    Ok(spec)
641}
642
643pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
644    filter_spec_for_host(
645        spec,
646        &HostPlatform {
647            id: Cow::Owned(platform_id.to_string()),
648        },
649        &HostComputer { id: None },
650    );
651}
652
653pub fn filter_spec_for_host(
654    spec: &mut RootSpec,
655    platform: &HostPlatform,
656    computer: &HostComputer,
657) {
658    filter_command_map(
659        &mut spec.commands,
660        platform.id.as_ref(),
661        computer.id.as_deref(),
662    );
663}
664
665fn filter_command_map(
666    map: &mut BTreeMap<String, CommandNode>,
667    platform_id: &str,
668    computer_id: Option<&str>,
669) {
670    map.retain(|_, node| {
671        if !node_visible_for_platform(&node.os, platform_id) {
672            return false;
673        }
674        if !node_visible_for_computer(&node.computer, computer_id) {
675            return false;
676        }
677        filter_command_map(&mut node.commands, platform_id, computer_id);
678        if node.exec.is_some() || !node.aliases.is_empty() || !node.config.is_empty() {
679            return true;
680        }
681        !node.commands.is_empty()
682    });
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::{AliasesSpec, ExecSpec, LocalInclude};
689    use std::fs;
690
691    #[test]
692    fn os_filter_drops_linux_only_branch() {
693        let mut spec = RootSpec {
694            metadata: None,
695            commands: BTreeMap::from([(
696                "sys".into(),
697                CommandNode {
698                    os: vec!["linux".into()],
699                    about: "linux".into(),
700                    commands: BTreeMap::from([(
701                        "ports".into(),
702                        CommandNode {
703                            exec: Some(ExecSpec {
704                                argv: vec!["echo".into(), "x".into()],
705                                ..Default::default()
706                            }),
707                            ..Default::default()
708                        },
709                    )]),
710                    ..Default::default()
711                },
712            )]),
713        };
714        filter_spec_for_platform(&mut spec, "macos");
715        assert!(spec.commands.is_empty());
716    }
717
718    #[test]
719    fn os_filter_keeps_alias_only_node() {
720        let mut spec = RootSpec {
721            metadata: None,
722            commands: BTreeMap::from([(
723                "shortcuts".into(),
724                CommandNode {
725                    aliases: AliasesSpec {
726                        shell: BTreeMap::from([("g".into(), "git".into())]),
727                        ..Default::default()
728                    },
729                    ..Default::default()
730                },
731            )]),
732        };
733        filter_spec_for_platform(&mut spec, "linux");
734        assert!(spec.commands.contains_key("shortcuts"));
735    }
736
737    #[test]
738    fn os_filter_keeps_config_only_node() {
739        let mut spec = RootSpec {
740            metadata: None,
741            commands: BTreeMap::from([(
742                "cfg".into(),
743                CommandNode {
744                    config: crate::ConfigSpec {
745                        shell: Some(crate::ConfigShell::Inline("export X=1\n".into())),
746                        ..Default::default()
747                    },
748                    ..Default::default()
749                },
750            )]),
751        };
752        filter_spec_for_platform(&mut spec, "linux");
753        assert!(spec.commands.contains_key("cfg"));
754    }
755
756    #[test]
757    fn computer_filter_drops_other_machine_branch() {
758        let mut spec = RootSpec {
759            metadata: None,
760            commands: BTreeMap::from([(
761                "framework".into(),
762                CommandNode {
763                    computer: vec!["framework".into()],
764                    config: crate::ConfigSpec {
765                        shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
766                        ..Default::default()
767                    },
768                    ..Default::default()
769                },
770            )]),
771        };
772        filter_spec_for_host(
773            &mut spec,
774            &HostPlatform {
775                id: Cow::Borrowed("linux"),
776            },
777            &HostComputer {
778                id: Some(Cow::Borrowed("mac2025")),
779            },
780        );
781        assert!(spec.commands.is_empty());
782    }
783
784    #[test]
785    fn computer_filter_keeps_unrestricted_nodes_without_registration() {
786        let mut spec = RootSpec {
787            metadata: None,
788            commands: BTreeMap::from([
789                (
790                    "shared".into(),
791                    CommandNode {
792                        config: crate::ConfigSpec {
793                            shell: Some(crate::ConfigShell::Inline("echo all\n".into())),
794                            ..Default::default()
795                        },
796                        ..Default::default()
797                    },
798                ),
799                (
800                    "framework".into(),
801                    CommandNode {
802                        computer: vec!["framework".into()],
803                        config: crate::ConfigSpec {
804                            shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
805                            ..Default::default()
806                        },
807                        ..Default::default()
808                    },
809                ),
810            ]),
811        };
812        filter_spec_for_host(
813            &mut spec,
814            &HostPlatform {
815                id: Cow::Borrowed("linux"),
816            },
817            &HostComputer { id: None },
818        );
819        assert!(spec.commands.contains_key("shared"));
820        assert!(!spec.commands.contains_key("framework"));
821    }
822
823    #[test]
824    fn wrapper_computer_merge_onto_include() {
825        let tmp = tempfile::tempdir().unwrap();
826        let root = tmp.path();
827        fs::write(
828            root.join("leaf.yaml"),
829            "about: leaf\nconfig:\n  shell: |\n    echo leaf\n",
830        )
831        .unwrap();
832        fs::write(
833            root.join("scripts.spec.yaml"),
834            "commands:\n  leaf:\n    computer: [framework]\n    include: leaf.yaml\n",
835        )
836        .unwrap();
837
838        let spec = load_spec_from_path(
839            &root.join("scripts.spec.yaml"),
840            HostPlatform {
841                id: Cow::Borrowed("linux"),
842            },
843        )
844        .unwrap();
845        assert_eq!(spec.commands["leaf"].computer, vec!["framework"]);
846    }
847
848    #[test]
849    fn wrapper_aliases_merge_onto_include() {
850        let tmp = tempfile::tempdir().unwrap();
851        let root = tmp.path();
852        fs::write(
853            root.join("leaf.yaml"),
854            "about: leaf\naliases: [lb]\nexec:\n  argv: [\"echo\", \"x\"]\n",
855        )
856        .unwrap();
857        fs::write(
858            root.join("scripts.spec.yaml"),
859            "commands:\n  leaf:\n    include: leaf.yaml\n    aliases:\n      g: git\n",
860        )
861        .unwrap();
862
863        let spec =
864            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
865        assert_eq!(spec.commands["leaf"].aliases.names, vec!["lb"]);
866        assert_eq!(
867            spec.commands["leaf"].aliases.shell.get("g").map(String::as_str),
868            Some("git")
869        );
870    }
871
872    #[test]
873    fn nested_include_resolves_from_use_root() {
874        let tmp = tempfile::tempdir().unwrap();
875        let root = tmp.path();
876        fs::create_dir(root.join("sub")).unwrap();
877        fs::write(
878            root.join("leaf.yaml"),
879            "about: root leaf\nexec:\n  argv: [\"echo\", \"root\"]\n",
880        )
881        .unwrap();
882        fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
883        fs::write(
884            root.join("scripts.spec.yaml"),
885            "commands:\n  outer:\n    include: sub/outer.yaml\n",
886        )
887        .unwrap();
888
889        let spec =
890            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
891        assert_eq!(
892            spec.commands["outer"].exec.as_ref().unwrap().argv,
893            vec!["echo", "root"]
894        );
895    }
896
897    #[test]
898    fn include_rejects_absolute_and_parent_paths() {
899        let tmp = tempfile::tempdir().unwrap();
900        let root = tmp.path().join("tree");
901        fs::create_dir(&root).unwrap();
902        let outside = tmp.path().join("outside.yaml");
903        fs::write(
904            &outside,
905            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
906        )
907        .unwrap();
908
909        for include in [
910            outside.to_string_lossy().into_owned(),
911            "../outside.yaml".to_string(),
912        ] {
913            fs::write(
914                root.join("scripts.spec.yaml"),
915                format!("commands:\n  escaped:\n    include: {include:?}\n"),
916            )
917            .unwrap();
918            let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
919                .unwrap_err();
920            assert!(
921                err.to_string().contains("must be relative")
922                    || err.to_string().contains("must not contain `..`"),
923                "{err:#}"
924            );
925        }
926    }
927
928    #[cfg(unix)]
929    #[test]
930    fn include_rejects_symlink_escape() {
931        use std::os::unix::fs::symlink;
932
933        let tmp = tempfile::tempdir().unwrap();
934        let root = tmp.path().join("tree");
935        fs::create_dir(&root).unwrap();
936        let outside = tmp.path().join("outside.yaml");
937        fs::write(
938            &outside,
939            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
940        )
941        .unwrap();
942        symlink(&outside, root.join("linked.yaml")).unwrap();
943        fs::write(
944            root.join("scripts.spec.yaml"),
945            "commands:\n  escaped:\n    include: linked.yaml\n",
946        )
947        .unwrap();
948
949        let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
950            .unwrap_err();
951        assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
952    }
953
954    #[test]
955    fn include_ref_deserializes_local_and_remote() {
956        let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
957        assert_eq!(
958            local,
959            IncludeRef::Local(LocalInclude::from_path("sub/a.yaml"))
960        );
961        let local_map: IncludeRef = serde_yaml::from_str(
962            "path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
963        )
964        .unwrap();
965        match local_map {
966            IncludeRef::Local(l) => {
967                assert_eq!(l.path, "scripts/x.sh");
968                assert!(l.passthrough);
969                assert_eq!(l.argv, vec!["bash"]);
970                assert!(l.sha256.is_some());
971            }
972            _ => panic!("expected local"),
973        }
974        let remote: IncludeRef = serde_yaml::from_str(
975            "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
976        )
977        .unwrap();
978        assert!(remote.is_remote());
979    }
980
981    #[test]
982    fn exec_remote_requires_sha256() {
983        let node = CommandNode {
984            exec: Some(ExecSpec {
985                url: Some("https://example.com/x.sh".into()),
986                ..Default::default()
987            }),
988            ..Default::default()
989        };
990        assert!(node.validate("x").is_err());
991    }
992}