use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::borrow::Cow;
use crate::inputs::InputDef;
use crate::remote::{self, FetchOpts};
use crate::{
deserialize_string_or_seq, CommandNode, EnvSpec, ExecSpec, IncludeLink, IncludeLinkKind,
IncludeRef, LocalInclude, Metadata, RemoteInclude, RootSpec,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPlatform {
pub id: Cow<'static, str>,
}
impl HostPlatform {
pub fn detect() -> Self {
if let Ok(v) = std::env::var("JAN_OS") {
let s = v.trim().to_ascii_lowercase();
if !s.is_empty() {
return Self::from_normalized(&s);
}
}
Self::from_normalized(std::env::consts::OS)
}
fn from_normalized(os: &str) -> Self {
let id = match os {
"darwin" | "macos" => Cow::Borrowed("macos"),
"linux" => Cow::Borrowed("linux"),
"windows" => Cow::Borrowed("windows"),
other => Cow::Owned(other.to_string()),
};
Self { id }
}
}
fn normalize_os_token(tok: &str) -> String {
match tok.trim().to_ascii_lowercase().as_str() {
"darwin" => "macos".to_string(),
s => s.to_string(),
}
}
fn node_visible_for_platform(os_list: &[String], platform: &str) -> bool {
if os_list.is_empty() {
return true;
}
os_list.iter().any(|o| normalize_os_token(o) == platform)
}
fn normalize_computer_token(tok: &str) -> String {
tok.trim().to_ascii_lowercase()
}
fn node_visible_for_computer(computer_list: &[String], host: Option<&str>) -> bool {
if computer_list.is_empty() {
return true;
}
let Some(host) = host else {
return false;
};
computer_list
.iter()
.any(|c| normalize_computer_token(c) == host)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostComputer {
pub id: Option<Cow<'static, str>>,
}
impl HostComputer {
pub fn detect() -> Self {
if let Ok(v) = std::env::var("JAN_COMPUTER") {
let s = v.trim();
if !s.is_empty() {
return Self {
id: Some(Cow::Owned(normalize_computer_token(s))),
};
}
}
if let Ok(cfg) = crate::config::load_user_config() {
if let Some(id) = cfg
.computer_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Self {
id: Some(Cow::Owned(normalize_computer_token(id))),
};
}
}
if let Some(home) = dirs::home_dir() {
let legacy = home.join(".config/jan/computer");
if legacy.is_file() {
if let Ok(text) = std::fs::read_to_string(&legacy) {
let id = text.trim();
if !id.is_empty() {
return Self {
id: Some(Cow::Owned(normalize_computer_token(id))),
};
}
}
}
}
Self::auto_detect()
}
fn auto_detect() -> Self {
if std::path::Path::new("/sys/devices/virtual/dmi/id/sys_vendor").is_readable() {
if let Ok(vendor) = std::fs::read_to_string("/sys/devices/virtual/dmi/id/sys_vendor") {
if vendor.to_ascii_lowercase().contains("framework") {
return Self {
id: Some(Cow::Borrowed("framework")),
};
}
}
}
let host = hostname_short();
let key = format!("{}-{}", std::env::consts::OS, host);
let id = match key.as_str() {
"darwin-mac2025" | "darwin-mac2025.local" => Some("mac2025"),
s if s.contains("2017") => Some("mac_2017"),
s if s.contains("2022") => Some("mac_2022"),
_ => None,
};
Self {
id: id.map(Cow::Borrowed),
}
}
}
fn hostname_short() -> String {
std::process::Command::new("hostname")
.arg("-s")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("HOST"))
.unwrap_or_default()
.trim()
.to_string()
})
}
trait PathReadable {
fn is_readable(&self) -> bool;
}
impl PathReadable for std::path::Path {
fn is_readable(&self) -> bool {
std::fs::OpenOptions::new().read(true).open(self).is_ok()
}
}
#[derive(Debug, Deserialize)]
struct RawRootSpec {
metadata: Option<Metadata>,
#[serde(default)]
include: Vec<IncludeRef>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
}
#[derive(Debug, Deserialize)]
struct RawCommandNode {
#[serde(default)]
os: Vec<String>,
#[serde(default)]
computer: Vec<String>,
#[serde(default)]
about: String,
path: Option<String>,
#[serde(default)]
dependencies: Vec<String>,
#[serde(default)]
requires: Vec<String>,
#[serde(default)]
env: EnvSpec,
#[serde(default)]
inputs: BTreeMap<String, InputDef>,
#[serde(default, deserialize_with = "deserialize_string_or_seq")]
cron: Vec<String>,
#[serde(default)]
packages: crate::PackagesSpec,
#[serde(default)]
tests: BTreeMap<String, crate::CommandTest>,
#[serde(default)]
aliases: crate::AliasesSpec,
#[serde(default)]
config: crate::ConfigSpec,
include: Option<IncludeRef>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
exec: Option<ExecSpec>,
}
#[derive(Clone)]
struct LoadCtx {
use_root: PathBuf,
}
impl LoadCtx {
fn read_local_bytes(&self, local: &LocalInclude) -> Result<(PathBuf, Vec<u8>)> {
let path = resolve_under_use_root(&self.use_root, &local.path)?;
let bytes = std::fs::read(&path)
.with_context(|| format!("read included file {}", path.display()))?;
if let Some(hash) = local
.sha256
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
let expected = remote::normalize_sha256(hash)?;
let got = remote::sha256_hex(&bytes);
if got != expected {
bail!(
"SHA256 mismatch for include `{}`: expected {expected}, got {got}",
local.path
);
}
}
Ok((path, bytes))
}
fn visit_token(&self, inc: &IncludeRef) -> Result<String> {
match inc {
IncludeRef::Local(local) => {
let p = resolve_under_use_root(&self.use_root, &local.path)?;
Ok(p.to_string_lossy().to_string())
}
IncludeRef::Remote(_) => Ok(inc.cycle_token()),
}
}
}
fn fetch_remote_include(r: &RemoteInclude) -> Result<String> {
let mut opts = FetchOpts::new();
if let Some(ttl) = r.ttl {
opts = opts.with_ttl(ttl);
}
remote::fetch_verified_text(&r.url, &r.sha256, &opts)
.with_context(|| format!("fetch remote include {}", r.url))
}
pub(crate) fn resolve_under_use_root_any(use_root: &Path, rel: &str) -> Result<PathBuf> {
let rel = rel.trim();
if rel.is_empty() {
bail!("empty path");
}
let p = Path::new(rel);
if p.is_absolute() {
bail!("path must be relative to the jan use root: {rel}");
}
if p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("path must not contain `..`: {rel}");
}
let full = use_root.join(p);
let resolved = full
.canonicalize()
.with_context(|| format!("path not found: {}", full.display()))?;
if !resolved.starts_with(use_root) {
bail!(
"path escapes jan use root: {} (root: {})",
resolved.display(),
use_root.display()
);
}
Ok(resolved)
}
pub(crate) fn resolve_under_use_root(use_root: &Path, rel: &str) -> Result<PathBuf> {
let resolved = resolve_under_use_root_any(use_root, rel)?;
if !resolved.is_file() {
bail!("include is not a file: {}", resolved.display());
}
Ok(resolved)
}
fn include_link_for(inc: &IncludeRef, kind: IncludeLinkKind) -> IncludeLink {
match inc {
IncludeRef::Local(local) => IncludeLink {
kind,
path: Some(local.path.clone()),
url: None,
sha256: local.sha256.clone(),
},
IncludeRef::Remote(r) => IncludeLink {
kind,
path: None,
url: Some(r.url.clone()),
sha256: Some(r.sha256.clone()),
},
}
}
fn apply_wrapper_overlays(raw: &mut RawCommandNode, node: &mut CommandNode) -> Result<()> {
node.os = merge_os_filters(&raw.os, &node.os)?;
node.computer = merge_computer_filters(&raw.computer, &node.computer)?;
node.about = overlay_about(&raw.about, std::mem::take(&mut node.about));
if !raw.cron.is_empty() {
node.cron = std::mem::take(&mut raw.cron);
}
if !raw.env.is_empty() {
node.env.merge_from(std::mem::take(&mut raw.env));
}
for (k, v) in std::mem::take(&mut raw.inputs) {
node.inputs.insert(k, v);
}
if raw.path.is_some() {
node.path = raw.path.take();
}
if !raw.dependencies.is_empty() {
node.dependencies = std::mem::take(&mut raw.dependencies);
}
if !raw.requires.is_empty() {
node.requires = std::mem::take(&mut raw.requires);
}
if !raw.packages.is_empty() {
node.packages.merge_from(std::mem::take(&mut raw.packages));
}
for (k, v) in std::mem::take(&mut raw.tests) {
node.tests.insert(k, v);
}
if !raw.aliases.is_empty() {
node.aliases.merge_from(std::mem::take(&mut raw.aliases));
}
if !raw.config.is_empty() {
node.config.merge_from(std::mem::take(&mut raw.config));
}
Ok(())
}
fn merge_os_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
match (outer.is_empty(), inner.is_empty()) {
(true, true) => Ok(vec![]),
(true, false) => Ok(inner.to_vec()),
(false, true) => Ok(outer.to_vec()),
(false, false) => {
let merged: Vec<String> = outer
.iter()
.filter(|o| {
let n = normalize_os_token(o);
inner.iter().any(|i| normalize_os_token(i) == n)
})
.cloned()
.collect();
if merged.is_empty() {
bail!(
"conflicting `os:` filters between include wrapper and included file \
(no platform appears in both lists)"
);
}
Ok(merged)
}
}
}
fn merge_computer_filters(outer: &[String], inner: &[String]) -> Result<Vec<String>> {
match (outer.is_empty(), inner.is_empty()) {
(true, true) => Ok(vec![]),
(true, false) => Ok(inner.to_vec()),
(false, true) => Ok(outer.to_vec()),
(false, false) => {
let merged: Vec<String> = outer
.iter()
.filter(|o| {
let n = normalize_computer_token(o);
inner.iter().any(|i| normalize_computer_token(i) == n)
})
.cloned()
.collect();
if merged.is_empty() {
bail!(
"conflicting `computer:` filters between include wrapper and included file \
(no computer id appears in both lists)"
);
}
Ok(merged)
}
}
}
fn overlay_about(overlay: &str, base: String) -> String {
let o = overlay.trim();
if o.is_empty() {
base
} else {
o.to_string()
}
}
fn resolve_raw_command_node(
raw: RawCommandNode,
ctx: &LoadCtx,
visited: &mut HashSet<String>,
) -> Result<CommandNode> {
if raw.include.is_some() && (raw.exec.is_some() || !raw.commands.is_empty()) {
bail!("command with `include` cannot also define `exec` or nested `commands` in the same YAML map");
}
let mut raw = raw;
if let Some(inc) = raw.include.take() {
let token = ctx.visit_token(&inc)?;
if !visited.insert(token.clone()) {
bail!("include cycle detected at `{token}`");
}
let label = inc.cycle_token();
let mut node = match &inc {
IncludeRef::Local(local) if !local.is_yaml() => {
if local.path.trim().is_empty() {
bail!("empty include path");
}
let (_path, _bytes) = ctx.read_local_bytes(local)?;
CommandNode {
about: String::new(),
exec: Some(ExecSpec {
argv: local.argv.clone(),
passthrough: local.passthrough,
file: Some(local.path.clone()),
sha256: local.sha256.clone(),
..Default::default()
}),
source: Some(include_link_for(&inc, IncludeLinkKind::Script)),
..Default::default()
}
}
IncludeRef::Local(local) => {
if !local.argv.is_empty() || local.passthrough {
bail!(
"include `{label}`: `argv` / `passthrough` are only valid for script files, not YAML"
);
}
let (_path, bytes) = ctx.read_local_bytes(local)?;
let text = String::from_utf8(bytes)
.with_context(|| format!("include `{label}` is not valid UTF-8"))?;
let inner: RawCommandNode = serde_yaml::from_str(&text)
.with_context(|| format!("parse include `{label}`"))?;
let mut node = resolve_raw_command_node(inner, ctx, visited)?;
node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
node
}
IncludeRef::Remote(r) => {
let text = fetch_remote_include(r)?;
let inner: RawCommandNode = serde_yaml::from_str(&text)
.with_context(|| format!("parse include `{label}`"))?;
let mut node = resolve_raw_command_node(inner, ctx, visited)?;
node.source = Some(include_link_for(&inc, IncludeLinkKind::Yaml));
node
}
};
visited.remove(&token);
apply_wrapper_overlays(&mut raw, &mut node)?;
return Ok(node);
}
let mut commands = BTreeMap::new();
for (name, child) in raw.commands {
commands.insert(name, resolve_raw_command_node(child, ctx, visited)?);
}
Ok(CommandNode {
os: raw.os,
computer: raw.computer,
about: raw.about,
path: raw.path,
dependencies: raw.dependencies,
requires: raw.requires,
env: raw.env,
inputs: raw.inputs,
cron: raw.cron,
packages: raw.packages,
tests: raw.tests,
aliases: raw.aliases,
config: raw.config,
commands,
exec: raw.exec,
source: None,
})
}
fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
let mut merged = BTreeMap::new();
for inc in &root.include {
let text = match inc {
IncludeRef::Local(local) => {
if !local.is_yaml() {
bail!(
"root-level include `{}` must be a YAML file (.yaml / .yml)",
local.path
);
}
if !local.argv.is_empty() || local.passthrough {
bail!(
"root-level include `{}`: `argv` / `passthrough` are not valid here",
local.path
);
}
let path = resolve_under_use_root(use_root, &local.path)?;
let bytes = std::fs::read(&path)
.with_context(|| format!("read root include {}", path.display()))?;
if let Some(hash) = local
.sha256
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
let expected = remote::normalize_sha256(hash)?;
let got = remote::sha256_hex(&bytes);
if got != expected {
bail!(
"SHA256 mismatch for root include `{}`: expected {expected}, got {got}",
local.path
);
}
}
String::from_utf8(bytes).with_context(|| {
format!("root include {} is not valid UTF-8", path.display())
})?
}
IncludeRef::Remote(r) => fetch_remote_include(r)?,
};
let label = inc.cycle_token();
let fragment: RawRootSpec =
serde_yaml::from_str(&text).with_context(|| format!("parse {label}"))?;
let mut expanded = merge_root_includes(fragment, use_root)?;
merged.append(&mut expanded.commands);
}
merged.append(&mut root.commands);
root.commands = merged;
root.include.clear();
Ok(root)
}
fn materialize_root(raw: RawRootSpec, ctx: &LoadCtx) -> Result<RootSpec> {
let mut visited = HashSet::new();
let mut commands = BTreeMap::new();
for (name, node) in raw.commands {
commands.insert(name, resolve_raw_command_node(node, ctx, &mut visited)?);
}
Ok(RootSpec {
metadata: raw.metadata,
commands,
})
}
fn validate_root(spec: &RootSpec) -> Result<()> {
for (name, node) in &spec.commands {
node.validate(name)?;
}
Ok(())
}
pub fn load_spec_from_path(spec_path: &Path, platform: HostPlatform) -> Result<RootSpec> {
let spec_path = spec_path
.canonicalize()
.with_context(|| format!("canonicalize spec file {}", spec_path.display()))?;
let text = std::fs::read_to_string(&spec_path)
.with_context(|| format!("read spec file {}", spec_path.display()))?;
let raw: RawRootSpec = serde_yaml::from_str(&text).context("parse YAML spec")?;
let use_root = spec_path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
let raw = merge_root_includes(raw, &use_root)?;
let ctx = LoadCtx { use_root };
let mut spec = materialize_root(raw, &ctx)?;
validate_root(&spec)?;
filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
Ok(spec)
}
pub fn load_spec_from_str(
raw: &str,
use_root: Option<&Path>,
platform: HostPlatform,
) -> Result<RootSpec> {
let raw: RawRootSpec = serde_yaml::from_str(raw).context("parse YAML spec")?;
let has_local_root_include = raw
.include
.iter()
.any(|i| matches!(i, IncludeRef::Local(_)));
let canonical_root = use_root
.map(|root| {
root.canonicalize()
.with_context(|| format!("canonicalize jan use root {}", root.display()))
})
.transpose()?;
let raw = if let Some(root) = canonical_root.as_deref() {
merge_root_includes(raw, root)?
} else {
if has_local_root_include {
bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
}
if !raw.include.is_empty() {
merge_root_includes(raw, Path::new("."))?
} else {
raw
}
};
let ctx = LoadCtx {
use_root: canonical_root.unwrap_or_else(|| PathBuf::from(".")),
};
let mut spec = materialize_root(raw, &ctx)?;
validate_root(&spec)?;
filter_spec_for_host(&mut spec, &platform, &HostComputer::detect());
Ok(spec)
}
pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
filter_spec_for_host(
spec,
&HostPlatform {
id: Cow::Owned(platform_id.to_string()),
},
&HostComputer { id: None },
);
}
pub fn filter_spec_for_host(spec: &mut RootSpec, platform: &HostPlatform, computer: &HostComputer) {
filter_command_map(
&mut spec.commands,
platform.id.as_ref(),
computer.id.as_deref(),
);
}
fn filter_command_map(
map: &mut BTreeMap<String, CommandNode>,
platform_id: &str,
computer_id: Option<&str>,
) {
map.retain(|_, node| {
if !node_visible_for_platform(&node.os, platform_id) {
return false;
}
if !node_visible_for_computer(&node.computer, computer_id) {
return false;
}
filter_command_map(&mut node.commands, platform_id, computer_id);
if node.exec.is_some() || !node.aliases.is_empty() || !node.config.is_empty() {
return true;
}
!node.commands.is_empty()
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AliasesSpec, ExecSpec, LocalInclude};
use std::fs;
#[test]
fn os_filter_drops_linux_only_branch() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"sys".into(),
CommandNode {
os: vec!["linux".into()],
about: "linux".into(),
commands: BTreeMap::from([(
"ports".into(),
CommandNode {
exec: Some(ExecSpec {
argv: vec!["echo".into(), "x".into()],
..Default::default()
}),
..Default::default()
},
)]),
..Default::default()
},
)]),
};
filter_spec_for_platform(&mut spec, "macos");
assert!(spec.commands.is_empty());
}
#[test]
fn os_filter_keeps_alias_only_node() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"shortcuts".into(),
CommandNode {
aliases: AliasesSpec {
shell: BTreeMap::from([("g".into(), "git".into())]),
..Default::default()
},
..Default::default()
},
)]),
};
filter_spec_for_platform(&mut spec, "linux");
assert!(spec.commands.contains_key("shortcuts"));
}
#[test]
fn os_filter_keeps_config_only_node() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"cfg".into(),
CommandNode {
config: crate::ConfigSpec {
shell: Some(crate::ConfigShell::Inline("export X=1\n".into())),
..Default::default()
},
..Default::default()
},
)]),
};
filter_spec_for_platform(&mut spec, "linux");
assert!(spec.commands.contains_key("cfg"));
}
#[test]
fn computer_filter_drops_other_machine_branch() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"framework".into(),
CommandNode {
computer: vec!["framework".into()],
config: crate::ConfigSpec {
shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
..Default::default()
},
..Default::default()
},
)]),
};
filter_spec_for_host(
&mut spec,
&HostPlatform {
id: Cow::Borrowed("linux"),
},
&HostComputer {
id: Some(Cow::Borrowed("mac2025")),
},
);
assert!(spec.commands.is_empty());
}
#[test]
fn computer_filter_keeps_unrestricted_nodes_without_registration() {
let mut spec = RootSpec {
metadata: None,
commands: BTreeMap::from([
(
"shared".into(),
CommandNode {
config: crate::ConfigSpec {
shell: Some(crate::ConfigShell::Inline("echo all\n".into())),
..Default::default()
},
..Default::default()
},
),
(
"framework".into(),
CommandNode {
computer: vec!["framework".into()],
config: crate::ConfigSpec {
shell: Some(crate::ConfigShell::Inline("echo fw\n".into())),
..Default::default()
},
..Default::default()
},
),
]),
};
filter_spec_for_host(
&mut spec,
&HostPlatform {
id: Cow::Borrowed("linux"),
},
&HostComputer { id: None },
);
assert!(spec.commands.contains_key("shared"));
assert!(!spec.commands.contains_key("framework"));
}
#[test]
fn wrapper_computer_merge_onto_include() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("leaf.yaml"),
"about: leaf\nconfig:\n shell: |\n echo leaf\n",
)
.unwrap();
fs::write(
root.join("scripts.spec.yaml"),
"commands:\n leaf:\n computer: [framework]\n include: leaf.yaml\n",
)
.unwrap();
let spec = load_spec_from_path(
&root.join("scripts.spec.yaml"),
HostPlatform {
id: Cow::Borrowed("linux"),
},
)
.unwrap();
assert_eq!(spec.commands["leaf"].computer, vec!["framework"]);
}
#[test]
fn wrapper_aliases_merge_onto_include() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::write(
root.join("leaf.yaml"),
"about: leaf\naliases: [lb]\nexec:\n argv: [\"echo\", \"x\"]\n",
)
.unwrap();
fs::write(
root.join("scripts.spec.yaml"),
"commands:\n leaf:\n include: leaf.yaml\n aliases:\n g: git\n",
)
.unwrap();
let spec =
load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
assert_eq!(spec.commands["leaf"].aliases.names, vec!["lb"]);
assert_eq!(
spec.commands["leaf"]
.aliases
.shell
.get("g")
.map(String::as_str),
Some("git")
);
}
#[test]
fn nested_include_resolves_from_use_root() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir(root.join("sub")).unwrap();
fs::write(
root.join("leaf.yaml"),
"about: root leaf\nexec:\n argv: [\"echo\", \"root\"]\n",
)
.unwrap();
fs::write(root.join("sub/outer.yaml"), "include: leaf.yaml\n").unwrap();
fs::write(
root.join("scripts.spec.yaml"),
"commands:\n outer:\n include: sub/outer.yaml\n",
)
.unwrap();
let spec =
load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect()).unwrap();
assert_eq!(
spec.commands["outer"].exec.as_ref().unwrap().argv,
vec!["echo", "root"]
);
}
#[test]
fn include_rejects_absolute_and_parent_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("tree");
fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside.yaml");
fs::write(
&outside,
"about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
)
.unwrap();
for include in [
outside.to_string_lossy().into_owned(),
"../outside.yaml".to_string(),
] {
fs::write(
root.join("scripts.spec.yaml"),
format!("commands:\n escaped:\n include: {include:?}\n"),
)
.unwrap();
let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
.unwrap_err();
assert!(
err.to_string().contains("must be relative")
|| err.to_string().contains("must not contain `..`"),
"{err:#}"
);
}
}
#[cfg(unix)]
#[test]
fn include_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("tree");
fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside.yaml");
fs::write(
&outside,
"about: outside\nexec:\n argv: [\"echo\", \"outside\"]\n",
)
.unwrap();
symlink(&outside, root.join("linked.yaml")).unwrap();
fs::write(
root.join("scripts.spec.yaml"),
"commands:\n escaped:\n include: linked.yaml\n",
)
.unwrap();
let err = load_spec_from_path(&root.join("scripts.spec.yaml"), HostPlatform::detect())
.unwrap_err();
assert!(err.to_string().contains("escapes jan use root"), "{err:#}");
}
#[test]
fn include_ref_deserializes_local_and_remote() {
let local: IncludeRef = serde_yaml::from_str("sub/a.yaml").unwrap();
assert_eq!(
local,
IncludeRef::Local(LocalInclude::from_path("sub/a.yaml"))
);
let local_map: IncludeRef = serde_yaml::from_str(
"path: scripts/x.sh\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nargv: [bash]\npassthrough: true\n",
)
.unwrap();
match local_map {
IncludeRef::Local(l) => {
assert_eq!(l.path, "scripts/x.sh");
assert!(l.passthrough);
assert_eq!(l.argv, vec!["bash"]);
assert!(l.sha256.is_some());
}
_ => panic!("expected local"),
}
let remote: IncludeRef = serde_yaml::from_str(
"url: https://example.com/a.yaml\nsha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n",
)
.unwrap();
assert!(remote.is_remote());
}
#[test]
fn exec_remote_requires_sha256() {
let node = CommandNode {
exec: Some(ExecSpec {
url: Some("https://example.com/x.sh".into()),
..Default::default()
}),
..Default::default()
};
assert!(node.validate("x").is_err());
}
}