use clap::{Parser, Subcommand, ValueEnum};
use minijinja::{AutoEscape, Environment, UndefinedBehavior};
use serde::Serialize;
use serde_json::Value;
use std::fs;
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const PORTABLE_SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
const MAX_FILE_BYTES: u64 = 128 * 1024;
const MAX_RENDERED_BYTES: usize = 1024 * 1024;
const ROLES: [&str; 9] = [
"auditor",
"coder",
"conductor",
"critic",
"discovery",
"engineer",
"planter",
"shepherd",
"worker",
];
const CHILD_ROLES: [&str; 7] = [
"auditor",
"coder",
"conductor",
"critic",
"discovery",
"engineer",
"worker",
];
const SKILLS: [&str; 20] = [
"adaptation",
"artifact-work",
"bridge",
"context",
"debugging",
"harness",
"implementing",
"lane-execution",
"motivation",
"planning",
"plant",
"planting",
"researching",
"reviewing",
"shepherd",
"spawn",
"sprint-graph",
"start",
"thinking",
"verification",
];
#[derive(Parser)]
#[command(
name = "shepherd",
version,
about = "Focused workspace setup, diagnostics, and handoff artifacts"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(long, default_value = ".")]
root: PathBuf,
},
Lane {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
run: String,
#[arg(long)]
name: String,
},
Doctor {
#[arg(long, value_enum)]
host: Host,
#[arg(long)]
plugin_root: PathBuf,
#[arg(long)]
json: bool,
},
Handoff {
#[arg(long, value_enum)]
host: Host,
#[arg(long)]
plugin_root: PathBuf,
#[arg(long)]
context: PathBuf,
#[arg(long)]
output: Option<PathBuf>,
},
}
#[derive(Clone, Copy, ValueEnum, Serialize)]
#[serde(rename_all = "lowercase")]
enum Host {
Claude,
Codex,
Pi,
}
#[derive(Serialize)]
struct Report {
schema: &'static str,
host: Host,
plugin_root: String,
helper_path: Option<String>,
helper_version: &'static str,
plugin_version: Option<String>,
ok: bool,
findings: Vec<String>,
warnings: Vec<String>,
}
fn main() {
let cli = Cli::parse();
match cli.command {
Command::Init { root } => {
if let Err(error) = init_workspace(&root) {
eprintln!("shepherd init: {error}");
std::process::exit(1);
}
}
Command::Lane { root, run, name } => {
if let Err(error) = init_lane(&root, &run, &name) {
eprintln!("shepherd lane: {error}");
std::process::exit(1);
}
}
Command::Doctor {
host,
plugin_root,
json,
} => {
let report = diagnose(host, &plugin_root);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).expect("report serialization")
);
} else {
println!(
"Shepherd doctor: {}",
if report.ok { "ok" } else { "failed" }
);
println!("host: {}", host_name(host));
println!("plugin: {}", report.plugin_root);
println!(
"helper: {} ({VERSION})",
report.helper_path.as_deref().unwrap_or("unknown")
);
for finding in &report.findings {
println!("ERROR: {finding}");
}
for warning in &report.warnings {
println!("WARN: {warning}");
}
}
if !report.ok {
std::process::exit(1);
}
}
Command::Handoff {
host,
plugin_root,
context,
output,
} => {
if let Err(error) = handoff(host, &plugin_root, &context, output.as_deref()) {
eprintln!("shepherd handoff: {error}");
std::process::exit(1);
}
}
}
}
const WORKSPACE_IGNORE: &str = "# Shepherd local state\n*\n!.gitignore\n!runs/\n!runs/*/\n!runs/*/*.md\n!runs/*/lanes/\n!runs/*/lanes/*/\n!runs/*/lanes/*/*.md\n!runs/*/lanes/*/evidence/\n!runs/*/lanes/*/evidence/*.md\n!runs/*/lanes/*/evidence/*.json\n!runs/*/lanes/*/evidence/*.txt\n!runs/*/lanes/*/evidence/*.sh\n";
fn project_root(requested: &Path) -> Result<PathBuf, String> {
let root = requested
.canonicalize()
.map_err(|error| format!("cannot read project root {}: {error}", requested.display()))?;
if !root.is_dir() {
return Err(format!(
"project root is not a directory: {}",
root.display()
));
}
Ok(root)
}
fn init_workspace(requested_root: &Path) -> Result<(), String> {
let root = project_root(requested_root)?;
let workspace = root.join(".shepherd");
safe_create_dir(&root, Path::new(".shepherd/runs"))?;
write_expected(&workspace.join(".gitignore"), WORKSPACE_IGNORE.as_bytes())?;
println!("initialized {}", workspace.display());
Ok(())
}
fn init_lane(requested_root: &Path, run: &str, lane: &str) -> Result<(), String> {
validate_identifier("run", run)?;
validate_identifier("lane", lane)?;
let root = project_root(requested_root)?;
let workspace = root.join(".shepherd");
if !workspace.join(".gitignore").is_file() {
return Err(format!(
"{} is not initialized; run 'shepherd init --root {}' first",
workspace.display(),
root.display()
));
}
let workspace = workspace
.canonicalize()
.map_err(|error| format!("cannot resolve {}: {error}", workspace.display()))?;
if !workspace.starts_with(&root) {
return Err(".shepherd must not escape the project root".into());
}
let relative = PathBuf::from("runs")
.join(run)
.join("lanes")
.join(lane)
.join("evidence");
safe_create_dir(&workspace, &relative)?;
let lane_root = workspace.join("runs").join(run).join("lanes").join(lane);
let plan = format!(
"# Lane {lane}\n\n## Outcome\n\n## Owned paths\n\n## Dependencies\n\n## Steps and checks\n"
);
let ledger = format!(
"# Lane ledger: {lane}\n\n- Run: `{run}`\n- Status: pending\n- Worktree or artifact root:\n- Branch/base:\n- Owner:\n\n## Dispatches and gates\n"
);
write_expected(&lane_root.join("plan.md"), plan.as_bytes())?;
write_expected(&lane_root.join("ledger.md"), ledger.as_bytes())?;
println!("initialized {}", lane_root.display());
Ok(())
}
fn validate_identifier(label: &str, value: &str) -> Result<(), String> {
let valid = !value.is_empty()
&& value.len() <= 80
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
&& value != "."
&& value != "..";
if valid {
Ok(())
} else {
Err(format!(
"{label} must be 1-80 ASCII letters, digits, '.', '-', or '_'"
))
}
}
fn write_expected(path: &Path, expected: &[u8]) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(_) => match existing_output_bytes(path)? {
existing if existing == expected => Ok(()),
_ => Err(format!(
"refusing to overwrite existing file with different content: {}",
path.display()
)),
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
write_no_clobber(path, expected)
}
Err(error) => Err(format!("cannot inspect {}: {error}", path.display())),
}
}
fn safe_create_dir(base: &Path, relative: &Path) -> Result<(), String> {
let mut current = base.to_path_buf();
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(format!(
"directory path must be relative: {}",
relative.display()
));
};
current.push(name);
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => {
return Err(format!(
"workspace path is linked or not a directory: {}",
current.display()
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(¤t)
.map_err(|error| format!("cannot create {}: {error}", current.display()))?,
Err(error) => return Err(format!("cannot inspect {}: {error}", current.display())),
}
}
Ok(())
}
fn host_name(host: Host) -> &'static str {
match host {
Host::Claude => "claude",
Host::Codex => "codex",
Host::Pi => "pi",
}
}
fn manifest_location(host: Host, root: &Path) -> (&'static str, bool) {
match host {
Host::Codex if root.join("plugin.json").exists() => ("plugin.json", false),
Host::Codex if root.join(".codex-plugin/plugin.json").exists() => {
(".codex-plugin/plugin.json", false)
}
Host::Claude if root.join(".claude-plugin/plugin.json").exists() => {
(".claude-plugin/plugin.json", false)
}
Host::Codex if !root.join("package.json").exists() => (".codex-plugin/plugin.json", false),
Host::Claude if !root.join("package.json").exists() => {
(".claude-plugin/plugin.json", false)
}
_ => ("package.json", true),
}
}
fn codex_settings(root: &Path, manifest: &Value, portable: bool) -> Result<Value, String> {
if !portable {
return Ok(manifest.clone());
}
if manifest.get("$schema").and_then(Value::as_str) != Some(PORTABLE_SCHEMA) {
return Err("portable plugin manifest has an unsupported schema".into());
}
match manifest.pointer("/extensions/com.openai") {
Some(settings) if settings.is_object() => Ok(settings.clone()),
Some(_) => Err("portable extensions.com.openai must be an object".into()),
None if root.join(".codex-plugin/plugin.json").exists() => {
let path = contained_regular_file(root, Path::new(".codex-plugin/plugin.json"))?;
serde_json::from_slice(&bounded_read(&path)?)
.map_err(|error| format!("compatibility manifest is not valid JSON: {error}"))
}
None => Ok(serde_json::json!({})),
}
}
fn diagnose(host: Host, requested_root: &Path) -> Report {
let mut report = Report {
schema: "shepherd.doctor/1",
host,
plugin_root: requested_root.display().to_string(),
helper_path: std::env::current_exe()
.ok()
.map(|path| path.display().to_string()),
helper_version: VERSION,
plugin_version: None,
ok: false,
findings: Vec::new(),
warnings: Vec::new(),
};
if !requested_root.is_absolute() {
report
.findings
.push("--plugin-root must be an absolute installed plugin path".into());
return report;
}
let root = match requested_root.canonicalize() {
Ok(root) if root.is_dir() => root,
Ok(_) => {
report.findings.push(format!(
"plugin root is not a directory: {}",
requested_root.display()
));
return report;
}
Err(error) => {
report.findings.push(format!(
"cannot read plugin root {}: {error}",
requested_root.display()
));
return report;
}
};
report.plugin_root = root.display().to_string();
let (manifest_name, package_carrier) = manifest_location(host, &root);
let manifest = required_json(&root, Path::new(manifest_name), &mut report);
if let Some(manifest) = manifest {
let expected_name = match (host, package_carrier) {
(Host::Claude, true) => "@pzzld/claude-shepherd",
(Host::Codex, true) => "@pzzld/codex-shepherd",
(Host::Pi, _) => "@pzzld/pi-shepherd",
_ => "shepherd",
};
if manifest.get("name").and_then(Value::as_str) != Some(expected_name) {
report
.findings
.push(format!("plugin manifest name must be {expected_name}"));
}
match manifest.get("version").and_then(Value::as_str) {
Some(version) => {
report.plugin_version = Some(version.to_owned());
if version != VERSION {
report.findings.push(format!(
"plugin version {version} differs from installed shepherd helper {VERSION}; install matching shepherd-cli and plugin versions"
));
}
}
None => report
.findings
.push("plugin manifest has no string version".into()),
}
inspect_host(host, &root, &manifest, package_carrier, &mut report);
}
report.warnings.push("file checks do not prove host hook trust, event delivery, or child dispatch; verify those in the selected host".into());
report.ok = report.findings.is_empty();
report
}
fn inspect_host(
host: Host,
root: &Path,
manifest: &Value,
package_carrier: bool,
report: &mut Report,
) {
let portable = matches!(host, Host::Codex) && root.join("plugin.json").exists();
let settings = if matches!(host, Host::Codex) && !package_carrier {
match codex_settings(root, manifest, portable) {
Ok(settings) => settings,
Err(error) => {
report.findings.push(error);
return;
}
}
} else {
manifest.clone()
};
let (skill_root, role_root, template_root) = match host {
Host::Claude => (
PathBuf::from("skills"),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
Host::Codex if !package_carrier => {
let skills = if portable {
PathBuf::from("skills")
} else {
declared_directory(root, settings.get("skills"), "skills", report)
.unwrap_or_else(|| PathBuf::from("missing-skills"))
};
let parent = skills.parent().unwrap_or_else(|| Path::new(""));
let roles = parent.join("roles");
let templates = parent.join("templates");
(skills, roles, templates)
}
Host::Codex => (
PathBuf::from("skills"),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
Host::Pi => (
declared_directory(root, manifest.pointer("/pi/skills/0"), "pi.skills", report)
.unwrap_or_else(|| PathBuf::from("missing-skills")),
PathBuf::from("roles"),
PathBuf::from("templates"),
),
};
for role in ROLES {
required_file(root, &role_root.join(format!("{role}.md")), report);
}
required_file(root, &template_root.join("handoff.md"), report);
required_file(root, &template_root.join("fold.md"), report);
for skill in SKILLS {
let file = skill_root.join(skill).join("SKILL.md");
if let Some(path) = required_file(root, &file, report) {
match bounded_read(&path).and_then(|bytes| {
String::from_utf8(bytes).map_err(|error| {
format!("installed skill is not UTF-8: {}: {error}", file.display())
})
}) {
Ok(body) => inspect_references(root, &file, &body, report),
Err(error) => report.findings.push(error),
}
}
}
match host {
Host::Claude => {
inspect_child_agents(root, Path::new("agents"), report);
inspect_hook(
root,
Path::new("hooks/hooks.json"),
"Write",
Path::new("hooks/scripts/seed_preflight_check.sh"),
report,
);
}
Host::Codex => {
if package_carrier {
report.warnings.push("Codex npm carrier contains role and skill resources only; inspect the marketplace plugin root to check its hook registration".into());
} else {
let default_hooks = Value::String("./hooks/hooks.json".into());
let hook_value = settings.get("hooks").or(portable.then_some(&default_hooks));
let hooks = declared_file(root, hook_value, "hooks", report);
if let Some(hooks) = hooks {
let script = hooks
.parent()
.unwrap_or_else(|| Path::new(""))
.join("scripts/seed_preflight_check.sh");
inspect_hook(root, &hooks, "^apply_patch$", &script, report);
}
}
}
Host::Pi => {
let prompts = declared_directory(
root,
manifest.pointer("/pi/prompts/0"),
"pi.prompts",
report,
);
let agents = declared_directory(
root,
manifest.pointer("/pi/subagents/agents/0"),
"pi.subagents.agents",
report,
);
if let Some(prompts) = prompts {
for role in ROLES {
required_file(root, &prompts.join(format!("{role}.md")), report);
}
}
if let Some(agents) = agents {
inspect_child_agents(root, &agents, report);
}
}
}
}
fn inspect_child_agents(root: &Path, relative: &Path, report: &mut Report) {
for role in CHILD_ROLES {
required_file(root, &relative.join(format!("{role}.md")), report);
}
let directory = root.join(relative);
let physical = match directory.canonicalize() {
Ok(path) if path.starts_with(root) && path.is_dir() => path,
_ => return,
};
match fs::read_dir(&physical) {
Ok(entries) => {
for entry in entries {
match entry {
Ok(entry) => {
let path = entry.path();
if path.extension().is_some_and(|extension| extension == "md") {
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("");
if !CHILD_ROLES.contains(&name) {
report.findings.push(format!(
"unexpected child agent: {}",
path.file_name().unwrap().to_string_lossy()
));
}
}
}
Err(error) => report
.findings
.push(format!("cannot inspect child agent directory: {error}")),
}
}
}
Err(error) => report.findings.push(format!(
"cannot inspect child agent directory {}: {error}",
relative.display()
)),
}
}
fn declared_directory(
root: &Path,
value: Option<&Value>,
label: &str,
report: &mut Report,
) -> Option<PathBuf> {
let relative = declared_path(value, label, report)?;
let candidate = root.join(&relative);
match candidate.canonicalize() {
Ok(physical) if physical.starts_with(root) && physical.is_dir() => Some(relative),
_ => {
report.findings.push(format!(
"declared {label} directory is missing or escapes plugin root: {}",
relative.display()
));
None
}
}
}
fn declared_file(
root: &Path,
value: Option<&Value>,
label: &str,
report: &mut Report,
) -> Option<PathBuf> {
let relative = declared_path(value, label, report)?;
required_file(root, &relative, report).map(|_| relative)
}
fn declared_path(value: Option<&Value>, label: &str, report: &mut Report) -> Option<PathBuf> {
match relative_declared_path(value, label) {
Ok(path) => Some(path),
Err(error) => {
report.findings.push(error);
None
}
}
}
fn relative_declared_path(value: Option<&Value>, label: &str) -> Result<PathBuf, String> {
let Some(value) = value.and_then(Value::as_str) else {
return Err(format!("manifest has no string {label} path"));
};
let relative = PathBuf::from(value);
if relative.is_absolute()
|| !relative
.components()
.all(|part| matches!(part, Component::CurDir | Component::Normal(_)))
{
return Err(format!(
"declared {label} path must stay inside plugin root: {value}"
));
}
Ok(relative
.components()
.filter(|part| !matches!(part, Component::CurDir))
.collect())
}
fn required_file(root: &Path, relative: &Path, report: &mut Report) -> Option<PathBuf> {
let candidate = root.join(relative);
match candidate.canonicalize() {
Ok(physical) if physical.starts_with(root) && physical.is_file() => Some(physical),
Ok(_) => {
report.findings.push(format!(
"required file escapes plugin root or is not regular: {}",
relative.display()
));
None
}
Err(_) => {
report.findings.push(format!(
"required installed file is missing: {}; reinstall the matching Shepherd plugin",
relative.display()
));
None
}
}
}
fn required_json(root: &Path, relative: &Path, report: &mut Report) -> Option<Value> {
let file = required_file(root, relative, report)?;
let bytes = match bounded_read(&file) {
Ok(bytes) => bytes,
Err(error) => {
report.findings.push(error);
return None;
}
};
match serde_json::from_slice(&bytes) {
Ok(value) => Some(value),
Err(error) => {
report.findings.push(format!(
"manifest is not valid JSON: {}: {error}",
relative.display()
));
None
}
}
}
fn inspect_hook(
root: &Path,
manifest_path: &Path,
matcher: &str,
script_path: &Path,
report: &mut Report,
) {
if let Some(manifest) = required_json(root, manifest_path, report) {
let expected_command = if matcher == "Write" {
format!("bash \"${{CLAUDE_PLUGIN_ROOT}}/{}\"", script_path.display())
} else {
format!("bash \"$PLUGIN_ROOT/{}\"", script_path.display())
};
let expected_hooks = serde_json::json!({"PreToolUse": [{
"matcher": matcher,
"hooks": [{"type": "command", "command": expected_command, "timeout": 5}]
}]});
let valid = manifest.as_object().is_some_and(|object| {
object.len() == 2
&& object.get("description").is_some_and(Value::is_string)
&& object.get("hooks") == Some(&expected_hooks)
});
if !valid {
report.findings.push(format!(
"installed hook manifest does not register the bounded {matcher} seed check: {}",
manifest_path.display()
));
}
}
if let Some(script) = required_file(root, script_path, report) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if fs::metadata(&script)
.is_ok_and(|metadata| metadata.permissions().mode() & 0o111 == 0)
{
report.findings.push(format!(
"installed hook is not executable: {}",
script_path.display()
));
}
}
}
if !on_path("jq") {
report.warnings.push("jq is absent on PATH; the seed hook defers supplemental validation to host permissions".into());
}
}
fn on_path(name: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| {
std::env::split_paths(&paths).any(|directory| {
let candidate = directory.join(name);
executable(&candidate)
|| cfg!(windows) && executable(&directory.join(format!("{name}.exe")))
})
})
}
fn executable(path: &Path) -> bool {
if !path.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
true
}
}
fn inspect_references(root: &Path, skill_file: &Path, body: &str, report: &mut Report) {
for (index, token) in body.split('`').enumerate() {
if index % 2 == 1 && token.starts_with("references/") && token.ends_with(".md") {
required_file(root, &skill_file.parent().unwrap().join(token), report);
}
}
for fragment in body.split("](").skip(1) {
let Some(target) = fragment.split(')').next() else {
continue;
};
let target = target.split('#').next().unwrap_or("");
if target.is_empty()
|| target.contains("://")
|| target.starts_with("mailto:")
|| target.contains(['*', '?', '{', '}', '<', '>'])
{
continue;
}
required_file(root, &skill_file.parent().unwrap().join(target), report);
}
}
fn handoff(
host: Host,
requested_root: &Path,
context_path: &Path,
output_path: Option<&Path>,
) -> Result<(), String> {
if !requested_root.is_absolute() {
return Err("--plugin-root must be an absolute installed plugin path".into());
}
let root = requested_root.canonicalize().map_err(|error| {
format!(
"cannot read plugin root {}: {error}",
requested_root.display()
)
})?;
if !root.is_dir() {
return Err(format!(
"plugin root is not a directory: {}",
root.display()
));
}
let (manifest_name, package_carrier) = manifest_location(host, &root);
let expected_name = match (host, package_carrier) {
(Host::Claude, true) => "@pzzld/claude-shepherd",
(Host::Codex, true) => "@pzzld/codex-shepherd",
(Host::Pi, _) => "@pzzld/pi-shepherd",
_ => "shepherd",
};
let manifest_file = contained_regular_file(&root, Path::new(manifest_name))?;
let manifest: Value = serde_json::from_slice(&bounded_read(&manifest_file)?)
.map_err(|error| format!("installed manifest {manifest_name} is invalid JSON: {error}"))?;
if manifest.get("name").and_then(Value::as_str) != Some(expected_name) {
return Err(format!(
"installed manifest name differs from {expected_name}"
));
}
let version = manifest
.get("version")
.and_then(Value::as_str)
.ok_or_else(|| "installed manifest has no string version".to_owned())?;
if version != VERSION {
return Err(format!(
"plugin version {version} differs from shepherd helper {VERSION}; install matching versions"
));
}
let template_name = if matches!(host, Host::Codex) && !package_carrier {
let portable = manifest_name == "plugin.json";
let settings = codex_settings(&root, &manifest, portable)?;
let skills = if portable {
PathBuf::from("skills")
} else {
relative_declared_path(settings.get("skills"), "skills")?
};
skills
.parent()
.unwrap_or_else(|| Path::new(""))
.join("templates/handoff.md")
} else {
PathBuf::from("templates/handoff.md")
};
let template_file = contained_regular_file(&root, &template_name)?;
let template_source = String::from_utf8(bounded_read(&template_file)?)
.map_err(|error| format!("installed handoff template is not UTF-8: {error}"))?;
let context_file = context_path
.canonicalize()
.map_err(|error| format!("cannot read context {}: {error}", context_path.display()))?;
if !context_file.is_file() {
return Err(format!(
"context is not a regular file: {}",
context_path.display()
));
}
let context: Value = serde_json::from_slice(&bounded_read(&context_file)?)
.map_err(|error| format!("context is not valid JSON: {error}"))?;
if !context.is_object() {
return Err("context must be a JSON object".into());
}
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
env.set_keep_trailing_newline(true);
env.set_auto_escape_callback(|_| AutoEscape::None);
let rendered = env
.template_from_str(&template_source)
.map_err(|error| format!("installed handoff template is invalid: {error}"))?
.render(&context)
.map_err(|error| format!("handoff context does not satisfy installed template: {error}"))?;
if rendered.trim().is_empty() || rendered.len() > MAX_RENDERED_BYTES {
return Err(format!(
"rendered handoff is blank or exceeds {MAX_RENDERED_BYTES} bytes"
));
}
match output_path {
None => print!("{rendered}"),
Some(path) => write_no_clobber(path, rendered.as_bytes())?,
}
Ok(())
}
fn contained_regular_file(root: &Path, relative: &Path) -> Result<PathBuf, String> {
let physical = root.join(relative).canonicalize().map_err(|error| {
format!(
"required installed file is missing: {}: {error}",
relative.display()
)
})?;
if !physical.starts_with(root) || !physical.is_file() {
return Err(format!(
"required installed file escapes plugin root or is not regular: {}",
relative.display()
));
}
Ok(physical)
}
fn bounded_read(path: &Path) -> Result<Vec<u8>, String> {
let file =
fs::File::open(path).map_err(|error| format!("cannot open {}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.take(MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
if bytes.len() as u64 > MAX_FILE_BYTES {
return Err(format!(
"input exceeds {MAX_FILE_BYTES} bytes: {}",
path.display()
));
}
Ok(bytes)
}
fn write_no_clobber(path: &Path, bytes: &[u8]) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(_) => {
let existing = existing_output_bytes(path)?;
return if existing == bytes {
Ok(())
} else {
Err(format!(
"output already exists with different bytes: {}",
path.display()
))
};
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("cannot inspect output {}: {error}", path.display())),
}
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
if !parent.is_dir() {
return Err(format!(
"output parent is not an existing directory: {}",
parent.display()
));
}
let mut staged = tempfile::NamedTempFile::new_in(parent)
.map_err(|error| format!("cannot stage output next to {}: {error}", path.display()))?;
staged
.write_all(bytes)
.and_then(|_| staged.flush())
.and_then(|_| staged.as_file().sync_all())
.map_err(|error| format!("cannot write staged output: {error}"))?;
match staged.persist_noclobber(path) {
Ok(_) => Ok(()),
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => Err(format!(
"output appeared during publication; no existing file was replaced: {}",
path.display()
)),
Err(error) => Err(format!(
"cannot publish output {}: {}",
path.display(),
error.error
)),
}
}
fn existing_output_bytes(path: &Path) -> Result<Vec<u8>, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("cannot inspect existing output {}: {error}", path.display()))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(format!(
"existing output is linked or non-regular: {}",
path.display()
));
}
let file = fs::File::open(path)
.map_err(|error| format!("cannot open existing output {}: {error}", path.display()))?;
let mut bytes = Vec::new();
file.take(MAX_RENDERED_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("cannot read existing output {}: {error}", path.display()))?;
if bytes.len() > MAX_RENDERED_BYTES {
return Err(format!(
"existing output exceeds {MAX_RENDERED_BYTES} bytes: {}",
path.display()
));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
#[test]
#[cfg(unix)]
fn read_bound_applies_to_opened_stream_not_only_stat_size() {
let result = super::bounded_read(std::path::Path::new("/dev/zero"));
assert!(result.unwrap_err().contains("input exceeds"));
}
}