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_host(spec: &mut RootSpec, platform: &HostPlatform, computer: &HostComputer) {
644    filter_command_map(
645        &mut spec.commands,
646        platform.id.as_ref(),
647        computer.id.as_deref(),
648    );
649}
650
651fn filter_command_map(
652    map: &mut BTreeMap<String, CommandNode>,
653    platform_id: &str,
654    computer_id: Option<&str>,
655) {
656    map.retain(|_, node| {
657        if !node_visible_for_platform(&node.os, platform_id) {
658            return false;
659        }
660        if !node_visible_for_computer(&node.computer, computer_id) {
661            return false;
662        }
663        filter_command_map(&mut node.commands, platform_id, computer_id);
664        if node.exec.is_some() || !node.aliases.is_empty() || !node.config.is_empty() {
665            return true;
666        }
667        !node.commands.is_empty()
668    });
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::{AliasesSpec, ExecSpec, LocalInclude};
675    use std::fs;
676
677    /// Platform-only filter (no computer id) — the host filter takes both.
678    fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
679        filter_spec_for_host(
680            spec,
681            &HostPlatform {
682                id: Cow::Owned(platform_id.to_string()),
683            },
684            &HostComputer { id: None },
685        );
686    }
687
688    #[test]
689    fn os_filter_drops_linux_only_branch() {
690        let mut spec = RootSpec {
691            metadata: None,
692            commands: BTreeMap::from([(
693                "sys".into(),
694                CommandNode {
695                    os: vec!["linux".into()],
696                    about: "linux".into(),
697                    commands: BTreeMap::from([(
698                        "ports".into(),
699                        CommandNode {
700                            exec: Some(ExecSpec {
701                                argv: vec!["echo".into(), "x".into()],
702                                ..Default::default()
703                            }),
704                            ..Default::default()
705                        },
706                    )]),
707                    ..Default::default()
708                },
709            )]),
710        };
711        filter_spec_for_platform(&mut spec, "macos");
712        assert!(spec.commands.is_empty());
713    }
714
715    #[test]
716    fn os_filter_keeps_alias_only_node() {
717        let mut spec = RootSpec {
718            metadata: None,
719            commands: BTreeMap::from([(
720                "shortcuts".into(),
721                CommandNode {
722                    aliases: AliasesSpec {
723                        shell: BTreeMap::from([("g".into(), "git".into())]),
724                        ..Default::default()
725                    },
726                    ..Default::default()
727                },
728            )]),
729        };
730        filter_spec_for_platform(&mut spec, "linux");
731        assert!(spec.commands.contains_key("shortcuts"));
732    }
733
734    #[test]
735    fn os_filter_keeps_config_only_node() {
736        let mut spec = RootSpec {
737            metadata: None,
738            commands: BTreeMap::from([(
739                "cfg".into(),
740                CommandNode {
741                    config: crate::ConfigSpec {
742                        shell: Some(crate::ConfigShell::Inline("export X=1\n".into())),
743                        ..Default::default()
744                    },
745                    ..Default::default()
746                },
747            )]),
748        };
749        filter_spec_for_platform(&mut spec, "linux");
750        assert!(spec.commands.contains_key("cfg"));
751    }
752
753    #[test]
754    fn computer_filter_drops_other_machine_branch() {
755        let mut spec = RootSpec {
756            metadata: None,
757            commands: BTreeMap::from([(
758                "framework".into(),
759                CommandNode {
760                    computer: vec!["framework".into()],
761                    config: crate::ConfigSpec {
762                        shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
763                        ..Default::default()
764                    },
765                    ..Default::default()
766                },
767            )]),
768        };
769        filter_spec_for_host(
770            &mut spec,
771            &HostPlatform {
772                id: Cow::Borrowed("linux"),
773            },
774            &HostComputer {
775                id: Some(Cow::Borrowed("mac2025")),
776            },
777        );
778        assert!(spec.commands.is_empty());
779    }
780
781    #[test]
782    fn computer_filter_keeps_unrestricted_nodes_without_registration() {
783        let mut spec = RootSpec {
784            metadata: None,
785            commands: BTreeMap::from([
786                (
787                    "shared".into(),
788                    CommandNode {
789                        config: crate::ConfigSpec {
790                            shell: Some(crate::ConfigShell::Inline("echo all\n".into())),
791                            ..Default::default()
792                        },
793                        ..Default::default()
794                    },
795                ),
796                (
797                    "framework".into(),
798                    CommandNode {
799                        computer: vec!["framework".into()],
800                        config: crate::ConfigSpec {
801                            shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
802                            ..Default::default()
803                        },
804                        ..Default::default()
805                    },
806                ),
807            ]),
808        };
809        filter_spec_for_host(
810            &mut spec,
811            &HostPlatform {
812                id: Cow::Borrowed("linux"),
813            },
814            &HostComputer { id: None },
815        );
816        assert!(spec.commands.contains_key("shared"));
817        assert!(!spec.commands.contains_key("framework"));
818    }
819
820    #[test]
821    fn wrapper_computer_merge_onto_include() {
822        let tmp = tempfile::tempdir().unwrap();
823        let root = tmp.path();
824        fs::write(
825            root.join("leaf.yaml"),
826            "about: leaf\nconfig:\n  shell: |\n    echo leaf\n",
827        )
828        .unwrap();
829        fs::write(
830            root.join("scripts.spec.yaml"),
831            "commands:\n  leaf:\n    computer: [framework]\n    include: leaf.yaml\n",
832        )
833        .unwrap();
834
835        let spec = load_spec_from_path(
836            &root.join("scripts.spec.yaml"),
837            HostPlatform {
838                id: Cow::Borrowed("linux"),
839            },
840        )
841        .unwrap();
842        assert_eq!(spec.commands["leaf"].computer, vec!["framework"]);
843    }
844
845    #[test]
846    fn wrapper_aliases_merge_onto_include() {
847        let tmp = tempfile::tempdir().unwrap();
848        let root = tmp.path();
849        fs::write(
850            root.join("leaf.yaml"),
851            "about: leaf\naliases: [lb]\nexec:\n  argv: [\"echo\", \"x\"]\n",
852        )
853        .unwrap();
854        fs::write(
855            root.join("scripts.spec.yaml"),
856            "commands:\n  leaf:\n    include: leaf.yaml\n    aliases:\n      g: git\n",
857        )
858        .unwrap();
859
860        let spec =
861            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
862        assert_eq!(spec.commands["leaf"].aliases.names, vec!["lb"]);
863        assert_eq!(
864            spec.commands["leaf"]
865                .aliases
866                .shell
867                .get("g")
868                .map(String::as_str),
869            Some("git")
870        );
871    }
872
873    #[test]
874    fn nested_include_resolves_from_use_root() {
875        let tmp = tempfile::tempdir().unwrap();
876        let root = tmp.path();
877        fs::create_dir(root.join("sub")).unwrap();
878        fs::write(
879            root.join("leaf.yaml"),
880            "about: root leaf\nexec:\n  argv: [\"echo\", \"root\"]\n",
881        )
882        .unwrap();
883        fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
884        fs::write(
885            root.join("scripts.spec.yaml"),
886            "commands:\n  outer:\n    include: sub/outer.yaml\n",
887        )
888        .unwrap();
889
890        let spec =
891            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
892        assert_eq!(
893            spec.commands["outer"].exec.as_ref().unwrap().argv,
894            vec!["echo", "root"]
895        );
896    }
897
898    #[test]
899    fn include_rejects_absolute_and_parent_paths() {
900        let tmp = tempfile::tempdir().unwrap();
901        let root = tmp.path().join("tree");
902        fs::create_dir(&root).unwrap();
903        let outside = tmp.path().join("outside.yaml");
904        fs::write(
905            &outside,
906            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
907        )
908        .unwrap();
909
910        for include in [
911            outside.to_string_lossy().into_owned(),
912            "../outside.yaml".to_string(),
913        ] {
914            fs::write(
915                root.join("scripts.spec.yaml"),
916                format!("commands:\n  escaped:\n    include: {include:?}\n"),
917            )
918            .unwrap();
919            let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
920                .unwrap_err();
921            assert!(
922                err.to_string().contains("must be relative")
923                    || err.to_string().contains("must not contain `..`"),
924                "{err:#}"
925            );
926        }
927    }
928
929    #[cfg(unix)]
930    #[test]
931    fn include_rejects_symlink_escape() {
932        use std::os::unix::fs::symlink;
933
934        let tmp = tempfile::tempdir().unwrap();
935        let root = tmp.path().join("tree");
936        fs::create_dir(&root).unwrap();
937        let outside = tmp.path().join("outside.yaml");
938        fs::write(
939            &outside,
940            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
941        )
942        .unwrap();
943        symlink(&outside, root.join("linked.yaml")).unwrap();
944        fs::write(
945            root.join("scripts.spec.yaml"),
946            "commands:\n  escaped:\n    include: linked.yaml\n",
947        )
948        .unwrap();
949
950        let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
951            .unwrap_err();
952        assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
953    }
954
955    #[test]
956    fn include_ref_deserializes_local_and_remote() {
957        let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
958        assert_eq!(
959            local,
960            IncludeRef::Local(LocalInclude::from_path("sub/a.yaml"))
961        );
962        let local_map: IncludeRef = serde_yaml::from_str(
963            "path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
964        )
965        .unwrap();
966        match local_map {
967            IncludeRef::Local(l) => {
968                assert_eq!(l.path, "scripts/x.sh");
969                assert!(l.passthrough);
970                assert_eq!(l.argv, vec!["bash"]);
971                assert!(l.sha256.is_some());
972            }
973            _ => panic!("expected local"),
974        }
975        let remote: IncludeRef = serde_yaml::from_str(
976            "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
977        )
978        .unwrap();
979        assert!(remote.is_remote());
980    }
981
982    #[test]
983    fn exec_remote_requires_sha256() {
984        let node = CommandNode {
985            exec: Some(ExecSpec {
986                url: Some("https://example.com/x.sh".into()),
987                ..Default::default()
988            }),
989            ..Default::default()
990        };
991        assert!(node.validate("x").is_err());
992    }
993}