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::{deserialize_string_or_seq, CommandNode, EnvSpec, ExecSpec, Metadata, RootSpec};
11use crate::inputs::InputDef;
12
13/// Which platform string to use when filtering `os:` lists on commands.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct HostPlatform {
16    /// Normalized id: `linux`, `macos`, `windows`, or another `std::env::consts::OS` value.
17    pub id: Cow<'static, str>,
18}
19
20impl HostPlatform {
21    /// Resolve the process host, honoring `JAN_OS` when set (for tests and overrides).
22    pub fn detect() -> Self {
23        if let Ok(v) = std::env::var("JAN_OS") {
24            let s = v.trim().to_ascii_lowercase();
25            if !s.is_empty() {
26                return Self::from_normalized(&s);
27            }
28        }
29        Self::from_normalized(std::env::consts::OS)
30    }
31
32    fn from_normalized(os: &str) -> Self {
33        let id = match os {
34            "darwin" | "macos" => Cow::Borrowed("macos"),
35            "linux" => Cow::Borrowed("linux"),
36            "windows" => Cow::Borrowed("windows"),
37            other => Cow::Owned(other.to_string()),
38        };
39        Self { id }
40    }
41}
42
43fn normalize_os_token(tok: &str) -> String {
44    match tok.trim().to_ascii_lowercase().as_str() {
45        "darwin" => "macos".to_string(),
46        s => s.to_string(),
47    }
48}
49
50fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
51    if os_list.is_empty() {
52        return true;
53    }
54    os_list.iter().any(|o| normalize_os_token(o) == platform)
55}
56
57#[derive(Debug, Deserialize)]
58struct RawRootSpec {
59    metadata: Option<Metadata>,
60    #[serde(default)]
61    include: Vec<String>,
62    #[serde(default)]
63    commands: BTreeMap<String, RawCommandNode>,
64}
65
66#[derive(Debug, Deserialize)]
67struct RawCommandNode {
68    #[serde(default)]
69    os: Vec<String>,
70    #[serde(default)]
71    about: String,
72    path: Option<String>,
73    #[serde(default)]
74    dependencies: Vec<String>,
75    #[serde(default)]
76    requires: Vec<String>,
77    #[serde(default)]
78    env: EnvSpec,
79    #[serde(default)]
80    inputs: BTreeMap<String, InputDef>,
81    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
82    cron: Vec<String>,
83    include: Option<String>,
84    #[serde(default)]
85    commands: BTreeMap<String, RawCommandNode>,
86    exec: Option<ExecSpec>,
87}
88
89#[derive(Clone)]
90struct LoadCtx {
91    /// Canonical root of the directory selected by `jan use`.
92    ///
93    /// Every include is interpreted relative to this directory, including includes
94    /// found in nested YAML files. Canonicalization also prevents symlink escapes.
95    use_root: PathBuf,
96}
97
98impl LoadCtx {
99    fn read_include(&self, rel: &str) -> Result<String> {
100        let path = resolve_under(&self.use_root, rel)?;
101        std::fs::read_to_string(&path)
102            .with_context(|| format!("read included spec {}", path.display()))
103    }
104
105    fn visit_token(&self, rel: &str) -> Result<String> {
106        let p = resolve_under(&self.use_root, rel)?;
107        Ok(p.to_string_lossy().to_string())
108    }
109}
110
111fn resolve_under(use_root: &Path, rel: &str) -> Result<PathBuf> {
112    let rel = rel.trim();
113    if rel.is_empty() {
114        bail!("empty include path");
115    }
116    let p = Path::new(rel);
117    if p.is_absolute() {
118        bail!("include path must be relative to the jan use root: {rel}");
119    }
120    if p.components()
121        .any(|c| matches!(c, std::path::Component::ParentDir))
122    {
123        bail!("include path must not contain `..`: {rel}");
124    }
125
126    let full = use_root.join(p);
127    let resolved = full
128        .canonicalize()
129        .with_context(|| format!("include path not found: {}", full.display()))?;
130    if !resolved.starts_with(use_root) {
131        bail!(
132            "include escapes jan use root: {} (root: {})",
133            resolved.display(),
134            use_root.display()
135        );
136    }
137    if !resolved.is_file() {
138        bail!("include is not a file: {}", resolved.display());
139    }
140    Ok(resolved)
141}
142
143fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
144    match (outer.is_empty(), inner.is_empty()) {
145        (true, true) => Ok(vec![]),
146        (true, false) => Ok(inner.to_vec()),
147        (false, true) => Ok(outer.to_vec()),
148        (false, false) => {
149            let merged: Vec<String> = outer
150                .iter()
151                .filter(|o| {
152                    let n = normalize_os_token(o);
153                    inner.iter().any(|i| normalize_os_token(i) == n)
154                })
155                .cloned()
156                .collect();
157            if merged.is_empty() {
158                bail!(
159                    "conflicting `os:` filters between include wrapper and included file \
160                     (no platform appears in both lists)"
161                );
162            }
163            Ok(merged)
164        }
165    }
166}
167
168fn overlay_about(overlay: &str, base: String) -> String {
169    let o = overlay.trim();
170    if o.is_empty() {
171        base
172    } else {
173        o.to_string()
174    }
175}
176
177fn resolve_raw_command_node(
178    raw: RawCommandNode,
179    ctx: &LoadCtx,
180    visited: &mut HashSet<String>,
181) -> Result<CommandNode> {
182    if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
183        bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
184    }
185
186    let mut raw = raw;
187    if let Some(rel) = raw.include.take() {
188        let token = ctx.visit_token(&rel)?;
189        if !visited.insert(token.clone()) {
190            bail!("include cycle detected at `{token}`");
191        }
192        let text = ctx.read_include(&rel)?;
193        let inner: RawCommandNode =
194            serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
195        let mut node = resolve_raw_command_node(inner, ctx, visited)?;
196        visited.remove(&token);
197        node.os = merge_os_filters(&raw.os, &node.os)?;
198        node.about = overlay_about(&raw.about, node.about);
199        if !raw.cron.is_empty() {
200            node.cron = raw.cron;
201        }
202        if !raw.env.is_empty() {
203            node.env.merge_from(raw.env);
204        }
205        for (k, v) in raw.inputs {
206            node.inputs.insert(k, v);
207        }
208        return Ok(node);
209    }
210
211    let mut commands = BTreeMap::new();
212    for (name, child) in raw.commands {
213        commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
214    }
215
216    Ok(CommandNode {
217        os: raw.os,
218        about: raw.about,
219        path: raw.path,
220        dependencies: raw.dependencies,
221        requires: raw.requires,
222        env: raw.env,
223        inputs: raw.inputs,
224        cron: raw.cron,
225        commands,
226        exec: raw.exec,
227    })
228}
229
230fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
231    let mut merged = BTreeMap::new();
232    for inc in &root.include {
233        let path = resolve_under(use_root, inc)?;
234        let text = std::fs::read_to_string(&path)
235            .with_context(|| format!("read root include {}", path.display()))?;
236        let fragment: RawRootSpec =
237            serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
238        let mut expanded = merge_root_includes(fragment, use_root)?;
239        merged.append(&mut expanded.commands);
240    }
241    merged.append(&mut root.commands);
242    root.commands = merged;
243    root.include.clear();
244    Ok(root)
245}
246
247fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
248    let mut visited = HashSet::new();
249    let mut commands = BTreeMap::new();
250    for (name, node) in raw.commands {
251        commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
252    }
253    Ok(RootSpec {
254        metadata: raw.metadata,
255        commands,
256    })
257}
258
259fn validate_root(spec: &RootSpec) -> Result<()> {
260    for (name, node) in &spec.commands {
261        node.validate(name)?;
262    }
263    Ok(())
264}
265
266pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
267    let spec_path = spec_path
268        .canonicalize()
269        .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
270    let text = std::fs::read_to_string(&spec_path)
271        .with_context(|| format!("read spec file {}", spec_path.display()))?;
272    let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
273    let use_root = spec_path
274        .parent()
275        .unwrap_or_else(|| Path::new("."))
276        .to_path_buf();
277    let raw = merge_root_includes(raw, &use_root)?;
278    let ctx = LoadCtx { use_root };
279    let mut spec = materialize_root(raw, &ctx)?;
280    // Validate structure before OS filtering: filtering can drop nested nodes (e.g. empty
281    // placeholders) and would otherwise hide invalid `exec` + `commands` combinations.
282    validate_root(&spec)?;
283    filter_spec_for_platform(&mut spec, platform.id.as_ref());
284    Ok(spec)
285}
286
287/// Parse an in-memory spec. Every `include` resolves relative to `use_root`.
288/// Pass `None` only when the document has no root `include` keys.
289pub fn load_spec_from_str(
290    raw: &str,
291    use_root: Option<&Path>,
292    platform: HostPlatform,
293) -> Result<RootSpec> {
294    let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
295    let canonical_root = use_root
296        .map(|root| {
297            root.canonicalize()
298                .with_context(|| format!("canonicalize jan use root {}", root.display()))
299        })
300        .transpose()?;
301    let raw = if let Some(root) = canonical_root.as_deref() {
302        merge_root_includes(raw, root)?
303    } else {
304        if !raw.include.is_empty() {
305            bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
306        }
307        raw
308    };
309    let ctx = LoadCtx {
310        use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
311    };
312    let mut spec = materialize_root(raw, &ctx)?;
313    validate_root(&spec)?;
314    filter_spec_for_platform(&mut spec, platform.id.as_ref());
315    Ok(spec)
316}
317
318pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
319    filter_command_map(&mut spec.commands, platform_id);
320}
321
322fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
323    map.retain(|_, node| {
324        if !node_visible_for_platform(&node.os, platform_id) {
325            return false;
326        }
327        filter_command_map(&mut node.commands, platform_id);
328        if node.exec.is_some() {
329            return true;
330        }
331        !node.commands.is_empty()
332    });
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::ExecSpec;
339    use std::fs;
340
341    #[test]
342    fn os_filter_drops_linux_only_branch() {
343        let mut spec = RootSpec {
344            metadata: None,
345            commands: BTreeMap::from([(
346                "sys".into(),
347                CommandNode {
348                    os: vec!["linux".into()],
349                    about: "linux".into(),
350                    commands: BTreeMap::from([(
351                        "ports".into(),
352                        CommandNode {
353                            exec: Some(ExecSpec {
354                                argv: vec!["echo".into(), "x".into()],
355                                passthrough: false,
356                            }),
357                            ..Default::default()
358                        },
359                    )]),
360                    ..Default::default()
361                },
362            )]),
363        };
364        filter_spec_for_platform(&mut spec, "macos");
365        assert!(spec.commands.is_empty());
366    }
367
368    #[test]
369    fn nested_include_resolves_from_use_root() {
370        let tmp = tempfile::tempdir().unwrap();
371        let root = tmp.path();
372        fs::create_dir(root.join("sub")).unwrap();
373        fs::write(
374            root.join("leaf.yaml"),
375            "about: root leaf\nexec:\n  argv: [\"echo\", \"root\"]\n",
376        )
377        .unwrap();
378        fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
379        fs::write(
380            root.join("scripts.spec.yaml"),
381            "commands:\n  outer:\n    include: sub/outer.yaml\n",
382        )
383        .unwrap();
384
385        let spec =
386            load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
387        assert_eq!(
388            spec.commands["outer"].exec.as_ref().unwrap().argv,
389            vec!["echo", "root"]
390        );
391    }
392
393    #[test]
394    fn include_rejects_absolute_and_parent_paths() {
395        let tmp = tempfile::tempdir().unwrap();
396        let root = tmp.path().join("tree");
397        fs::create_dir(&root).unwrap();
398        let outside = tmp.path().join("outside.yaml");
399        fs::write(
400            &outside,
401            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
402        )
403        .unwrap();
404
405        for include in [
406            outside.to_string_lossy().into_owned(),
407            "../outside.yaml".to_string(),
408        ] {
409            fs::write(
410                root.join("scripts.spec.yaml"),
411                format!("commands:\n  escaped:\n    include: {include:?}\n"),
412            )
413            .unwrap();
414            let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
415                .unwrap_err();
416            assert!(
417                err.to_string().contains("must be relative")
418                    || err.to_string().contains("must not contain `..`"),
419                "{err:#}"
420            );
421        }
422    }
423
424    #[cfg(unix)]
425    #[test]
426    fn include_rejects_symlink_escape() {
427        use std::os::unix::fs::symlink;
428
429        let tmp = tempfile::tempdir().unwrap();
430        let root = tmp.path().join("tree");
431        fs::create_dir(&root).unwrap();
432        let outside = tmp.path().join("outside.yaml");
433        fs::write(
434            &outside,
435            "about: outside\nexec:\n  argv: [\"echo\", \"outside\"]\n",
436        )
437        .unwrap();
438        symlink(&outside, root.join("linked.yaml")).unwrap();
439        fs::write(
440            root.join("scripts.spec.yaml"),
441            "commands:\n  escaped:\n    include: linked.yaml\n",
442        )
443        .unwrap();
444
445        let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
446            .unwrap_err();
447        assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
448    }
449}