use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::{IncludeRef, LocalInclude};
#[derive(Debug, Deserialize)]
pub struct LooseDoc {
#[serde(default)]
include: Vec<IncludeRef>,
#[serde(default)]
commands: BTreeMap<String, LooseNode>,
}
#[derive(Debug, Deserialize)]
pub struct LooseNode {
#[serde(default)]
include: Option<IncludeRef>,
#[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 reject_remote(inc: &IncludeRef) -> Result<()> {
if let IncludeRef::Remote(r) = inc {
bail!(
"jan bundle cannot pack remote include `{}` — vendor the YAML locally first",
r.url
);
}
Ok(())
}
fn local_include(inc: &IncludeRef) -> Result<&LocalInclude> {
reject_remote(inc)?;
match inc {
IncludeRef::Local(l) => Ok(l),
IncludeRef::Remote(_) => unreachable!("reject_remote"),
}
}
fn visit_include_target(
local: &LocalInclude,
anchor: &Path,
seen: &mut HashSet<PathBuf>,
ordered: &mut Vec<PathBuf>,
) -> Result<()> {
let p = resolve_under(anchor, &local.path)?;
if local.is_yaml() {
visit_yaml_file(&p, anchor, seen, ordered)
} else {
enforce_under_anchor(&p, anchor)?;
let canon = p
.canonicalize()
.with_context(|| format!("canonicalize {}", p.display()))?;
if seen.insert(canon.clone()) {
ordered.push(canon);
}
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 local = local_include(inc)?;
visit_include_target(local, 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 local = local_include(inc)?;
visit_include_target(local, 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();
std::fs::write(
root.join("scripts.spec.yaml"),
r#"
metadata:
name: demo
include:
- sub/leaf.yaml
commands:
wrapped:
include: sub/leaf.yaml
"#,
)
.unwrap();
let files = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().any(|p| p.ends_with("scripts.spec.yaml")));
assert!(files.iter().any(|p| p.ends_with("leaf.yaml")));
}
#[test]
fn closure_packs_script_includes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("scripts")).unwrap();
std::fs::write(root.join("scripts/hi.sh"), "#!/bin/sh\necho hi\n").unwrap();
std::fs::write(
root.join("scripts.spec.yaml"),
r#"
commands:
hi:
include: scripts/hi.sh
"#,
)
.unwrap();
let files = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap();
assert!(files.iter().any(|p| p.ends_with("hi.sh")));
}
#[test]
fn closure_rejects_remote_include() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut f = std::fs::File::create(root.join("scripts.spec.yaml")).unwrap();
write!(
f,
r#"
commands:
remote:
include:
url: https://example.com/a.yaml
sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"#
)
.unwrap();
let err = ordered_yaml_closure(&root.join("scripts.spec.yaml"), root).unwrap_err();
assert!(err.to_string().contains("cannot pack remote include"), "{err:#}");
}
}