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