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