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, IncludeLink, IncludeLinkKind,
14 IncludeRef, LocalInclude, Metadata, RemoteInclude, 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 #[serde(default)]
88 packages: crate::PackagesSpec,
89 include: Option<IncludeRef>,
90 #[serde(default)]
91 commands: BTreeMap<String, RawCommandNode>,
92 exec: Option<ExecSpec>,
93}
94
95#[derive(Clone)]
96struct LoadCtx {
97 use_root: PathBuf,
102}
103
104impl LoadCtx {
105 fn read_local_bytes(&self, local: &LocalInclude) -> Result<(PathBuf, Vec<u8>)> {
106 let path = resolve_under_use_root(&self.use_root, &local.path)?;
107 let bytes = std::fs::read(&path)
108 .with_context(|| format!("read included file {}", path.display()))?;
109 if let Some(hash) = local.sha256.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
110 let expected = remote::normalize_sha256(hash)?;
111 let got = remote::sha256_hex(&bytes);
112 if got != expected {
113 bail!(
114 "SHA256 mismatch for include `{}`: expected {expected}, got {got}",
115 local.path
116 );
117 }
118 }
119 Ok((path, bytes))
120 }
121
122 fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
123 match inc {
124 IncludeRef::Local(local) => {
125 let p = resolve_under_use_root(&self.use_root, &local.path)?;
126 Ok(p.to_string_lossy().to_string())
127 }
128 IncludeRef::Remote(_) => Ok(inc.cycle_token()),
129 }
130 }
131}
132
133fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
134 let mut opts = FetchOpts::new();
135 if let Some(ttl) = r.ttl {
136 opts = opts.with_ttl(ttl);
137 }
138 remote::fetch_verified_text(&r.url, &r.sha256, &opts)
139 .with_context(|| format!("fetch remote include {}", r.url))
140}
141
142pub(crate) fn resolve_under_use_root_any(use_root: &Path, rel: &str) -> Result<PathBuf> {
144 let rel = rel.trim();
145 if rel.is_empty() {
146 bail!("empty path");
147 }
148 let p = Path::new(rel);
149 if p.is_absolute() {
150 bail!("path must be relative to the jan use root: {rel}");
151 }
152 if p.components()
153 .any(|c| matches!(c, std::path::Component::ParentDir))
154 {
155 bail!("path must not contain `..`: {rel}");
156 }
157
158 let full = use_root.join(p);
159 let resolved = full
160 .canonicalize()
161 .with_context(|| format!("path not found: {}", full.display()))?;
162 if !resolved.starts_with(use_root) {
163 bail!(
164 "path escapes jan use root: {} (root: {})",
165 resolved.display(),
166 use_root.display()
167 );
168 }
169 Ok(resolved)
170}
171
172pub(crate) fn resolve_under_use_root(use_root: &Path, rel: &str) -> Result<PathBuf> {
174 let resolved = resolve_under_use_root_any(use_root, rel)?;
175 if !resolved.is_file() {
176 bail!("include is not a file: {}", resolved.display());
177 }
178 Ok(resolved)
179}
180
181fn include_link_for(inc: &IncludeRef, kind: IncludeLinkKind) -> IncludeLink {
182 match inc {
183 IncludeRef::Local(local) => IncludeLink {
184 kind,
185 path: Some(local.path.clone()),
186 url: None,
187 sha256: local.sha256.clone(),
188 },
189 IncludeRef::Remote(r) => IncludeLink {
190 kind,
191 path: None,
192 url: Some(r.url.clone()),
193 sha256: Some(r.sha256.clone()),
194 },
195 }
196}
197
198fn apply_wrapper_overlays(raw: &mut RawCommandNode, node: &mut CommandNode) -> Result<()> {
199 node.os = merge_os_filters(&raw.os, &node.os)?;
200 node.about = overlay_about(&raw.about, std::mem::take(&mut node.about));
201 if !raw.cron.is_empty() {
202 node.cron = std::mem::take(&mut raw.cron);
203 }
204 if !raw.env.is_empty() {
205 node.env.merge_from(std::mem::take(&mut raw.env));
206 }
207 for (k, v) in std::mem::take(&mut raw.inputs) {
208 node.inputs.insert(k, v);
209 }
210 if raw.path.is_some() {
211 node.path = raw.path.take();
212 }
213 if !raw.dependencies.is_empty() {
214 node.dependencies = std::mem::take(&mut raw.dependencies);
215 }
216 if !raw.requires.is_empty() {
217 node.requires = std::mem::take(&mut raw.requires);
218 }
219 if !raw.packages.is_empty() {
220 node.packages.merge_from(std::mem::take(&mut raw.packages));
221 }
222 Ok(())
223}
224
225fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
226 match (outer.is_empty(), inner.is_empty()) {
227 (true, true) => Ok(vec![]),
228 (true, false) => Ok(inner.to_vec()),
229 (false, true) => Ok(outer.to_vec()),
230 (false, false) => {
231 let merged: Vec<String> = outer
232 .iter()
233 .filter(|o| {
234 let n = normalize_os_token(o);
235 inner.iter().any(|i| normalize_os_token(i) == n)
236 })
237 .cloned()
238 .collect();
239 if merged.is_empty() {
240 bail!(
241 "conflicting `os:` filters between include wrapper and included file \
242 (no platform appears in both lists)"
243 );
244 }
245 Ok(merged)
246 }
247 }
248}
249
250fn overlay_about(overlay: &str, base: String) -> String {
251 let o = overlay.trim();
252 if o.is_empty() {
253 base
254 } else {
255 o.to_string()
256 }
257}
258
259fn resolve_raw_command_node(
260 raw: RawCommandNode,
261 ctx: &LoadCtx,
262 visited: &mut HashSet<String>,
263) -> Result<CommandNode> {
264 if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
265 bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
266 }
267
268 let mut raw = raw;
269 if let Some(inc) = raw.include.take() {
270 let token = ctx.visit_token(&inc)?;
271 if !visited.insert(token.clone()) {
272 bail!("include cycle detected at `{token}`");
273 }
274 let label = inc.cycle_token();
275 let mut node = match &inc {
276 IncludeRef::Local(local) if !local.is_yaml() => {
277 if local.path.trim().is_empty() {
278 bail!("empty include path");
279 }
280 let (_path, _bytes) = ctx.read_local_bytes(local)?;
281 CommandNode {
282 about: String::new(),
283 exec: Some(ExecSpec {
284 argv: local.argv.clone(),
285 passthrough: local.passthrough,
286 file: Some(local.path.clone()),
287 sha256: local.sha256.clone(),
288 url: None,
289 ttl: None,
290 }),
291 source: Some(include_link_for(&inc, IncludeLinkKind::Script)),
292 ..Default::default()
293 }
294 }
295 IncludeRef::Local(local) => {
296 if !local.argv.is_empty() || local.passthrough {
297 bail!(
298 "include `{label}`: `argv` / `passthrough` are only valid for script files, not YAML"
299 );
300 }
301 let (_path, bytes) = ctx.read_local_bytes(local)?;
302 let text = String::from_utf8(bytes)
303 .with_context(|| format!("include `{label}` is not valid UTF-8"))?;
304 let inner: RawCommandNode = serde_yaml::from_str(&text)
305 .with_context(|| format!("parse include `{label}`"))?;
306 let mut node = resolve_raw_command_node(inner, ctx, visited)?;
307 node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
308 node
309 }
310 IncludeRef::Remote(r) => {
311 let text = fetch_remote_include(r)?;
312 let inner: RawCommandNode = serde_yaml::from_str(&text)
313 .with_context(|| format!("parse include `{label}`"))?;
314 let mut node = resolve_raw_command_node(inner, ctx, visited)?;
315 node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
316 node
317 }
318 };
319 visited.remove(&token);
320 apply_wrapper_overlays(&mut raw, &mut node)?;
321 return Ok(node);
322 }
323
324 let mut commands = BTreeMap::new();
325 for (name, child) in raw.commands {
326 commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
327 }
328
329 Ok(CommandNode {
330 os: raw.os,
331 about: raw.about,
332 path: raw.path,
333 dependencies: raw.dependencies,
334 requires: raw.requires,
335 env: raw.env,
336 inputs: raw.inputs,
337 cron: raw.cron,
338 packages: raw.packages,
339 commands,
340 exec: raw.exec,
341 source: None,
342 })
343}
344
345fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
346 let mut merged = BTreeMap::new();
347 for inc in &root.include {
348 let text = match inc {
349 IncludeRef::Local(local) => {
350 if !local.is_yaml() {
351 bail!(
352 "root-level include `{}` must be a YAML file (.yaml / .yml)",
353 local.path
354 );
355 }
356 if !local.argv.is_empty() || local.passthrough {
357 bail!(
358 "root-level include `{}`: `argv` / `passthrough` are not valid here",
359 local.path
360 );
361 }
362 let path = resolve_under_use_root(use_root, &local.path)?;
363 let bytes = std::fs::read(&path)
364 .with_context(|| format!("read root include {}", path.display()))?;
365 if let Some(hash) = local.sha256.as_deref().map(str::trim).filter(|s| !s.is_empty())
366 {
367 let expected = remote::normalize_sha256(hash)?;
368 let got = remote::sha256_hex(&bytes);
369 if got != expected {
370 bail!(
371 "SHA256 mismatch for root include `{}`: expected {expected}, got {got}",
372 local.path
373 );
374 }
375 }
376 String::from_utf8(bytes).with_context(|| {
377 format!("root include {} is not valid UTF-8", path.display())
378 })?
379 }
380 IncludeRef::Remote(r) => fetch_remote_include(r)?,
381 };
382 let label = inc.cycle_token();
383 let fragment: RawRootSpec =
384 serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
385 let mut expanded = merge_root_includes(fragment, use_root)?;
386 merged.append(&mut expanded.commands);
387 }
388 merged.append(&mut root.commands);
389 root.commands = merged;
390 root.include.clear();
391 Ok(root)
392}
393
394fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
395 let mut visited = HashSet::new();
396 let mut commands = BTreeMap::new();
397 for (name, node) in raw.commands {
398 commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
399 }
400 Ok(RootSpec {
401 metadata: raw.metadata,
402 commands,
403 })
404}
405
406fn validate_root(spec: &RootSpec) -> Result<()> {
407 for (name, node) in &spec.commands {
408 node.validate(name)?;
409 }
410 Ok(())
411}
412
413pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
414 let spec_path = spec_path
415 .canonicalize()
416 .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
417 let text = std::fs::read_to_string(&spec_path)
418 .with_context(|| format!("read spec file {}", spec_path.display()))?;
419 let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
420 let use_root = spec_path
421 .parent()
422 .unwrap_or_else(|| Path::new("."))
423 .to_path_buf();
424 let raw = merge_root_includes(raw, &use_root)?;
425 let ctx = LoadCtx { use_root };
426 let mut spec = materialize_root(raw, &ctx)?;
427 validate_root(&spec)?;
430 filter_spec_for_platform(&mut spec, platform.id.as_ref());
431 Ok(spec)
432}
433
434pub fn load_spec_from_str(
437 raw: &str,
438 use_root: Option<&Path>,
439 platform: HostPlatform,
440) -> Result<RootSpec> {
441 let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
442 let has_local_root_include = raw.include.iter().any(|i| matches!(i, IncludeRef::Local(_)));
443 let canonical_root = use_root
444 .map(|root| {
445 root.canonicalize()
446 .with_context(|| format!("canonicalize jan use root {}", root.display()))
447 })
448 .transpose()?;
449 let raw = if let Some(root) = canonical_root.as_deref() {
450 merge_root_includes(raw, root)?
451 } else {
452 if has_local_root_include {
453 bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
454 }
455 if !raw.include.is_empty() {
457 merge_root_includes(raw, Path::new("."))?
458 } else {
459 raw
460 }
461 };
462 let ctx = LoadCtx {
463 use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
464 };
465 let mut spec = materialize_root(raw, &ctx)?;
466 validate_root(&spec)?;
467 filter_spec_for_platform(&mut spec, platform.id.as_ref());
468 Ok(spec)
469}
470
471pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
472 filter_command_map(&mut spec.commands, platform_id);
473}
474
475fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
476 map.retain(|_, node| {
477 if !node_visible_for_platform(&node.os, platform_id) {
478 return false;
479 }
480 filter_command_map(&mut node.commands, platform_id);
481 if node.exec.is_some() {
482 return true;
483 }
484 !node.commands.is_empty()
485 });
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::{ExecSpec, LocalInclude};
492 use std::fs;
493
494 #[test]
495 fn os_filter_drops_linux_only_branch() {
496 let mut spec = RootSpec {
497 metadata: None,
498 commands: BTreeMap::from([(
499 "sys".into(),
500 CommandNode {
501 os: vec!["linux".into()],
502 about: "linux".into(),
503 commands: BTreeMap::from([(
504 "ports".into(),
505 CommandNode {
506 exec: Some(ExecSpec {
507 argv: vec!["echo".into(), "x".into()],
508 passthrough: false,
509 url: None,
510 file: None,
511 sha256: None,
512 ttl: None,
513 }),
514 ..Default::default()
515 },
516 )]),
517 ..Default::default()
518 },
519 )]),
520 };
521 filter_spec_for_platform(&mut spec, "macos");
522 assert!(spec.commands.is_empty());
523 }
524
525 #[test]
526 fn nested_include_resolves_from_use_root() {
527 let tmp = tempfile::tempdir().unwrap();
528 let root = tmp.path();
529 fs::create_dir(root.join("sub")).unwrap();
530 fs::write(
531 root.join("leaf.yaml"),
532 "about: root leaf\nexec:\n argv: [\"echo\", \"root\"]\n",
533 )
534 .unwrap();
535 fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
536 fs::write(
537 root.join("scripts.spec.yaml"),
538 "commands:\n outer:\n include: sub/outer.yaml\n",
539 )
540 .unwrap();
541
542 let spec =
543 load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
544 assert_eq!(
545 spec.commands["outer"].exec.as_ref().unwrap().argv,
546 vec!["echo", "root"]
547 );
548 }
549
550 #[test]
551 fn include_rejects_absolute_and_parent_paths() {
552 let tmp = tempfile::tempdir().unwrap();
553 let root = tmp.path().join("tree");
554 fs::create_dir(&root).unwrap();
555 let outside = tmp.path().join("outside.yaml");
556 fs::write(
557 &outside,
558 "about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
559 )
560 .unwrap();
561
562 for include in [
563 outside.to_string_lossy().into_owned(),
564 "../outside.yaml".to_string(),
565 ] {
566 fs::write(
567 root.join("scripts.spec.yaml"),
568 format!("commands:\n escaped:\n include: {include:?}\n"),
569 )
570 .unwrap();
571 let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
572 .unwrap_err();
573 assert!(
574 err.to_string().contains("must be relative")
575 || err.to_string().contains("must not contain `..`"),
576 "{err:#}"
577 );
578 }
579 }
580
581 #[cfg(unix)]
582 #[test]
583 fn include_rejects_symlink_escape() {
584 use std::os::unix::fs::symlink;
585
586 let tmp = tempfile::tempdir().unwrap();
587 let root = tmp.path().join("tree");
588 fs::create_dir(&root).unwrap();
589 let outside = tmp.path().join("outside.yaml");
590 fs::write(
591 &outside,
592 "about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
593 )
594 .unwrap();
595 symlink(&outside, root.join("linked.yaml")).unwrap();
596 fs::write(
597 root.join("scripts.spec.yaml"),
598 "commands:\n escaped:\n include: linked.yaml\n",
599 )
600 .unwrap();
601
602 let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
603 .unwrap_err();
604 assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
605 }
606
607 #[test]
608 fn include_ref_deserializes_local_and_remote() {
609 let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
610 assert_eq!(local, IncludeRef::Local(LocalInclude::from_path("sub/a.yaml")));
611 let local_map: IncludeRef = serde_yaml::from_str(
612 "path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
613 )
614 .unwrap();
615 match local_map {
616 IncludeRef::Local(l) => {
617 assert_eq!(l.path, "scripts/x.sh");
618 assert!(l.passthrough);
619 assert_eq!(l.argv, vec!["bash"]);
620 assert!(l.sha256.is_some());
621 }
622 _ => panic!("expected local"),
623 }
624 let remote: IncludeRef = serde_yaml::from_str(
625 "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
626 )
627 .unwrap();
628 assert!(remote.is_remote());
629 }
630
631 #[test]
632 fn exec_remote_requires_sha256() {
633 let node = CommandNode {
634 exec: Some(ExecSpec {
635 argv: vec![],
636 passthrough: false,
637 url: Some("https://example.com/x.sh".into()),
638 file: None,
639 sha256: None,
640 ttl: None,
641 }),
642 ..Default::default()
643 };
644 assert!(node.validate("x").is_err());
645 }
646}