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