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