1use 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#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct HostPlatform {
16 pub id: Cow<'static, str>,
18}
19
20impl HostPlatform {
21 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 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_root(&spec)?;
283 filter_spec_for_platform(&mut spec, platform.id.as_ref());
284 Ok(spec)
285}
286
287pub 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}