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, ExecSpec, Metadata, RootSpec};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct HostPlatform {
15 pub id: Cow<'static, str>,
17}
18
19impl HostPlatform {
20 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 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_root(&spec)?;
273 filter_spec_for_platform(&mut spec, platform.id.as_ref());
274 Ok(spec)
275}
276
277pub 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}