use crate::config::{BootstrapConfig, CommandStep, Config, CopyStep, Guard, NoSymlink};
use crate::error::{GwmError, Result};
use regex::Regex;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct BootstrapReport {
pub steps: Vec<StepResult>,
}
#[derive(Debug, Clone)]
pub struct StepResult {
pub label: String,
pub status: StepStatus,
pub detail: String,
}
impl StepResult {
pub fn ok(label: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Ok,
detail: String::new(),
}
}
pub fn ok_with_detail(label: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Ok,
detail: detail.into(),
}
}
pub fn skipped(label: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Skipped,
detail: reason.into(),
}
}
pub fn warning(label: impl Into<String>, message: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Warning,
detail: message.into(),
}
}
pub fn failed(label: impl Into<String>, message: impl Into<String>) -> Self {
Self {
label: label.into(),
status: StepStatus::Failed,
detail: message.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepStatus {
Ok,
Skipped,
Warning,
Failed,
}
impl StepStatus {
pub fn sigil(&self) -> &'static str {
match self {
StepStatus::Ok => "✓",
StepStatus::Skipped => "·",
StepStatus::Warning => "!",
StepStatus::Failed => "✗",
}
}
}
pub struct BootstrapCtx<'a> {
pub main_repo: &'a Path,
pub worktree: &'a Path,
pub config: &'a Config,
}
pub fn run(ctx: &BootstrapCtx<'_>) -> Result<BootstrapReport> {
let mut report = BootstrapReport { steps: Vec::new() };
let bs = &ctx.config.bootstrap;
run_core_steps(ctx, bs, &mut report);
run_commands(ctx, bs, &mut report);
Ok(report)
}
pub fn run_core(ctx: &BootstrapCtx<'_>) -> Result<BootstrapReport> {
let mut report = BootstrapReport { steps: Vec::new() };
let bs = &ctx.config.bootstrap;
run_core_steps(ctx, bs, &mut report);
Ok(report)
}
fn run_core_steps(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
run_no_symlinks(ctx, bs, report);
run_copies(ctx, bs, report);
}
fn run_copies(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
for step in &bs.copy {
let label = format!("copy {} -> {}", step.from, step.to);
let src = ctx.main_repo.join(&step.from);
let dst = ctx.worktree.join(&step.to);
if let Err(e) = ensure_within(ctx.worktree, &dst) {
report.steps.push(StepResult::failed(
label,
format!("destination outside worktree: {}", e),
));
continue;
}
match std::fs::symlink_metadata(&dst) {
Ok(meta) if meta.file_type().is_symlink() => {
report.steps.push(StepResult::failed(
label,
format!(
"refusing to copy: destination {} is a symlink — would redirect the write outside the worktree (issue #93)",
dst.display()
),
));
continue;
}
Ok(_) => {
report.steps.push(StepResult::skipped(
label,
"destination already exists, leaving it alone",
));
continue;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
report.steps.push(StepResult::failed(
label,
format!(
"failed to stat destination {}: {} — refusing to proceed with unknown filesystem state",
dst.display(),
e
),
));
continue;
}
}
if !src.exists() {
match resolve_missing(step, bs, &dst) {
Some(res) => report.steps.push(StepResult { label, ..res }),
None => {
if step.required {
report.steps.push(StepResult::failed(label, "required source missing"));
} else {
report.steps.push(StepResult::skipped(label, "optional source missing"));
}
}
}
continue;
}
match guard_match(step, bs, &src) {
Ok(Some(g)) => {
handle_guard_match(&g, &src, &dst, ctx, report, &label);
continue;
}
Ok(None) => {}
Err(detail) => {
report.steps.push(StepResult::failed(label, detail));
continue;
}
}
match copy_no_follow(&src, &dst) {
Ok(()) => report.steps.push(StepResult::ok_with_detail(
label,
format!("copied from {}", src.display()),
)),
Err(e) => report
.steps
.push(StepResult::failed(label, format!("copy failed: {}", e))),
}
}
}
fn resolve_missing(step: &CopyStep, bs: &BootstrapConfig, dst: &Path) -> Option<StepResult> {
let mode = step.fallback.as_deref().unwrap_or("skip");
match mode {
"inline" => {
let key = key_from_to(&step.to);
let fb = bs.fallback.get(&key)?;
match write_no_follow(dst, fb.content.as_bytes()) {
Ok(()) => Some(StepResult::warning(
"",
format!("source missing — wrote inline fallback to {}", dst.display()),
)),
Err(e) => Some(StepResult::failed("", format!("inline fallback write failed: {}", e))),
}
}
"abort" => Some(StepResult::failed("", "source missing and fallback=abort")),
_ => None,
}
}
fn key_from_to(to: &str) -> String {
to.trim_start_matches('.').replace(['.', '-'], "_")
}
fn guard_match(step: &CopyStep, bs: &BootstrapConfig, src: &Path) -> std::result::Result<Option<Guard>, String> {
if step.guards.is_empty() {
return Ok(None);
}
let Ok(content) = std::fs::read_to_string(src) else {
return Ok(None);
};
for guard_name in &step.guards {
let Some(guard) = bs.guard.iter().find(|g| &g.name == guard_name) else {
return Ok(None);
};
for pat in &guard.deny_patterns {
match Regex::new(pat) {
Ok(re) => {
if re.is_match(&content) {
return Ok(Some(guard.clone()));
}
}
Err(e) => {
return Err(format!(
"guard '{}' deny_pattern {:?} failed to compile at evaluation time — \
Config bypassed Config::load_for_repo (#96)? regex: {}",
guard.name, pat, e
));
}
}
}
}
Ok(None)
}
fn handle_guard_match(
guard: &Guard,
src: &Path,
dst: &Path,
ctx: &BootstrapCtx<'_>,
report: &mut BootstrapReport,
label: &str,
) {
match guard.on_match.as_str() {
"seed-from-example" => {
let example_rel = guard.example_file.as_deref().unwrap_or(".env.example");
let example_src = ctx.main_repo.join(example_rel);
if let Err(e) = ensure_within(ctx.main_repo, &example_src) {
report.steps.push(StepResult::failed(
label,
format!(
"guard '{}' example_file outside main repo: {} (traversal rejected, issue #94)",
guard.name, e
),
));
return;
}
if example_src.exists() {
match copy_no_follow(&example_src, dst) {
Ok(_) => report.steps.push(StepResult::warning(
label,
format!(
"guard '{}' tripped on {} — seeded {} from {} (edit before use)",
guard.name,
src.display(),
dst.display(),
example_src.display()
),
)),
Err(e) => report.steps.push(StepResult::failed(
label,
format!("guard '{}' seed-from-example failed: {}", guard.name, e),
)),
}
} else {
report.steps.push(StepResult::failed(
label,
format!(
"guard '{}' tripped and no example_file {} available",
guard.name,
example_src.display()
),
));
}
}
_ => {
report.steps.push(StepResult::failed(
label,
format!("guard '{}' tripped on {} — abort", guard.name, src.display()),
));
}
}
}
fn run_no_symlinks(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
for ns in &bs.no_symlink {
let label = format!("no-symlink {}", ns.path);
let target: PathBuf = ctx.worktree.join(&ns.path);
handle_no_symlink(&label, &target, report);
}
for default in ["vendor", "node_modules"] {
if bs.no_symlink.iter().any(|n: &NoSymlink| n.path == default) {
continue;
}
let target = ctx.worktree.join(default);
if target.is_symlink() {
handle_no_symlink(&format!("no-symlink {} (auto)", default), &target, report);
}
}
}
fn handle_no_symlink(label: &str, target: &Path, report: &mut BootstrapReport) {
if !target.exists() && !target.is_symlink() {
report.steps.push(StepResult::skipped(label, "not present"));
return;
}
if target.is_symlink() {
match std::fs::remove_file(target) {
Ok(_) => report.steps.push(StepResult::warning(
label,
format!("removed symlink {}", target.display()),
)),
Err(e) => report.steps.push(StepResult::failed(
label,
format!("failed to remove symlink {}: {}", target.display(), e),
)),
}
} else {
report
.steps
.push(StepResult::ok_with_detail(label, "real directory, ok"));
}
}
fn run_commands(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
for step in &bs.command {
let label = format!("run {}", step.name);
if let Some(ref guard) = step.when {
if !evaluate_when(guard, ctx.worktree) {
report
.steps
.push(StepResult::skipped(label, format!("when condition '{}' false", guard)));
continue;
}
}
match exec_shell(step, ctx.worktree) {
Ok(output) => report
.steps
.push(StepResult::ok_with_detail(label, trailing_lines(&output, 3))),
Err(e) => report.steps.push(StepResult::failed(label, e.to_string())),
}
}
}
pub fn evaluate_when(expr: &str, cwd: &Path) -> bool {
let tokens = tokenize_when(expr);
let mut parser = WhenParser {
tokens: &tokens,
pos: 0,
cwd,
};
parser.parse_or()
}
pub fn when_atoms(expr: &str) -> Vec<String> {
tokenize_when(expr)
.into_iter()
.filter_map(|t| match t {
WhenToken::Atom(s) => Some(s),
_ => None,
})
.collect()
}
#[derive(Debug, PartialEq, Eq)]
enum WhenToken {
Atom(String),
Not,
And,
Or,
}
fn tokenize_when(expr: &str) -> Vec<WhenToken> {
let bytes = expr.as_bytes();
let mut tokens = Vec::new();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_whitespace() {
i += 1;
continue;
}
if c == b'!' {
tokens.push(WhenToken::Not);
i += 1;
continue;
}
if c == b'&' && bytes.get(i + 1) == Some(&b'&') {
tokens.push(WhenToken::And);
i += 2;
continue;
}
if c == b'|' && bytes.get(i + 1) == Some(&b'|') {
tokens.push(WhenToken::Or);
i += 2;
continue;
}
let start = i;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_whitespace() {
break;
}
if b == b'&' && bytes.get(i + 1) == Some(&b'&') {
break;
}
if b == b'|' && bytes.get(i + 1) == Some(&b'|') {
break;
}
i += 1;
}
tokens.push(WhenToken::Atom(expr[start..i].to_string()));
}
tokens
}
struct WhenParser<'a> {
tokens: &'a [WhenToken],
pos: usize,
cwd: &'a Path,
}
impl<'a> WhenParser<'a> {
fn peek(&self) -> Option<&WhenToken> {
self.tokens.get(self.pos)
}
fn parse_or(&mut self) -> bool {
let mut acc = self.parse_and();
while let Some(WhenToken::Or) = self.peek() {
self.pos += 1;
let rhs = self.parse_and();
acc = acc || rhs;
}
acc
}
fn parse_and(&mut self) -> bool {
let mut acc = self.parse_not();
while let Some(WhenToken::And) = self.peek() {
self.pos += 1;
let rhs = self.parse_not();
acc = acc && rhs;
}
acc
}
fn parse_not(&mut self) -> bool {
if let Some(WhenToken::Not) = self.peek() {
self.pos += 1;
return !self.parse_not();
}
self.parse_atom()
}
fn parse_atom(&mut self) -> bool {
match self.tokens.get(self.pos) {
Some(WhenToken::Atom(s)) => {
self.pos += 1;
eval_when_atom(s, self.cwd)
}
_ => true,
}
}
}
fn eval_when_atom(atom: &str, cwd: &Path) -> bool {
if let Some(rest) = atom.strip_prefix("file_exists:") {
return cwd.join(rest.trim()).exists();
}
if let Some(rest) = atom.strip_prefix("cmd_exists:") {
return which::which(rest.trim()).is_ok();
}
if let Some(rest) = atom.strip_prefix("env_set:") {
return std::env::var(rest.trim()).is_ok();
}
if let Some(rest) = atom.strip_prefix("env_eq:") {
let Some((name, value)) = rest.split_once('=') else {
return false;
};
return std::env::var(name.trim()).ok().as_deref() == Some(value);
}
if let Some(pattern) = atom.strip_prefix("glob_exists:") {
return glob_exists(pattern.trim(), cwd);
}
true
}
fn glob_exists(pattern: &str, cwd: &Path) -> bool {
let full = cwd.join(pattern);
let Some(full_str) = full.to_str() else {
return false;
};
match glob::glob(full_str) {
Ok(mut iter) => iter.any(|r| r.is_ok()),
Err(_) => false,
}
}
fn exec_shell(step: &CommandStep, cwd: &Path) -> Result<String> {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&step.run).current_dir(cwd);
for (k, v) in &step.env {
cmd.env(k, v);
}
let out = crate::command_log::run_logged(&mut cmd, step.run.clone())
.map_err(|e| GwmError::CommandFailed(format!("bootstrap step '{}': {}", step.name, e)))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
return Err(GwmError::CommandFailed(format!(
"bootstrap step '{}' exited with {}\n{}",
step.name,
out.status,
if stderr.is_empty() { stdout } else { stderr }
)));
}
Ok(if stdout.is_empty() { stderr } else { stdout })
}
pub fn trailing_lines(s: &str, n: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
pub fn copy_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
let mut buf = Vec::new();
std::fs::File::open(src)?.read_to_end(&mut buf)?;
#[cfg(unix)]
let src_perms = std::fs::metadata(src)?.permissions();
write_no_follow(dst, &buf)?;
#[cfg(unix)]
std::fs::set_permissions(dst, src_perms)?;
Ok(())
}
fn ensure_within(base: &Path, path: &Path) -> std::io::Result<()> {
let base_canon = base.canonicalize()?;
let mut anc: &Path = path;
let canon_anc = loop {
if let Ok(c) = anc.canonicalize() {
break c;
}
match anc.parent() {
Some(p) if !p.as_os_str().is_empty() => anc = p,
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("cannot resolve any ancestor of {:?}", path),
));
}
}
};
if !canon_anc.starts_with(&base_canon) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{:?} resolves outside {:?} — '..' traversal, absolute path, or symlinked intermediate component rejected (issue #94)",
path, base_canon
),
));
}
Ok(())
}
pub fn write_no_follow(dst: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.custom_flags(libc::O_NOFOLLOW);
}
let mut f = opts.open(dst)?;
f.write_all(bytes)?;
Ok(())
}