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