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