use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::borrow::Cow;
use crate::{deserialize_string_or_seq, CommandNode, EnvSpec, ExecSpec, Metadata, RootSpec};
use crate::inputs::InputDef;
#[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)
}
#[derive(Debug, Deserialize)]
struct RawRootSpec {
metadata: Option<Metadata>,
#[serde(default)]
include: Vec<String>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
}
#[derive(Debug, Deserialize)]
struct RawCommandNode {
#[serde(default)]
os: 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>,
include: Option<String>,
#[serde(default)]
commands: BTreeMap<String, RawCommandNode>,
exec: Option<ExecSpec>,
}
#[derive(Clone)]
struct LoadCtx {
use_root: PathBuf,
}
impl LoadCtx {
fn read_include(&self, rel: &str) -> Result<String> {
let path = resolve_under(&self.use_root, rel)?;
std::fs::read_to_string(&path)
.with_context(|| format!("read included spec {}", path.display()))
}
fn visit_token(&self, rel: &str) -> Result<String> {
let p = resolve_under(&self.use_root, rel)?;
Ok(p.to_string_lossy().to_string())
}
}
fn resolve_under(use_root: &Path, rel: &str) -> Result<PathBuf> {
let rel = rel.trim();
if rel.is_empty() {
bail!("empty include path");
}
let p = Path::new(rel);
if p.is_absolute() {
bail!("include path must be relative to the jan use root: {rel}");
}
if p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("include path must not contain `..`: {rel}");
}
let full = use_root.join(p);
let resolved = full
.canonicalize()
.with_context(|| format!("include path not found: {}", full.display()))?;
if !resolved.starts_with(use_root) {
bail!(
"include escapes jan use root: {} (root: {})",
resolved.display(),
use_root.display()
);
}
if !resolved.is_file() {
bail!("include is not a file: {}", resolved.display());
}
Ok(resolved)
}
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 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(rel) = raw.include.take() {
let token = ctx.visit_token(&rel)?;
if !visited.insert(token.clone()) {
bail!("include cycle detected at `{token}`");
}
let text = ctx.read_include(&rel)?;
let inner: RawCommandNode =
serde_yaml::from_str(&text).with_context(|| format!("parse include `{rel}`"))?;
let mut node = resolve_raw_command_node(inner, ctx, visited)?;
visited.remove(&token);
node.os = merge_os_filters(&raw.os, &node.os)?;
node.about = overlay_about(&raw.about, node.about);
if !raw.cron.is_empty() {
node.cron = raw.cron;
}
if !raw.env.is_empty() {
node.env.merge_from(raw.env);
}
for (k, v) in raw.inputs {
node.inputs.insert(k, v);
}
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,
about: raw.about,
path: raw.path,
dependencies: raw.dependencies,
requires: raw.requires,
env: raw.env,
inputs: raw.inputs,
cron: raw.cron,
commands,
exec: raw.exec,
})
}
fn merge_root_includes(mut root: RawRootSpec, use_root: &Path) -> Result<RawRootSpec> {
let mut merged = BTreeMap::new();
for inc in &root.include {
let path = resolve_under(use_root, inc)?;
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read root include {}", path.display()))?;
let fragment: RawRootSpec =
serde_yaml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
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_platform(&mut spec, platform.id.as_ref());
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 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 !raw.include.is_empty() {
bail!("root-level `include` requires a base directory (pass when calling load_spec_from_str)");
}
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_platform(&mut spec, platform.id.as_ref());
Ok(spec)
}
pub fn filter_spec_for_platform(spec: &mut RootSpec, platform_id: &str) {
filter_command_map(&mut spec.commands, platform_id);
}
fn filter_command_map(map: &mut BTreeMap<String, CommandNode>, platform_id: &str) {
map.retain(|_, node| {
if !node_visible_for_platform(&node.os, platform_id) {
return false;
}
filter_command_map(&mut node.commands, platform_id);
if node.exec.is_some() {
return true;
}
!node.commands.is_empty()
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExecSpec;
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()],
passthrough: false,
}),
..Default::default()
},
)]),
..Default::default()
},
)]),
};
filter_spec_for_platform(&mut spec, "macos");
assert!(spec.commands.is_empty());
}
#[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:#}");
}
}