use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use crate::{CommandNode, EnvSpec, RootSpec, RunContext};
#[derive(Debug, Clone)]
struct ScriptEntry {
path: PathBuf,
dependencies: Vec<String>,
}
const ESSENTIAL_ENV: &[&str] = &[
"PATH",
"HOME",
"USER",
"LOGNAME",
"SHELL",
"LANG",
"LC_ALL",
"LC_CTYPE",
"LC_MESSAGES",
"TERM",
"TERMINFO",
"COLORTERM",
"NO_COLOR",
"TMPDIR",
"TMP",
"TEMP",
"XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
];
pub fn collect_chain_env(chain: &[String], spec: &RootSpec) -> EnvSpec {
let mut env = EnvSpec::default();
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
env.merge_from(node.env.clone());
map = &node.commands;
}
env
}
pub fn check_private_env(names: &[String]) -> Result<()> {
let mut missing = Vec::new();
for name in names {
let name = name.trim();
if name.is_empty() {
bail!("private env entry must not be empty");
}
if std::env::var_os(name).is_none() {
missing.push(name.to_string());
}
}
if missing.is_empty() {
return Ok(());
}
bail!(
"missing required environment variables: {} \
(declare them as `env.private` and export them in the host shell; values are not read from YAML)",
missing.join(", ")
);
}
pub fn fetch_pass_secret(pass_id: &str) -> Result<String> {
let pass_id = pass_id.trim();
if pass_id.is_empty() {
bail!("pass id must not be empty");
}
if pass_id.starts_with('-')
|| pass_id.contains('\0')
|| pass_id.contains('\n')
|| pass_id.contains('\r')
{
bail!("invalid pass id `{pass_id}`");
}
let output = Command::new("pass")
.arg(pass_id)
.output()
.with_context(|| format!("spawn `pass {pass_id}` (is `pass` installed?)"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
if detail.is_empty() {
bail!("`pass {pass_id}` failed with status {}", output.status);
}
bail!("`pass {pass_id}` failed: {detail}");
}
let text = String::from_utf8(output.stdout).context("`pass` output was not valid UTF-8")?;
let secret = text
.lines()
.next()
.unwrap_or("")
.trim_end_matches(['\r', '\n'])
.to_string();
if secret.is_empty() {
bail!("`pass {pass_id}` returned an empty password");
}
Ok(secret)
}
pub fn resolve_pass_env(pass: &BTreeMap<String, String>) -> Result<BTreeMap<String, String>> {
let mut out = BTreeMap::new();
for (env_name, pass_id) in pass {
let env_name = env_name.trim();
if env_name.is_empty() {
bail!("env.pass key must not be empty");
}
let secret = fetch_pass_secret(pass_id)?;
out.insert(env_name.to_string(), secret);
}
Ok(out)
}
pub fn apply_process_env(
cmd: &mut Command,
env: &EnvSpec,
path_override: Option<String>,
) -> Result<()> {
if env.restricts_child_env() {
let built = build_restricted_env(env, path_override)?;
cmd.env_clear();
for (key, value) in built {
cmd.env(key, value);
}
} else if let Some(path) = path_override {
cmd.env("PATH", path);
}
Ok(())
}
pub fn resolve_child_env(
env: &EnvSpec,
path_override: Option<String>,
) -> Result<BTreeMap<String, String>> {
if env.restricts_child_env() {
return build_restricted_env(env, path_override);
}
let mut out: BTreeMap<String, String> = std::env::vars().collect();
for name in &env.private {
let name = name.trim();
if name.is_empty() {
continue;
}
if let Ok(value) = std::env::var(name) {
out.insert(name.to_string(), value);
}
}
for (k, v) in &env.public {
out.insert(k.clone(), v.clone());
}
for (k, v) in resolve_pass_env(&env.pass)? {
out.insert(k, v);
}
if let Some(path) = path_override {
out.insert("PATH".into(), path);
}
Ok(out)
}
fn build_restricted_env(
env: &EnvSpec,
path_override: Option<String>,
) -> Result<BTreeMap<String, String>> {
let mut out = BTreeMap::new();
for key in ESSENTIAL_ENV {
if let Ok(v) = std::env::var(key) {
out.insert((*key).to_string(), v);
}
}
for name in &env.private {
let name = name.trim();
let value = std::env::var(name).with_context(|| format!("read private env `{name}`"))?;
out.insert(name.to_string(), value);
}
for (k, v) in &env.public {
out.insert(k.clone(), v.clone());
}
for (k, v) in resolve_pass_env(&env.pass)? {
out.insert(k, v);
}
if let Some(path) = path_override {
out.insert("PATH".into(), path);
}
Ok(out)
}
pub fn collect_chain_metadata(
chain: &[String],
spec: &RootSpec,
) -> (Vec<String>, Vec<String>, Option<String>) {
let mut deps = Vec::new();
let mut requires = Vec::new();
let mut path = None;
let mut map = &spec.commands;
for seg in chain {
let Some(node) = map.get(seg) else { break };
deps.extend(node.dependencies.iter().cloned());
requires.extend(node.requires.iter().cloned());
if node.path.is_some() {
path = node.path.clone();
}
map = &node.commands;
}
deps.sort();
deps.dedup();
requires.sort();
requires.dedup();
(deps, requires, path)
}
fn resolve_path_str(raw: &str, ctx: &RunContext<'_>) -> Result<PathBuf> {
let p = Path::new(raw.trim());
if p.is_absolute() {
return p
.canonicalize()
.with_context(|| format!("resolve path {}", p.display()));
}
if let Ok(root) = std::env::var("JAN_SCRIPTS_ROOT") {
let candidate = PathBuf::from(root.trim()).join(p);
if candidate.is_dir() {
return candidate
.canonicalize()
.with_context(|| format!("resolve path {}", candidate.display()));
}
}
let spec_dir = PathBuf::from(&ctx.spec_root.spec_dir);
for base in [
ctx.cwd,
spec_dir.as_path(),
spec_dir.parent().unwrap_or(Path::new(".")),
] {
let candidate = base.join(p);
if candidate.is_dir() {
return candidate
.canonicalize()
.with_context(|| format!("resolve path {}", candidate.display()));
}
}
bail!(
"could not resolve script path `{}` (tried cwd, spec dir, spec parent, and JAN_SCRIPTS_ROOT)",
raw
);
}
fn index_scripts(spec: &RootSpec, ctx: &RunContext<'_>) -> Result<BTreeMap<String, ScriptEntry>> {
let mut index = BTreeMap::new();
index_commands(&spec.commands, ctx, &mut index)?;
Ok(index)
}
fn index_commands(
map: &BTreeMap<String, CommandNode>,
ctx: &RunContext<'_>,
index: &mut BTreeMap<String, ScriptEntry>,
) -> Result<()> {
for (name, node) in map {
if let Some(ref raw_path) = node.path {
if let Ok(path) = resolve_path_str(raw_path, ctx) {
let entry = ScriptEntry {
path,
dependencies: node.dependencies.clone(),
};
if let Some(prev) = index.get(name) {
if prev.path != entry.path {
bail!(
"duplicate script name `{name}` with different paths ({} vs {})",
prev.path.display(),
entry.path.display()
);
}
}
index.insert(name.clone(), entry);
}
}
index_commands(&node.commands, ctx, index)?;
}
Ok(())
}
fn visit_dependency(
name: &str,
index: &BTreeMap<String, ScriptEntry>,
visiting: &mut HashSet<String>,
visited: &mut HashSet<String>,
ordered: &mut Vec<PathBuf>,
) -> Result<()> {
if visited.contains(name) {
return Ok(());
}
if !visiting.insert(name.to_string()) {
bail!("cyclic script dependency involving `{name}`");
}
let Some(entry) = index.get(name) else {
return Ok(());
};
for dep in &entry.dependencies {
visit_dependency(dep, index, visiting, visited, ordered)?;
}
visiting.remove(name);
visited.insert(name.to_string());
ordered.push(entry.path.clone());
Ok(())
}
pub fn resolve_path_prefixes(
spec: &RootSpec,
chain: &[String],
ctx: &RunContext<'_>,
) -> Result<Vec<PathBuf>> {
let (dep_names, _, own_path) = collect_chain_metadata(chain, spec);
let index = index_scripts(spec, ctx)?;
let mut visiting = HashSet::new();
let mut visited = HashSet::new();
let mut dirs = Vec::new();
for name in &dep_names {
visit_dependency(name, &index, &mut visiting, &mut visited, &mut dirs)?;
}
if let Some(raw) = own_path {
if let Ok(own) = resolve_path_str(&raw, ctx) {
if !dirs.iter().any(|p| p == &own) {
dirs.push(own);
}
}
}
Ok(dirs)
}
pub fn prepend_path_env(dirs: &[PathBuf]) -> Result<String> {
let current = std::env::var("PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let mut parts: Vec<String> = dirs
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
if !current.is_empty() {
parts.push(current);
}
Ok(parts.join(sep))
}
pub fn check_requires(requires: &[String]) -> Result<()> {
let mut missing = Vec::new();
for name in requires {
let name = name.trim();
if name.is_empty() {
continue;
}
if !utility_available(name) {
missing.push(name.to_string());
}
}
if missing.is_empty() {
return Ok(());
}
bail!(
"missing required utilities on PATH: {} (install them or adjust the spec `requires` list)",
missing.join(", ")
);
}
fn inherited_path_dirs() -> Vec<PathBuf> {
let Ok(path_var) = std::env::var("PATH") else {
return Vec::new();
};
let sep = if cfg!(windows) { ';' } else { ':' };
path_var
.split(sep)
.filter(|d| !d.is_empty())
.map(PathBuf::from)
.collect()
}
pub fn resolve_program(program: &str, spec_dirs: &[PathBuf]) -> Result<PathBuf> {
resolve_program_in(program, &inherited_path_dirs(), spec_dirs)
}
fn resolve_program_in(
program: &str,
inherited_dirs: &[PathBuf],
spec_dirs: &[PathBuf],
) -> Result<PathBuf> {
let trimmed = program.trim();
if trimmed.is_empty() {
bail!("exec.argv[0] must not be empty");
}
let candidate = Path::new(trimmed);
if candidate.components().count() > 1 {
return Ok(candidate.to_path_buf());
}
if let Some(found) = lookup_in_dirs(trimmed, inherited_dirs) {
return Ok(found);
}
if let Some(found) = lookup_in_dirs(trimmed, spec_dirs) {
return Ok(found);
}
bail!("program `{trimmed}` not found on PATH (from exec.argv)");
}
pub fn utility_available(name: &str) -> bool {
if which_in_path(name) {
return true;
}
Command::new(if cfg!(windows) { "where" } else { "which" })
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn which_in_path(name: &str) -> bool {
lookup_in_dirs(name, &inherited_path_dirs()).is_some()
}
fn lookup_in_dirs(name: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
for dir in dirs {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
{
for ext in ["exe", "cmd", "bat", "com"] {
let with_ext = dir.join(format!("{name}.{ext}"));
if with_ext.is_file() {
return Some(with_ext);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EnvSpec, ExecSpec, SpecRootIdentity};
use std::collections::BTreeMap;
use std::fs;
use tempfile::tempdir;
#[test]
fn transitive_dependencies_prepended_in_order() {
let dir = tempdir().unwrap();
let dep_a = dir.path().join("a");
let dep_b = dir.path().join("b");
let main = dir.path().join("main");
fs::create_dir_all(&dep_a).unwrap();
fs::create_dir_all(&dep_b).unwrap();
fs::create_dir_all(&main).unwrap();
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([
(
"a".into(),
CommandNode {
path: Some(dep_a.to_string_lossy().into_owned()),
dependencies: vec![],
..Default::default()
},
),
(
"b".into(),
CommandNode {
path: Some(dep_b.to_string_lossy().into_owned()),
dependencies: vec!["a".into()],
..Default::default()
},
),
(
"main".into(),
CommandNode {
path: Some(main.to_string_lossy().into_owned()),
dependencies: vec!["b".into()],
commands: BTreeMap::from([(
"run".into(),
CommandNode {
exec: Some(ExecSpec {
argv: vec!["echo".into()],
passthrough: false,
..Default::default()
}),
..Default::default()
},
)]),
..Default::default()
},
),
]),
};
let identity = SpecRootIdentity {
spec_dir: dir.path().to_string_lossy().into_owned(),
root_yaml: "spec.yaml".into(),
};
let ctx = RunContext {
cwd: dir.path(),
db_path: None,
branch: "test".into(),
no_log: true,
spec_root: &identity,
};
let dirs = resolve_path_prefixes(&spec, &["main".into(), "run".into()], &ctx).unwrap();
assert_eq!(dirs.len(), 3);
assert_eq!(dirs[0], dep_a.canonicalize().unwrap());
assert_eq!(dirs[1], dep_b.canonicalize().unwrap());
assert_eq!(dirs[2], main.canonicalize().unwrap());
}
#[test]
fn cyclic_dependency_errors() {
let dir = tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
fs::create_dir_all(&a).unwrap();
fs::create_dir_all(&b).unwrap();
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([
(
"a".into(),
CommandNode {
path: Some(a.to_string_lossy().into_owned()),
dependencies: vec!["b".into()],
..Default::default()
},
),
(
"b".into(),
CommandNode {
path: Some(b.to_string_lossy().into_owned()),
dependencies: vec!["a".into()],
..Default::default()
},
),
(
"run".into(),
CommandNode {
dependencies: vec!["a".into()],
exec: Some(ExecSpec {
argv: vec!["echo".into()],
passthrough: false,
..Default::default()
}),
..Default::default()
},
),
]),
};
let identity = SpecRootIdentity {
spec_dir: dir.path().to_string_lossy().into_owned(),
root_yaml: "spec.yaml".into(),
};
let ctx = RunContext {
cwd: dir.path(),
db_path: None,
branch: "test".into(),
no_log: true,
spec_root: &identity,
};
let err = resolve_path_prefixes(&spec, &["run".into()], &ctx).unwrap_err();
assert!(err.to_string().contains("cyclic"));
}
#[test]
fn chain_env_later_overrides_earlier() {
let spec = RootSpec {
metadata: None,
commands: BTreeMap::from([(
"a".into(),
CommandNode {
env: EnvSpec {
public: BTreeMap::from([
("X".into(), "1".into()),
("Y".into(), "a".into()),
]),
private: vec!["SECRET".into()],
pass: BTreeMap::from([("TOKEN".into(), "github/pat".into())]),
},
commands: BTreeMap::from([(
"b".into(),
CommandNode {
env: EnvSpec {
public: BTreeMap::from([("X".into(), "2".into())]),
private: vec!["OTHER".into()],
pass: BTreeMap::from([("TOKEN".into(), "github/other".into())]),
},
..Default::default()
},
)]),
..Default::default()
},
)]),
};
let env = collect_chain_env(&["a".into(), "b".into()], &spec);
assert_eq!(env.public.get("X").map(String::as_str), Some("2"));
assert_eq!(env.public.get("Y").map(String::as_str), Some("a"));
assert_eq!(env.private, vec!["SECRET".to_string(), "OTHER".to_string()]);
assert_eq!(
env.pass.get("TOKEN").map(String::as_str),
Some("github/other")
);
}
#[test]
fn env_pass_conflicts_with_private() {
let env = EnvSpec {
private: vec!["GH_TOKEN".into()],
pass: BTreeMap::from([("GH_TOKEN".into(), "github/pat".into())]),
..Default::default()
};
let err = env.validate("issue").unwrap_err();
assert!(err
.to_string()
.contains("both `env.private` and `env.pass`"));
}
#[test]
fn fetch_pass_uses_fake_pass_on_path() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("pass");
std::fs::write(
&fake,
"#!/bin/sh\ncase \"$1\" in\n github/pat) printf 's3cret\\nnote line\\n';;\n *) exit 1;;\nesac\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&fake).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&fake, perms).unwrap();
}
let old_path = std::env::var_os("PATH");
let sep = if cfg!(windows) { ";" } else { ":" };
let new_path = match &old_path {
Some(p) => format!("{}{}{}", dir.path().display(), sep, p.to_string_lossy()),
None => dir.path().display().to_string(),
};
std::env::set_var("PATH", &new_path);
let secret = fetch_pass_secret("github/pat").unwrap();
assert_eq!(secret, "s3cret");
match old_path {
Some(p) => std::env::set_var("PATH", p),
None => std::env::remove_var("PATH"),
}
let _ = new_path; }
#[test]
fn check_private_env_reports_missing() {
let err = check_private_env(&["JAN_TEST_MISSING_ENV_VAR_XYZ".to_string()]).unwrap_err();
assert!(err
.to_string()
.contains("missing required environment variables"));
}
#[test]
fn check_requires_reports_missing() {
let err = check_requires(&["definitely-not-a-real-binary-xyz".to_string()]).unwrap_err();
assert!(err.to_string().contains("missing required utilities"));
}
fn touch_executable(dir: &Path, name: &str) -> PathBuf {
let p = dir.join(name);
fs::write(&p, "#!/bin/sh\n").unwrap();
p
}
#[test]
fn spec_path_cannot_shadow_inherited_program() {
let dir = tempdir().unwrap();
let system = dir.path().join("system");
let spec_dir = dir.path().join("spec");
fs::create_dir_all(&system).unwrap();
fs::create_dir_all(&spec_dir).unwrap();
let real = touch_executable(&system, "sh-like");
touch_executable(&spec_dir, "sh-like");
let resolved = resolve_program_in(
"sh-like",
std::slice::from_ref(&system),
std::slice::from_ref(&spec_dir),
)
.unwrap();
assert_eq!(resolved, real);
}
#[test]
fn spec_path_supplies_programs_absent_from_inherited_path() {
let dir = tempdir().unwrap();
let spec_dir = dir.path().join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let helper = touch_executable(&spec_dir, "helper-only");
let resolved =
resolve_program_in("helper-only", &[], std::slice::from_ref(&spec_dir)).unwrap();
assert_eq!(resolved, helper);
}
#[test]
fn unresolvable_program_errors_before_spawn() {
let err = resolve_program_in("definitely-not-a-real-binary-xyz", &[], &[]).unwrap_err();
assert!(err.to_string().contains("not found on PATH"));
}
#[test]
fn explicit_paths_are_left_alone() {
let resolved = resolve_program_in("./tools/build.sh", &[], &[]).unwrap();
assert_eq!(resolved, PathBuf::from("./tools/build.sh"));
}
}