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::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#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct HostPlatform {
20 pub id: Cow<'static, str>,
22}
23
24impl HostPlatform {
25 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 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_root(&spec)?;
313 filter_spec_for_platform(&mut spec, platform.id.as_ref());
314 Ok(spec)
315}
316
317pub 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 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}