use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct LooseDoc {
#[serde(default)]
include: Vec<String>,
#[serde(default)]
commands: BTreeMap<String, LooseNode>,
}
#[derive(Debug, Deserialize)]
pub struct LooseNode {
#[serde(default)]
include: Option<String>,
#[serde(default)]
commands: BTreeMap<String, LooseNode>,
}
fn resolve_under(use_root: &Path, rel: &str) -> Result<PathBuf> {
let p = Path::new(rel.trim());
if p.as_os_str().is_empty() {
bail!("empty include path");
}
if p.is_absolute() {
bail!(
"include path must be relative to the jan use root: {}",
p.display()
);
}
if p.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
bail!("include path must not contain `..`: {}", p.display());
}
let full = use_root.join(p);
let resolved = full
.canonicalize()
.with_context(|| format!("include path not found: {}", full.display()))?;
enforce_under_anchor(&resolved, use_root)?;
Ok(resolved)
}
#[allow(dead_code)]
pub fn canonical_spec_root(spec_file: &Path) -> Result<PathBuf> {
let spec_file = spec_file
.canonicalize()
.with_context(|| format!("canonicalize {}", spec_file.display()))?;
let parent = spec_file
.parent()
.ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
Ok(parent.to_path_buf())
}
pub fn ordered_yaml_closure(entry_yaml: &Path, bundle_root: &Path) -> Result<Vec<PathBuf>> {
let bundle_root = bundle_root
.canonicalize()
.with_context(|| format!("canonicalize bundle root {}", bundle_root.display()))?;
if !bundle_root.is_dir() {
bail!("bundle root is not a directory: {}", bundle_root.display());
}
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut ordered: Vec<PathBuf> = Vec::new();
visit_yaml_file(entry_yaml, &bundle_root, &mut seen, &mut ordered)?;
Ok(ordered)
}
fn enforce_under_anchor(path: &Path, anchor: &Path) -> Result<()> {
let p = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
if !(p.starts_with(anchor)) {
bail!(
"include escapes spec root anchor: {}\n(offending file: {}; anchor: {})",
p.display(),
path.display(),
anchor.display()
);
}
Ok(())
}
fn visit_yaml_file(
path: &Path,
anchor: &Path,
seen: &mut HashSet<PathBuf>,
ordered: &mut Vec<PathBuf>,
) -> Result<()> {
enforce_under_anchor(path, anchor)?;
let canon = path
.canonicalize()
.with_context(|| format!("canonicalize {}", path.display()))?;
if !seen.insert(canon.clone()) {
return Ok(());
}
ordered.push(canon.clone());
let text =
std::fs::read_to_string(&canon).with_context(|| format!("read {}", canon.display()))?;
let doc: LooseDoc =
serde_yaml::from_str(&text).with_context(|| format!("parse YAML {}", canon.display()))?;
for inc in &doc.include {
let p = resolve_under(anchor, inc)?;
visit_yaml_file(&p, anchor, seen, ordered)?;
}
for node in doc.commands.values() {
walk_node(node, anchor, seen, ordered)?;
}
Ok(())
}
fn walk_node(
node: &LooseNode,
anchor: &Path,
seen: &mut HashSet<PathBuf>,
ordered: &mut Vec<PathBuf>,
) -> Result<()> {
if let Some(inc) = &node.include {
let p = resolve_under(anchor, inc)?;
visit_yaml_file(&p, anchor, seen, ordered)?;
}
for child in node.commands.values() {
walk_node(child, anchor, seen, ordered)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn closure_collects_root_and_nested_includes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sub = root.join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("leaf.yaml"),
r#"
about: leaf
exec:
argv: ["echo", "x"]
"#,
)
.unwrap();
let main = root.join("main.yaml");
std::fs::write(
&main,
r#"
include:
- sub/leaf.yaml
commands:
outer:
include: sub/leaf.yaml
"#,
)
.unwrap();
let anchor = canonical_spec_root(&main).unwrap();
let mut out = ordered_yaml_closure(&main, &anchor).unwrap();
out.sort();
let mut names: Vec<_> = out
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
names.sort();
assert_eq!(names, vec!["leaf.yaml", "main.yaml"]);
}
#[test]
fn closure_rejects_escape_outside_anchor() {
let tmp = tempfile::tempdir().unwrap();
let inside = tmp.path().join("in");
let outside = tmp.path().join("out");
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let ext = outside.join("evil.yaml");
std::fs::write(&ext, "commands:\n").unwrap();
let main = inside.join("main.yaml");
let mut tmpf = tempfile::NamedTempFile::new_in(&inside).unwrap();
write!(
tmpf,
r#"
commands:
x:
include: {}
"#,
ext.display()
)
.unwrap();
std::fs::rename(tmpf.path(), &main).unwrap();
let anchor = canonical_spec_root(&main).unwrap();
assert!(ordered_yaml_closure(&main, &anchor).is_err());
}
}