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
61fn normalize_computer_token(tok: &str) -> String {
62 tok.trim().to_ascii_lowercase()
63}
64
65fn node_visible_for_computer(computer_list: &[String], host: Option<&str>) -> bool {
66 if computer_list.is_empty() {
67 return true;
68 }
69 let Some(host) = host else {
70 return false;
71 };
72 computer_list
73 .iter()
74 .any(|c| normalize_computer_token(c) == host)
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct HostComputer {
80 pub id: Option<Cow<'static, str>>,
82}
83
84impl HostComputer {
85 pub fn detect() -> Self {
87 if let Ok(v) = std::env::var("JAN_COMPUTER") {
88 let s = v.trim();
89 if !s.is_empty() {
90 return Self {
91 id: Some(Cow::Owned(normalize_computer_token(s))),
92 };
93 }
94 }
95 if let Ok(cfg) = crate::config::load_user_config() {
96 if let Some(id) = cfg
97 .computer_id
98 .as_deref()
99 .map(str::trim)
100 .filter(|s| !s.is_empty())
101 {
102 return Self {
103 id: Some(Cow::Owned(normalize_computer_token(id))),
104 };
105 }
106 }
107 if let Some(home) = dirs::home_dir() {
108 let legacy = home.join(".config/jan/computer");
109 if legacy.is_file() {
110 if let Ok(text) = std::fs::read_to_string(&legacy) {
111 let id = text.trim();
112 if !id.is_empty() {
113 return Self {
114 id: Some(Cow::Owned(normalize_computer_token(id))),
115 };
116 }
117 }
118 }
119 }
120 Self::auto_detect()
121 }
122
123 fn auto_detect() -> Self {
124 if std::path::Path::new("/sys/devices/virtual/dmi/id/sys_vendor").is_readable() {
125 if let Ok(vendor) = std::fs::read_to_string("/sys/devices/virtual/dmi/id/sys_vendor") {
126 if vendor.to_ascii_lowercase().contains("framework") {
127 return Self {
128 id: Some(Cow::Borrowed("framework")),
129 };
130 }
131 }
132 }
133 let host = hostname_short();
134 let key = format!("{}-{}", std::env::consts::OS, host);
135 let id = match key.as_str() {
136 "darwin-mac2025" | "darwin-mac2025.local" => Some("mac2025"),
137 s if s.contains("2017") => Some("mac_2017"),
138 s if s.contains("2022") => Some("mac_2022"),
139 _ => None,
140 };
141 Self {
142 id: id.map(Cow::Borrowed),
143 }
144 }
145}
146
147fn hostname_short() -> String {
148 std::process::Command::new("hostname")
149 .arg("-s")
150 .output()
151 .ok()
152 .filter(|o| o.status.success())
153 .and_then(|o| String::from_utf8(o.stdout).ok())
154 .map(|s| s.trim().to_string())
155 .filter(|s| !s.is_empty())
156 .unwrap_or_else(|| {
157 std::env::var("HOSTNAME")
158 .or_else(|_| std::env::var("HOST"))
159 .unwrap_or_default()
160 .trim()
161 .to_string()
162 })
163}
164
165trait PathReadable {
166 fn is_readable(&self) -> bool;
167}
168
169impl PathReadable for std::path::Path {
170 fn is_readable(&self) -> bool {
171 std::fs::OpenOptions::new().read(true).open(self).is_ok()
172 }
173}
174
175#[derive(Debug, Deserialize)]
176struct RawRootSpec {
177 metadata: Option<Metadata>,
178 #[serde(default)]
179 include: Vec<IncludeRef>,
180 #[serde(default)]
181 commands: BTreeMap<String, RawCommandNode>,
182}
183
184#[derive(Debug, Deserialize)]
185struct RawCommandNode {
186 #[serde(default)]
187 os: Vec<String>,
188 #[serde(default)]
189 computer: Vec<String>,
190 #[serde(default)]
191 about: String,
192 path: Option<String>,
193 #[serde(default)]
194 dependencies: Vec<String>,
195 #[serde(default)]
196 requires: Vec<String>,
197 #[serde(default)]
198 env: EnvSpec,
199 #[serde(default)]
200 inputs: BTreeMap<String, InputDef>,
201 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
202 cron: Vec<String>,
203 #[serde(default)]
204 packages: crate::PackagesSpec,
205 #[serde(default)]
206 tests: BTreeMap<String, crate::CommandTest>,
207 #[serde(default)]
208 aliases: crate::AliasesSpec,
209 #[serde(default)]
210 config: crate::ConfigSpec,
211 include: Option<IncludeRef>,
212 #[serde(default)]
213 commands: BTreeMap<String, RawCommandNode>,
214 exec: Option<ExecSpec>,
215}
216
217#[derive(Clone)]
218struct LoadCtx {
219 use_root: PathBuf,
224}
225
226impl LoadCtx {
227 fn read_local_bytes(&self, local: &LocalInclude) -> Result<(PathBuf, Vec<u8>)> {
228 let path = resolve_under_use_root(&self.use_root, &local.path)?;
229 let bytes = std::fs::read(&path)
230 .with_context(|| format!("read included file {}", path.display()))?;
231 if let Some(hash) = local
232 .sha256
233 .as_deref()
234 .map(str::trim)
235 .filter(|s| !s.is_empty())
236 {
237 let expected = remote::normalize_sha256(hash)?;
238 let got = remote::sha256_hex(&bytes);
239 if got != expected {
240 bail!(
241 "SHA256 mismatch for include `{}`: expected {expected}, got {got}",
242 local.path
243 );
244 }
245 }
246 Ok((path, bytes))
247 }
248
249 fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
250 match inc {
251 IncludeRef::Local(local) => {
252 let p = resolve_under_use_root(&self.use_root, &local.path)?;
253 Ok(p.to_string_lossy().to_string())
254 }
255 IncludeRef::Remote(_) => Ok(inc.cycle_token()),
256 }
257 }
258}
259
260fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
261 let mut opts = FetchOpts::new();
262 if let Some(ttl) = r.ttl {
263 opts = opts.with_ttl(ttl);
264 }
265 remote::fetch_verified_text(&r.url, &r.sha256, &opts)
266 .with_context(|| format!("fetch remote include {}", r.url))
267}
268
269pub(crate) fn resolve_under_use_root_any(use_root: &Path, rel: &str) -> Result<PathBuf> {
271 let rel = rel.trim();
272 if rel.is_empty() {
273 bail!("empty path");
274 }
275 let p = Path::new(rel);
276 if p.is_absolute() {
277 bail!("path must be relative to the jan use root: {rel}");
278 }
279 if p.components()
280 .any(|c| matches!(c, std::path::Component::ParentDir))
281 {
282 bail!("path must not contain `..`: {rel}");
283 }
284
285 let full = use_root.join(p);
286 let resolved = full
287 .canonicalize()
288 .with_context(|| format!("path not found: {}", full.display()))?;
289 if !resolved.starts_with(use_root) {
290 bail!(
291 "path escapes jan use root: {} (root: {})",
292 resolved.display(),
293 use_root.display()
294 );
295 }
296 Ok(resolved)
297}
298
299pub(crate) fn resolve_under_use_root(use_root: &Path, rel: &str) -> Result<PathBuf> {
301 let resolved = resolve_under_use_root_any(use_root, rel)?;
302 if !resolved.is_file() {
303 bail!("include is not a file: {}", resolved.display());
304 }
305 Ok(resolved)
306}
307
308fn include_link_for(inc: &IncludeRef, kind: IncludeLinkKind) -> IncludeLink {
309 match inc {
310 IncludeRef::Local(local) => IncludeLink {
311 kind,
312 path: Some(local.path.clone()),
313 url: None,
314 sha256: local.sha256.clone(),
315 },
316 IncludeRef::Remote(r) => IncludeLink {
317 kind,
318 path: None,
319 url: Some(r.url.clone()),
320 sha256: Some(r.sha256.clone()),
321 },
322 }
323}
324
325fn apply_wrapper_overlays(raw: &mut RawCommandNode, node: &mut CommandNode) -> Result<()> {
326 node.os = merge_os_filters(&raw.os, &node.os)?;
327 node.computer = merge_computer_filters(&raw.computer, &node.computer)?;
328 node.about = overlay_about(&raw.about, std::mem::take(&mut node.about));
329 if !raw.cron.is_empty() {
330 node.cron = std::mem::take(&mut raw.cron);
331 }
332 if !raw.env.is_empty() {
333 node.env.merge_from(std::mem::take(&mut raw.env));
334 }
335 for (k, v) in std::mem::take(&mut raw.inputs) {
336 node.inputs.insert(k, v);
337 }
338 if raw.path.is_some() {
339 node.path = raw.path.take();
340 }
341 if !raw.dependencies.is_empty() {
342 node.dependencies = std::mem::take(&mut raw.dependencies);
343 }
344 if !raw.requires.is_empty() {
345 node.requires = std::mem::take(&mut raw.requires);
346 }
347 if !raw.packages.is_empty() {
348 node.packages.merge_from(std::mem::take(&mut raw.packages));
349 }
350 for (k, v) in std::mem::take(&mut raw.tests) {
351 node.tests.insert(k, v);
352 }
353 if !raw.aliases.is_empty() {
354 node.aliases.merge_from(std::mem::take(&mut raw.aliases));
355 }
356 if !raw.config.is_empty() {
357 node.config.merge_from(std::mem::take(&mut raw.config));
358 }
359 Ok(())
360}
361
362fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
363 match (outer.is_empty(), inner.is_empty()) {
364 (true, true) => Ok(vec![]),
365 (true, false) => Ok(inner.to_vec()),
366 (false, true) => Ok(outer.to_vec()),
367 (false, false) => {
368 let merged: Vec<String> = outer
369 .iter()
370 .filter(|o| {
371 let n = normalize_os_token(o);
372 inner.iter().any(|i| normalize_os_token(i) == n)
373 })
374 .cloned()
375 .collect();
376 if merged.is_empty() {
377 bail!(
378 "conflicting `os:` filters between include wrapper and included file \
379 (no platform appears in both lists)"
380 );
381 }
382 Ok(merged)
383 }
384 }
385}
386
387fn merge_computer_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
388 match (outer.is_empty(), inner.is_empty()) {
389 (true, true) => Ok(vec![]),
390 (true, false) => Ok(inner.to_vec()),
391 (false, true) => Ok(outer.to_vec()),
392 (false, false) => {
393 let merged: Vec<String> = outer
394 .iter()
395 .filter(|o| {
396 let n = normalize_computer_token(o);
397 inner.iter().any(|i| normalize_computer_token(i) == n)
398 })
399 .cloned()
400 .collect();
401 if merged.is_empty() {
402 bail!(
403 "conflicting `computer:` filters between include wrapper and included file \
404 (no computer id appears in both lists)"
405 );
406 }
407 Ok(merged)
408 }
409 }
410}
411
412fn overlay_about(overlay: &str, base: String) -> String {
413 let o = overlay.trim();
414 if o.is_empty() {
415 base
416 } else {
417 o.to_string()
418 }
419}
420
421fn resolve_raw_command_node(
422 raw: RawCommandNode,
423 ctx: &LoadCtx,
424 visited: &mut HashSet<String>,
425) -> Result<CommandNode> {
426 if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
427 bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
428 }
429
430 let mut raw = raw;
431 if let Some(inc) = raw.include.take() {
432 let token = ctx.visit_token(&inc)?;
433 if !visited.insert(token.clone()) {
434 bail!("include cycle detected at `{token}`");
435 }
436 let label = inc.cycle_token();
437 let mut node = match &inc {
438 IncludeRef::Local(local) if !local.is_yaml() => {
439 if local.path.trim().is_empty() {
440 bail!("empty include path");
441 }
442 let (_path, _bytes) = ctx.read_local_bytes(local)?;
443 CommandNode {
444 about: String::new(),
445 exec: Some(ExecSpec {
446 argv: local.argv.clone(),
447 passthrough: local.passthrough,
448 file: Some(local.path.clone()),
449 sha256: local.sha256.clone(),
450 ..Default::default()
451 }),
452 source: Some(include_link_for(&inc, IncludeLinkKind::Script)),
453 ..Default::default()
454 }
455 }
456 IncludeRef::Local(local) => {
457 if !local.argv.is_empty() || local.passthrough {
458 bail!(
459 "include `{label}`: `argv` / `passthrough` are only valid for script files, not YAML"
460 );
461 }
462 let (_path, bytes) = ctx.read_local_bytes(local)?;
463 let text = String::from_utf8(bytes)
464 .with_context(|| format!("include `{label}` is not valid UTF-8"))?;
465 let inner: RawCommandNode = serde_yaml::from_str(&text)
466 .with_context(|| format!("parse include `{label}`"))?;
467 let mut node = resolve_raw_command_node(inner, ctx, visited)?;
468 node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
469 node
470 }
471 IncludeRef::Remote(r) => {
472 let text = fetch_remote_include(r)?;
473 let inner: RawCommandNode = serde_yaml::from_str(&text)
474 .with_context(|| format!("parse include `{label}`"))?;
475 let mut node = resolve_raw_command_node(inner, ctx, visited)?;
476 node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
477 node
478 }
479 };
480 visited.remove(&token);
481 apply_wrapper_overlays(&mut raw, &mut node)?;
482 return Ok(node);
483 }
484
485 let mut commands = BTreeMap::new();
486 for (name, child) in raw.commands {
487 commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
488 }
489
490 Ok(CommandNode {
491 os: raw.os,
492 computer: raw.computer,
493 about: raw.about,
494 path: raw.path,
495 dependencies: raw.dependencies,
496 requires: raw.requires,
497 env: raw.env,
498 inputs: raw.inputs,
499 cron: raw.cron,
500 packages: raw.packages,
501 tests: raw.tests,
502 aliases: raw.aliases,
503 config: raw.config,
504 commands,
505 exec: raw.exec,
506 source: None,
507 })
508}
509
510fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
511 let mut merged = BTreeMap::new();
512 for inc in &root.include {
513 let text = match inc {
514 IncludeRef::Local(local) => {
515 if !local.is_yaml() {
516 bail!(
517 "root-level include `{}` must be a YAML file (.yaml / .yml)",
518 local.path
519 );
520 }
521 if !local.argv.is_empty() || local.passthrough {
522 bail!(
523 "root-level include `{}`: `argv` / `passthrough` are not valid here",
524 local.path
525 );
526 }
527 let path = resolve_under_use_root(use_root, &local.path)?;
528 let bytes = std::fs::read(&path)
529 .with_context(|| format!("read root include {}", path.display()))?;
530 if let Some(hash) = local
531 .sha256
532 .as_deref()
533 .map(str::trim)
534 .filter(|s| !s.is_empty())
535 {
536 let expected = remote::normalize_sha256(hash)?;
537 let got = remote::sha256_hex(&bytes);
538 if got != expected {
539 bail!(
540 "SHA256 mismatch for root include `{}`: expected {expected}, got {got}",
541 local.path
542 );
543 }
544 }
545 String::from_utf8(bytes).with_context(|| {
546 format!("root include {} is not valid UTF-8", path.display())
547 })?
548 }
549 IncludeRef::Remote(r) => fetch_remote_include(r)?,
550 };
551 let label = inc.cycle_token();
552 let fragment: RawRootSpec =
553 serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
554 let mut expanded = merge_root_includes(fragment, use_root)?;
555 merged.append(&mut expanded.commands);
556 }
557 merged.append(&mut root.commands);
558 root.commands = merged;
559 root.include.clear();
560 Ok(root)
561}
562
563fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
564 let mut visited = HashSet::new();
565 let mut commands = BTreeMap::new();
566 for (name, node) in raw.commands {
567 commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
568 }
569 Ok(RootSpec {
570 metadata: raw.metadata,
571 commands,
572 })
573}
574
575fn validate_root(spec: &RootSpec) -> Result<()> {
576 for (name, node) in &spec.commands {
577 node.validate(name)?;
578 }
579 Ok(())
580}
581
582pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
583 let spec_path = spec_path
584 .canonicalize()
585 .with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
586 let text = std::fs::read_to_string(&spec_path)
587 .with_context(|| format!("read spec file {}", spec_path.display()))?;
588 let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
589 let use_root = spec_path
590 .parent()
591 .unwrap_or_else(|| Path::new("."))
592 .to_path_buf();
593 let raw = merge_root_includes(raw, &use_root)?;
594 let ctx = LoadCtx { use_root };
595 let mut spec = materialize_root(raw, &ctx)?;
596 validate_root(&spec)?;
599 filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
600 Ok(spec)
601}
602
603pub fn load_spec_from_str(
606 raw: &str,
607 use_root: Option<&Path>,
608 platform: HostPlatform,
609) -> Result<RootSpec> {
610 let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
611 let has_local_root_include = raw
612 .include
613 .iter()
614 .any(|i| matches!(i, IncludeRef::Local(_)));
615 let canonical_root = use_root
616 .map(|root| {
617 root.canonicalize()
618 .with_context(|| format!("canonicalize jan use root {}", root.display()))
619 })
620 .transpose()?;
621 let raw = if let Some(root) = canonical_root.as_deref() {
622 merge_root_includes(raw, root)?
623 } else {
624 if has_local_root_include {
625 bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
626 }
627 if !raw.include.is_empty() {
629 merge_root_includes(raw, Path::new("."))?
630 } else {
631 raw
632 }
633 };
634 let ctx = LoadCtx {
635 use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
636 };
637 let mut spec = materialize_root(raw, &ctx)?;
638 validate_root(&spec)?;
639 filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
640 Ok(spec)
641}
642
643pub fn filter_spec_for_host(spec: &mut RootSpec, platform: &HostPlatform, computer: &HostComputer) {
644 filter_command_map(
645 &mut spec.commands,
646 platform.id.as_ref(),
647 computer.id.as_deref(),
648 );
649}
650
651fn filter_command_map(
652 map: &mut BTreeMap<String, CommandNode>,
653 platform_id: &str,
654 computer_id: Option<&str>,
655) {
656 map.retain(|_, node| {
657 if !node_visible_for_platform(&node.os, platform_id) {
658 return false;
659 }
660 if !node_visible_for_computer(&node.computer, computer_id) {
661 return false;
662 }
663 filter_command_map(&mut node.commands, platform_id, computer_id);
664 if node.exec.is_some() || !node.aliases.is_empty() || !node.config.is_empty() {
665 return true;
666 }
667 !node.commands.is_empty()
668 });
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use crate::{AliasesSpec, ExecSpec, LocalInclude};
675 use std::fs;
676
677 fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
679 filter_spec_for_host(
680 spec,
681 &HostPlatform {
682 id: Cow::Owned(platform_id.to_string()),
683 },
684 &HostComputer { id: None },
685 );
686 }
687
688 #[test]
689 fn os_filter_drops_linux_only_branch() {
690 let mut spec = RootSpec {
691 metadata: None,
692 commands: BTreeMap::from([(
693 "sys".into(),
694 CommandNode {
695 os: vec!["linux".into()],
696 about: "linux".into(),
697 commands: BTreeMap::from([(
698 "ports".into(),
699 CommandNode {
700 exec: Some(ExecSpec {
701 argv: vec!["echo".into(), "x".into()],
702 ..Default::default()
703 }),
704 ..Default::default()
705 },
706 )]),
707 ..Default::default()
708 },
709 )]),
710 };
711 filter_spec_for_platform(&mut spec, "macos");
712 assert!(spec.commands.is_empty());
713 }
714
715 #[test]
716 fn os_filter_keeps_alias_only_node() {
717 let mut spec = RootSpec {
718 metadata: None,
719 commands: BTreeMap::from([(
720 "shortcuts".into(),
721 CommandNode {
722 aliases: AliasesSpec {
723 shell: BTreeMap::from([("g".into(), "git".into())]),
724 ..Default::default()
725 },
726 ..Default::default()
727 },
728 )]),
729 };
730 filter_spec_for_platform(&mut spec, "linux");
731 assert!(spec.commands.contains_key("shortcuts"));
732 }
733
734 #[test]
735 fn os_filter_keeps_config_only_node() {
736 let mut spec = RootSpec {
737 metadata: None,
738 commands: BTreeMap::from([(
739 "cfg".into(),
740 CommandNode {
741 config: crate::ConfigSpec {
742 shell: Some(crate::ConfigShell::Inline("export X=1\n".into())),
743 ..Default::default()
744 },
745 ..Default::default()
746 },
747 )]),
748 };
749 filter_spec_for_platform(&mut spec, "linux");
750 assert!(spec.commands.contains_key("cfg"));
751 }
752
753 #[test]
754 fn computer_filter_drops_other_machine_branch() {
755 let mut spec = RootSpec {
756 metadata: None,
757 commands: BTreeMap::from([(
758 "framework".into(),
759 CommandNode {
760 computer: vec!["framework".into()],
761 config: crate::ConfigSpec {
762 shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
763 ..Default::default()
764 },
765 ..Default::default()
766 },
767 )]),
768 };
769 filter_spec_for_host(
770 &mut spec,
771 &HostPlatform {
772 id: Cow::Borrowed("linux"),
773 },
774 &HostComputer {
775 id: Some(Cow::Borrowed("mac2025")),
776 },
777 );
778 assert!(spec.commands.is_empty());
779 }
780
781 #[test]
782 fn computer_filter_keeps_unrestricted_nodes_without_registration() {
783 let mut spec = RootSpec {
784 metadata: None,
785 commands: BTreeMap::from([
786 (
787 "shared".into(),
788 CommandNode {
789 config: crate::ConfigSpec {
790 shell: Some(crate::ConfigShell::Inline("echo all\n".into())),
791 ..Default::default()
792 },
793 ..Default::default()
794 },
795 ),
796 (
797 "framework".into(),
798 CommandNode {
799 computer: vec!["framework".into()],
800 config: crate::ConfigSpec {
801 shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
802 ..Default::default()
803 },
804 ..Default::default()
805 },
806 ),
807 ]),
808 };
809 filter_spec_for_host(
810 &mut spec,
811 &HostPlatform {
812 id: Cow::Borrowed("linux"),
813 },
814 &HostComputer { id: None },
815 );
816 assert!(spec.commands.contains_key("shared"));
817 assert!(!spec.commands.contains_key("framework"));
818 }
819
820 #[test]
821 fn wrapper_computer_merge_onto_include() {
822 let tmp = tempfile::tempdir().unwrap();
823 let root = tmp.path();
824 fs::write(
825 root.join("leaf.yaml"),
826 "about: leaf\nconfig:\n shell: |\n echo leaf\n",
827 )
828 .unwrap();
829 fs::write(
830 root.join("scripts.spec.yaml"),
831 "commands:\n leaf:\n computer: [framework]\n include: leaf.yaml\n",
832 )
833 .unwrap();
834
835 let spec = load_spec_from_path(
836 &root.join("scripts.spec.yaml"),
837 HostPlatform {
838 id: Cow::Borrowed("linux"),
839 },
840 )
841 .unwrap();
842 assert_eq!(spec.commands["leaf"].computer, vec!["framework"]);
843 }
844
845 #[test]
846 fn wrapper_aliases_merge_onto_include() {
847 let tmp = tempfile::tempdir().unwrap();
848 let root = tmp.path();
849 fs::write(
850 root.join("leaf.yaml"),
851 "about: leaf\naliases: [lb]\nexec:\n argv: [\"echo\", \"x\"]\n",
852 )
853 .unwrap();
854 fs::write(
855 root.join("scripts.spec.yaml"),
856 "commands:\n leaf:\n include: leaf.yaml\n aliases:\n g: git\n",
857 )
858 .unwrap();
859
860 let spec =
861 load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
862 assert_eq!(spec.commands["leaf"].aliases.names, vec!["lb"]);
863 assert_eq!(
864 spec.commands["leaf"]
865 .aliases
866 .shell
867 .get("g")
868 .map(String::as_str),
869 Some("git")
870 );
871 }
872
873 #[test]
874 fn nested_include_resolves_from_use_root() {
875 let tmp = tempfile::tempdir().unwrap();
876 let root = tmp.path();
877 fs::create_dir(root.join("sub")).unwrap();
878 fs::write(
879 root.join("leaf.yaml"),
880 "about: root leaf\nexec:\n argv: [\"echo\", \"root\"]\n",
881 )
882 .unwrap();
883 fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
884 fs::write(
885 root.join("scripts.spec.yaml"),
886 "commands:\n outer:\n include: sub/outer.yaml\n",
887 )
888 .unwrap();
889
890 let spec =
891 load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
892 assert_eq!(
893 spec.commands["outer"].exec.as_ref().unwrap().argv,
894 vec!["echo", "root"]
895 );
896 }
897
898 #[test]
899 fn include_rejects_absolute_and_parent_paths() {
900 let tmp = tempfile::tempdir().unwrap();
901 let root = tmp.path().join("tree");
902 fs::create_dir(&root).unwrap();
903 let outside = tmp.path().join("outside.yaml");
904 fs::write(
905 &outside,
906 "about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
907 )
908 .unwrap();
909
910 for include in [
911 outside.to_string_lossy().into_owned(),
912 "../outside.yaml".to_string(),
913 ] {
914 fs::write(
915 root.join("scripts.spec.yaml"),
916 format!("commands:\n escaped:\n include: {include:?}\n"),
917 )
918 .unwrap();
919 let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
920 .unwrap_err();
921 assert!(
922 err.to_string().contains("must be relative")
923 || err.to_string().contains("must not contain `..`"),
924 "{err:#}"
925 );
926 }
927 }
928
929 #[cfg(unix)]
930 #[test]
931 fn include_rejects_symlink_escape() {
932 use std::os::unix::fs::symlink;
933
934 let tmp = tempfile::tempdir().unwrap();
935 let root = tmp.path().join("tree");
936 fs::create_dir(&root).unwrap();
937 let outside = tmp.path().join("outside.yaml");
938 fs::write(
939 &outside,
940 "about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
941 )
942 .unwrap();
943 symlink(&outside, root.join("linked.yaml")).unwrap();
944 fs::write(
945 root.join("scripts.spec.yaml"),
946 "commands:\n escaped:\n include: linked.yaml\n",
947 )
948 .unwrap();
949
950 let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
951 .unwrap_err();
952 assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
953 }
954
955 #[test]
956 fn include_ref_deserializes_local_and_remote() {
957 let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
958 assert_eq!(
959 local,
960 IncludeRef::Local(LocalInclude::from_path("sub/a.yaml"))
961 );
962 let local_map: IncludeRef = serde_yaml::from_str(
963 "path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
964 )
965 .unwrap();
966 match local_map {
967 IncludeRef::Local(l) => {
968 assert_eq!(l.path, "scripts/x.sh");
969 assert!(l.passthrough);
970 assert_eq!(l.argv, vec!["bash"]);
971 assert!(l.sha256.is_some());
972 }
973 _ => panic!("expected local"),
974 }
975 let remote: IncludeRef = serde_yaml::from_str(
976 "url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
977 )
978 .unwrap();
979 assert!(remote.is_remote());
980 }
981
982 #[test]
983 fn exec_remote_requires_sha256() {
984 let node = CommandNode {
985 exec: Some(ExecSpec {
986 url: Some("https://example.com/x.sh".into()),
987 ..Default::default()
988 }),
989 ..Default::default()
990 };
991 assert!(node.validate("x").is_err());
992 }
993}