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, IncludeRef, Metadata, RemoteInclude,
14    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    include: Option<IncludeRef>,
88    #[serde(default)]
89    commands: BTreeMap<String, RawCommandNode>,
90    exec: Option<ExecSpec>,
91}
92
93#[derive(Clone)]
94struct LoadCtx {
95    /// Canonical root of the directory selected by `jan use`.
96    ///
97    /// Local includes are interpreted relative to this directory. Canonicalization
98    /// also prevents symlink escapes.
99    use_root: PathBuf,
100}
101
102impl LoadCtx {
103    fn read_include(&self, inc: &IncludeRef) -> Result<String> {
104        match inc {
105            IncludeRef::Local(rel) => {
106                let path = resolve_under(&self.use_root, rel)?;
107                std::fs::read_to_string(&path)
108                    .with_context(|| format!("read included spec {}", path.display()))
109            }
110            IncludeRef::Remote(r) => fetch_remote_include(r),
111        }
112    }
113
114    fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
115        match inc {
116            IncludeRef::Local(rel) => {
117                let p = resolve_under(&self.use_root, rel)?;
118                Ok(p.to_string_lossy().to_string())
119            }
120            IncludeRef::Remote(_) => Ok(inc.cycle_token()),
121        }
122    }
123}
124
125fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
126    let mut opts = FetchOpts::new();
127    if let Some(ttl) = r.ttl {
128        opts = opts.with_ttl(ttl);
129    }
130    remote::fetch_verified_text(&r.url, &r.sha256, &opts)
131        .with_context(|| format!("fetch remote include {}", r.url))
132}
133
134fn resolve_under(use_root: &Path, rel: &str) -> Result<PathBuf> {
135    let rel = rel.trim();
136    if rel.is_empty() {
137        bail!("empty include path");
138    }
139    let p = Path::new(rel);
140    if p.is_absolute() {
141        bail!("include path must be relative to the jan use root: {rel}");
142    }
143    if p.components()
144        .any(|c| matches!(c, std::path::Component::ParentDir))
145    {
146        bail!("include path must not contain `..`: {rel}");
147    }
148
149    let full = use_root.join(p);
150    let resolved = full
151        .canonicalize()
152        .with_context(|| format!("include path not found: {}", full.display()))?;
153    if !resolved.starts_with(use_root) {
154        bail!(
155            "include escapes jan use root: {} (root: {})",
156            resolved.display(),
157            use_root.display()
158        );
159    }
160    if !resolved.is_file() {
161        bail!("include is not a file: {}", resolved.display());
162    }
163    Ok(resolved)
164}
165
166fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
167    match (outer.is_empty(), inner.is_empty()) {
168        (true, true) => Ok(vec![]),
169        (true, false) => Ok(inner.to_vec()),
170        (false, true) => Ok(outer.to_vec()),
171        (false, false) => {
172            let merged: Vec<String> = outer
173                .iter()
174                .filter(|o| {
175                    let n = normalize_os_token(o);
176                    inner.iter().any(|i| normalize_os_token(i) == n)
177                })
178                .cloned()
179                .collect();
180            if merged.is_empty() {
181                bail!(
182                    "conflicting `os:` filters between include wrapper and included file \
183                     (no platform appears in both lists)"
184                );
185            }
186            Ok(merged)
187        }
188    }
189}
190
191fn overlay_about(overlay: &str, base: String) -> String {
192    let o = overlay.trim();
193    if o.is_empty() {
194        base
195    } else {
196        o.to_string()
197    }
198}
199
200fn resolve_raw_command_node(
201    raw: RawCommandNode,
202    ctx: &LoadCtx,
203    visited: &mut HashSet<String>,
204) -> Result<CommandNode> {
205    if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
206        bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
207    }
208
209    let mut raw = raw;
210    if let Some(inc) = raw.include.take() {
211        let token = ctx.visit_token(&inc)?;
212        if !visited.insert(token.clone()) {
213            bail!("include cycle detected at `{token}`");
214        }
215        let text = ctx.read_include(&inc)?;
216        let label = inc.cycle_token();
217        let inner: RawCommandNode =
218            serde_yaml::from_str(&text).with_context(|| format!("parse include `{label}`"))?;
219        let mut node = resolve_raw_command_node(inner, ctx, visited)?;
220        visited.remove(&token);
221        node.os = merge_os_filters(&raw.os, &node.os)?;
222        node.about = overlay_about(&raw.about, node.about);
223        if !raw.cron.is_empty() {
224            node.cron = raw.cron;
225        }
226        if !raw.env.is_empty() {
227            node.env.merge_from(raw.env);
228        }
229        for (k, v) in raw.inputs {
230            node.inputs.insert(k, v);
231        }
232        return Ok(node);
233    }
234
235    let mut commands = BTreeMap::new();
236    for (name, child) in raw.commands {
237        commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
238    }
239
240    Ok(CommandNode {
241        os: raw.os,
242        about: raw.about,
243        path: raw.path,
244        dependencies: raw.dependencies,
245        requires: raw.requires,
246        env: raw.env,
247        inputs: raw.inputs,
248        cron: raw.cron,
249        commands,
250        exec: raw.exec,
251    })
252}
253
254fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
255    let mut merged = BTreeMap::new();
256    for inc in &root.include {
257        let text = match inc {
258            IncludeRef::Local(rel) => {
259                let path = resolve_under(use_root, rel)?;
260                std::fs::read_to_string(&path)
261                    .with_context(|| format!("read root include {}", path.display()))?
262            }
263            IncludeRef::Remote(r) => fetch_remote_include(r)?,
264        };
265        let label = inc.cycle_token();
266        let fragment: RawRootSpec =
267            serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
268        let mut expanded = merge_root_includes(fragment, use_root)?;
269        merged.append(&mut expanded.commands);
270    }
271    merged.append(&mut root.commands);
272    root.commands = merged;
273    root.include.clear();
274    Ok(root)
275}
276
277fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
278    let mut visited = HashSet::new();
279    let mut commands = BTreeMap::new();
280    for (name, node) in raw.commands {
281        commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
282    }
283    Ok(RootSpec {
284        metadata: raw.metadata,
285        commands,
286    })
287}
288
289fn validate_root(spec: &RootSpec) -> Result<()> {
290    for (name, node) in &spec.commands {
291        node.validate(name)?;
292    }
293    Ok(())
294}
295
296pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
297    let spec_path = spec_path
298        .canonicalize()
299        .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
300    let text = std::fs::read_to_string(&spec_path)
301        .with_context(|| format!("read spec file {}", spec_path.display()))?;
302    let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
303    let use_root = spec_path
304        .parent()
305        .unwrap_or_else(|| Path::new("."))
306        .to_path_buf();
307    let raw = merge_root_includes(raw, &use_root)?;
308    let ctx = LoadCtx { use_root };
309    let mut spec = materialize_root(raw, &ctx)?;
310    // Validate structure before OS filtering: filtering can drop nested nodes (e.g. empty
311    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
312    validate_root(&spec)?;
313    filter_spec_for_platform(&mut spec, platform.id.as_ref());
314    Ok(spec)
315}
316
317/// Parse an in-memory spec. Every local `include` resolves relative to `use_root`.
318/// Pass `None` only when the document has no root `include` keys.
319pub fn load_spec_from_str(
320    raw: &str,
321    use_root: Option<&Path>,
322    platform: HostPlatform,
323) -> Result<RootSpec> {
324    let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
325    let has_local_root_include = raw.include.iter().any(|i| matches!(i, IncludeRef::Local(_)));
326    let canonical_root = use_root
327        .map(|root| {
328            root.canonicalize()
329                .with_context(|| format!("canonicalize jan use root {}", root.display()))
330        })
331        .transpose()?;
332    let raw = if let Some(root) = canonical_root.as_deref() {
333        merge_root_includes(raw, root)?
334    } else {
335        if has_local_root_include {
336            bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
337        }
338        // Remote-only root includes can merge without a local base.
339        if !raw.include.is_empty() {
340            merge_root_includes(raw, Path::new("."))?
341        } else {
342            raw
343        }
344    };
345    let ctx = LoadCtx {
346        use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
347    };
348    let mut spec = materialize_root(raw, &ctx)?;
349    validate_root(&spec)?;
350    filter_spec_for_platform(&mut spec, platform.id.as_ref());
351    Ok(spec)
352}
353
354pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
355    filter_command_map(&mut spec.commands, platform_id);
356}
357
358fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
359    map.retain(|_, node| {
360        if !node_visible_for_platform(&node.os, platform_id) {
361            return false;
362        }
363        filter_command_map(&mut node.commands, platform_id);
364        if node.exec.is_some() {
365            return true;
366        }
367        !node.commands.is_empty()
368    });
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::ExecSpec;
375    use std::fs;
376
377    #[test]
378    fn os_filter_drops_linux_only_branch() {
379        let mut spec = RootSpec {
380            metadata: None,
381            commands: BTreeMap::from([(
382                "sys".into(),
383                CommandNode {
384                    os: vec!["linux".into()],
385                    about: "linux".into(),
386                    commands: BTreeMap::from([(
387                        "ports".into(),
388                        CommandNode {
389                            exec: Some(ExecSpec {
390                                argv: vec!["echo".into(), "x".into()],
391                                passthrough: false,
392                                url: None,
393                                sha256: None,
394                                ttl: None,
395                            }),
396                            ..Default::default()
397                        },
398                    )]),
399                    ..Default::default()
400                },
401            )]),
402        };
403        filter_spec_for_platform(&mut spec, "macos");
404        assert!(spec.commands.is_empty());
405    }
406
407    #[test]
408    fn nested_include_resolves_from_use_root() {
409        let tmp = tempfile::tempdir().unwrap();
410        let root = tmp.path();
411        fs::create_dir(root.join("sub")).unwrap();
412        fs::write(
413            root.join("leaf.yaml"),
414            "about: root leaf\nexec:\n  argv: [\"echo\", \"root\"]\n",
415        )
416        .unwrap();
417        fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
418        fs::write(
419            root.join("scripts.spec.yaml"),
420            "commands:\n  outer:\n    include: sub/outer.yaml\n",
421        )
422        .unwrap();
423
424        let spec =
425            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
426        assert_eq!(
427            spec.commands["outer"].exec.as_ref().unwrap().argv,
428            vec!["echo", "root"]
429        );
430    }
431
432    #[test]
433    fn include_rejects_absolute_and_parent_paths() {
434        let tmp = tempfile::tempdir().unwrap();
435        let root = tmp.path().join("tree");
436        fs::create_dir(&root).unwrap();
437        let outside = tmp.path().join("outside.yaml");
438        fs::write(
439            &outside,
440            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
441        )
442        .unwrap();
443
444        for include in [
445            outside.to_string_lossy().into_owned(),
446            "../outside.yaml".to_string(),
447        ] {
448            fs::write(
449                root.join("scripts.spec.yaml"),
450                format!("commands:\n  escaped:\n    include: {include:?}\n"),
451            )
452            .unwrap();
453            let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
454                .unwrap_err();
455            assert!(
456                err.to_string().contains("must be relative")
457                    || err.to_string().contains("must not contain `..`"),
458                "{err:#}"
459            );
460        }
461    }
462
463    #[cfg(unix)]
464    #[test]
465    fn include_rejects_symlink_escape() {
466        use std::os::unix::fs::symlink;
467
468        let tmp = tempfile::tempdir().unwrap();
469        let root = tmp.path().join("tree");
470        fs::create_dir(&root).unwrap();
471        let outside = tmp.path().join("outside.yaml");
472        fs::write(
473            &outside,
474            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
475        )
476        .unwrap();
477        symlink(&outside, root.join("linked.yaml")).unwrap();
478        fs::write(
479            root.join("scripts.spec.yaml"),
480            "commands:\n  escaped:\n    include: linked.yaml\n",
481        )
482        .unwrap();
483
484        let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
485            .unwrap_err();
486        assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
487    }
488
489    #[test]
490    fn include_ref_deserializes_local_and_remote() {
491        let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
492        assert_eq!(local, IncludeRef::Local("sub/a.yaml".into()));
493        let remote: IncludeRef = serde_yaml::from_str(
494            "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
495        )
496        .unwrap();
497        assert!(remote.is_remote());
498    }
499
500    #[test]
501    fn exec_remote_requires_sha256() {
502        let node = CommandNode {
503            exec: Some(ExecSpec {
504                argv: vec![],
505                passthrough: false,
506                url: Some("https://example.com/x.sh".into()),
507                sha256: None,
508                ttl: None,
509            }),
510            ..Default::default()
511        };
512        assert!(node.validate("x").is_err());
513    }
514}