Skip to main content

jan_cli/
spec_load.rs

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