1use crate::config::{BootstrapConfig, CommandStep, Config, CopyStep, Guard, NoSymlink};
2use crate::error::{GwmError, Result};
3use regex::Regex;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Debug, Clone)]
9pub struct BootstrapReport {
10 pub steps: Vec<StepResult>,
11}
12
13#[derive(Debug, Clone)]
14pub struct StepResult {
15 pub label: String,
16 pub status: StepStatus,
17 pub detail: String,
18}
19
20impl StepResult {
21 pub fn ok(label: impl Into<String>) -> Self {
24 Self {
25 label: label.into(),
26 status: StepStatus::Ok,
27 detail: String::new(),
28 }
29 }
30
31 pub fn ok_with_detail(label: impl Into<String>, detail: impl Into<String>) -> Self {
36 Self {
37 label: label.into(),
38 status: StepStatus::Ok,
39 detail: detail.into(),
40 }
41 }
42
43 pub fn skipped(label: impl Into<String>, reason: impl Into<String>) -> Self {
46 Self {
47 label: label.into(),
48 status: StepStatus::Skipped,
49 detail: reason.into(),
50 }
51 }
52
53 pub fn warning(label: impl Into<String>, message: impl Into<String>) -> Self {
57 Self {
58 label: label.into(),
59 status: StepStatus::Warning,
60 detail: message.into(),
61 }
62 }
63
64 pub fn failed(label: impl Into<String>, message: impl Into<String>) -> Self {
68 Self {
69 label: label.into(),
70 status: StepStatus::Failed,
71 detail: message.into(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum StepStatus {
78 Ok,
79 Skipped,
80 Warning,
81 Failed,
82}
83
84impl StepStatus {
85 pub fn sigil(&self) -> &'static str {
90 match self {
91 StepStatus::Ok => "✓",
92 StepStatus::Skipped => "·",
93 StepStatus::Warning => "!",
94 StepStatus::Failed => "✗",
95 }
96 }
97}
98
99pub struct BootstrapCtx<'a> {
100 pub main_repo: &'a Path,
101 pub worktree: &'a Path,
102 pub config: &'a Config,
103}
104
105pub fn run(ctx: &BootstrapCtx<'_>) -> Result<BootstrapReport> {
106 let mut report = BootstrapReport { steps: Vec::new() };
107 let bs = &ctx.config.bootstrap;
108
109 run_core_steps(ctx, bs, &mut report);
110 run_commands(ctx, bs, &mut report);
111
112 Ok(report)
113}
114
115pub fn run_core(ctx: &BootstrapCtx<'_>) -> Result<BootstrapReport> {
116 let mut report = BootstrapReport { steps: Vec::new() };
117 let bs = &ctx.config.bootstrap;
118
119 run_core_steps(ctx, bs, &mut report);
120
121 Ok(report)
122}
123
124fn run_core_steps(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
125 run_no_symlinks(ctx, bs, report);
131 run_copies(ctx, bs, report);
132}
133
134fn run_copies(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
135 for step in &bs.copy {
136 let label = format!("copy {} -> {}", step.from, step.to);
137 let src = ctx.main_repo.join(&step.from);
138 let dst = ctx.worktree.join(&step.to);
139
140 if let Err(e) = ensure_within(ctx.worktree, &dst) {
146 report.steps.push(StepResult::failed(
147 label,
148 format!("destination outside worktree: {}", e),
149 ));
150 continue;
151 }
152
153 match std::fs::symlink_metadata(&dst) {
167 Ok(meta) if meta.file_type().is_symlink() => {
168 report.steps.push(StepResult::failed(
169 label,
170 format!(
171 "refusing to copy: destination {} is a symlink — would redirect the write outside the worktree (issue #93)",
172 dst.display()
173 ),
174 ));
175 continue;
176 }
177 Ok(_) => {
178 report.steps.push(StepResult::skipped(
179 label,
180 "destination already exists, leaving it alone",
181 ));
182 continue;
183 }
184 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
185 Err(e) => {
186 report.steps.push(StepResult::failed(
187 label,
188 format!(
189 "failed to stat destination {}: {} — refusing to proceed with unknown filesystem state",
190 dst.display(),
191 e
192 ),
193 ));
194 continue;
195 }
196 }
197
198 if !src.exists() {
199 match resolve_missing(step, bs, &dst) {
200 Some(res) => report.steps.push(StepResult { label, ..res }),
201 None => {
202 if step.required {
203 report.steps.push(StepResult::failed(label, "required source missing"));
204 } else {
205 report.steps.push(StepResult::skipped(label, "optional source missing"));
206 }
207 }
208 }
209 continue;
210 }
211
212 match guard_match(step, bs, &src) {
214 Ok(Some(g)) => {
215 handle_guard_match(&g, &src, &dst, ctx, report, &label);
216 continue;
217 }
218 Ok(None) => {}
219 Err(detail) => {
220 report.steps.push(StepResult::failed(label, detail));
221 continue;
222 }
223 }
224
225 match copy_no_follow(&src, &dst) {
226 Ok(()) => report.steps.push(StepResult::ok_with_detail(
227 label,
228 format!("copied from {}", src.display()),
229 )),
230 Err(e) => report
231 .steps
232 .push(StepResult::failed(label, format!("copy failed: {}", e))),
233 }
234 }
235}
236
237fn resolve_missing(step: &CopyStep, bs: &BootstrapConfig, dst: &Path) -> Option<StepResult> {
238 let mode = step.fallback.as_deref().unwrap_or("skip");
239 match mode {
240 "inline" => {
241 let key = key_from_to(&step.to);
243 let fb = bs.fallback.get(&key)?;
244 match write_no_follow(dst, fb.content.as_bytes()) {
245 Ok(()) => Some(StepResult::warning(
246 "",
247 format!("source missing — wrote inline fallback to {}", dst.display()),
248 )),
249 Err(e) => Some(StepResult::failed("", format!("inline fallback write failed: {}", e))),
250 }
251 }
252 "abort" => Some(StepResult::failed("", "source missing and fallback=abort")),
253 _ => None,
254 }
255}
256
257fn key_from_to(to: &str) -> String {
258 to.trim_start_matches('.').replace(['.', '-'], "_")
260}
261
262fn guard_match(step: &CopyStep, bs: &BootstrapConfig, src: &Path) -> std::result::Result<Option<Guard>, String> {
278 if step.guards.is_empty() {
279 return Ok(None);
280 }
281 let Ok(content) = std::fs::read_to_string(src) else {
282 return Ok(None);
283 };
284 for guard_name in &step.guards {
285 let Some(guard) = bs.guard.iter().find(|g| &g.name == guard_name) else {
286 return Ok(None);
287 };
288 for pat in &guard.deny_patterns {
289 match Regex::new(pat) {
290 Ok(re) => {
291 if re.is_match(&content) {
292 return Ok(Some(guard.clone()));
293 }
294 }
295 Err(e) => {
296 return Err(format!(
297 "guard '{}' deny_pattern {:?} failed to compile at evaluation time — \
298 Config bypassed Config::load_for_repo (#96)? regex: {}",
299 guard.name, pat, e
300 ));
301 }
302 }
303 }
304 }
305 Ok(None)
306}
307
308fn handle_guard_match(
309 guard: &Guard,
310 src: &Path,
311 dst: &Path,
312 ctx: &BootstrapCtx<'_>,
313 report: &mut BootstrapReport,
314 label: &str,
315) {
316 match guard.on_match.as_str() {
317 "seed-from-example" => {
318 let example_rel = guard.example_file.as_deref().unwrap_or(".env.example");
319 let example_src = ctx.main_repo.join(example_rel);
320 if let Err(e) = ensure_within(ctx.main_repo, &example_src) {
326 report.steps.push(StepResult::failed(
327 label,
328 format!(
329 "guard '{}' example_file outside main repo: {} (traversal rejected, issue #94)",
330 guard.name, e
331 ),
332 ));
333 return;
334 }
335 if example_src.exists() {
336 match copy_no_follow(&example_src, dst) {
337 Ok(_) => report.steps.push(StepResult::warning(
338 label,
339 format!(
340 "guard '{}' tripped on {} — seeded {} from {} (edit before use)",
341 guard.name,
342 src.display(),
343 dst.display(),
344 example_src.display()
345 ),
346 )),
347 Err(e) => report.steps.push(StepResult::failed(
348 label,
349 format!("guard '{}' seed-from-example failed: {}", guard.name, e),
350 )),
351 }
352 } else {
353 report.steps.push(StepResult::failed(
354 label,
355 format!(
356 "guard '{}' tripped and no example_file {} available",
357 guard.name,
358 example_src.display()
359 ),
360 ));
361 }
362 }
363 _ => {
364 report.steps.push(StepResult::failed(
366 label,
367 format!("guard '{}' tripped on {} — abort", guard.name, src.display()),
368 ));
369 }
370 }
371}
372
373fn run_no_symlinks(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
374 for ns in &bs.no_symlink {
375 let label = format!("no-symlink {}", ns.path);
376 let target: PathBuf = ctx.worktree.join(&ns.path);
377 handle_no_symlink(&label, &target, report);
378 }
379 for default in ["vendor", "node_modules"] {
381 if bs.no_symlink.iter().any(|n: &NoSymlink| n.path == default) {
382 continue;
383 }
384 let target = ctx.worktree.join(default);
385 if target.is_symlink() {
386 handle_no_symlink(&format!("no-symlink {} (auto)", default), &target, report);
387 }
388 }
389}
390
391fn handle_no_symlink(label: &str, target: &Path, report: &mut BootstrapReport) {
392 if !target.exists() && !target.is_symlink() {
393 report.steps.push(StepResult::skipped(label, "not present"));
394 return;
395 }
396 if target.is_symlink() {
397 match std::fs::remove_file(target) {
398 Ok(_) => report.steps.push(StepResult::warning(
399 label,
400 format!("removed symlink {}", target.display()),
401 )),
402 Err(e) => report.steps.push(StepResult::failed(
403 label,
404 format!("failed to remove symlink {}: {}", target.display(), e),
405 )),
406 }
407 } else {
408 report
409 .steps
410 .push(StepResult::ok_with_detail(label, "real directory, ok"));
411 }
412}
413
414fn run_commands(ctx: &BootstrapCtx<'_>, bs: &BootstrapConfig, report: &mut BootstrapReport) {
415 for step in &bs.command {
416 let label = format!("run {}", step.name);
417 if let Some(ref guard) = step.when {
418 if !evaluate_when(guard, ctx.worktree) {
419 report
420 .steps
421 .push(StepResult::skipped(label, format!("when condition '{}' false", guard)));
422 continue;
423 }
424 }
425 match exec_shell(step, ctx.worktree) {
426 Ok(output) => report
427 .steps
428 .push(StepResult::ok_with_detail(label, trailing_lines(&output, 3))),
429 Err(e) => report.steps.push(StepResult::failed(label, e.to_string())),
430 }
431 }
432}
433
434pub fn evaluate_when(expr: &str, cwd: &Path) -> bool {
441 let tokens = tokenize_when(expr);
442 let mut parser = WhenParser {
443 tokens: &tokens,
444 pos: 0,
445 cwd,
446 };
447 parser.parse_or()
448}
449
450pub fn when_atoms(expr: &str) -> Vec<String> {
456 tokenize_when(expr)
457 .into_iter()
458 .filter_map(|t| match t {
459 WhenToken::Atom(s) => Some(s),
460 _ => None,
461 })
462 .collect()
463}
464
465#[derive(Debug, PartialEq, Eq)]
466enum WhenToken {
467 Atom(String),
468 Not,
469 And,
470 Or,
471}
472
473fn tokenize_when(expr: &str) -> Vec<WhenToken> {
474 let bytes = expr.as_bytes();
475 let mut tokens = Vec::new();
476 let mut i = 0;
477 while i < bytes.len() {
478 let c = bytes[i];
479 if c.is_ascii_whitespace() {
480 i += 1;
481 continue;
482 }
483 if c == b'!' {
484 tokens.push(WhenToken::Not);
485 i += 1;
486 continue;
487 }
488 if c == b'&' && bytes.get(i + 1) == Some(&b'&') {
489 tokens.push(WhenToken::And);
490 i += 2;
491 continue;
492 }
493 if c == b'|' && bytes.get(i + 1) == Some(&b'|') {
494 tokens.push(WhenToken::Or);
495 i += 2;
496 continue;
497 }
498 let start = i;
499 while i < bytes.len() {
500 let b = bytes[i];
501 if b.is_ascii_whitespace() {
502 break;
503 }
504 if b == b'&' && bytes.get(i + 1) == Some(&b'&') {
505 break;
506 }
507 if b == b'|' && bytes.get(i + 1) == Some(&b'|') {
508 break;
509 }
510 i += 1;
511 }
512 tokens.push(WhenToken::Atom(expr[start..i].to_string()));
513 }
514 tokens
515}
516
517struct WhenParser<'a> {
518 tokens: &'a [WhenToken],
519 pos: usize,
520 cwd: &'a Path,
521}
522
523impl<'a> WhenParser<'a> {
524 fn peek(&self) -> Option<&WhenToken> {
525 self.tokens.get(self.pos)
526 }
527
528 fn parse_or(&mut self) -> bool {
529 let mut acc = self.parse_and();
530 while let Some(WhenToken::Or) = self.peek() {
531 self.pos += 1;
532 let rhs = self.parse_and();
533 acc = acc || rhs;
534 }
535 acc
536 }
537
538 fn parse_and(&mut self) -> bool {
539 let mut acc = self.parse_not();
540 while let Some(WhenToken::And) = self.peek() {
541 self.pos += 1;
542 let rhs = self.parse_not();
543 acc = acc && rhs;
544 }
545 acc
546 }
547
548 fn parse_not(&mut self) -> bool {
549 if let Some(WhenToken::Not) = self.peek() {
550 self.pos += 1;
551 return !self.parse_not();
552 }
553 self.parse_atom()
554 }
555
556 fn parse_atom(&mut self) -> bool {
557 match self.tokens.get(self.pos) {
558 Some(WhenToken::Atom(s)) => {
559 self.pos += 1;
560 eval_when_atom(s, self.cwd)
561 }
562 _ => true,
566 }
567 }
568}
569
570fn eval_when_atom(atom: &str, cwd: &Path) -> bool {
571 if let Some(rest) = atom.strip_prefix("file_exists:") {
575 return cwd.join(rest.trim()).exists();
576 }
577 if let Some(rest) = atom.strip_prefix("cmd_exists:") {
578 return which::which(rest.trim()).is_ok();
579 }
580 if let Some(rest) = atom.strip_prefix("env_set:") {
581 return std::env::var(rest.trim()).is_ok();
582 }
583 if let Some(rest) = atom.strip_prefix("env_eq:") {
584 let Some((name, value)) = rest.split_once('=') else {
585 return false;
586 };
587 return std::env::var(name.trim()).ok().as_deref() == Some(value);
588 }
589 if let Some(pattern) = atom.strip_prefix("glob_exists:") {
590 return glob_exists(pattern.trim(), cwd);
591 }
592 true
595}
596
597fn glob_exists(pattern: &str, cwd: &Path) -> bool {
598 let full = cwd.join(pattern);
599 let Some(full_str) = full.to_str() else {
600 return false;
601 };
602 match glob::glob(full_str) {
603 Ok(mut iter) => iter.any(|r| r.is_ok()),
604 Err(_) => false,
605 }
606}
607
608fn exec_shell(step: &CommandStep, cwd: &Path) -> Result<String> {
609 let mut cmd = Command::new("sh");
610 cmd.arg("-c").arg(&step.run).current_dir(cwd);
611 for (k, v) in &step.env {
612 cmd.env(k, v);
613 }
614 let out = crate::command_log::run_logged(&mut cmd, step.run.clone())
624 .map_err(|e| GwmError::CommandFailed(format!("bootstrap step '{}': {}", step.name, e)))?;
625 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
626 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
627 if !out.status.success() {
628 return Err(GwmError::CommandFailed(format!(
629 "bootstrap step '{}' exited with {}\n{}",
630 step.name,
631 out.status,
632 if stderr.is_empty() { stdout } else { stderr }
633 )));
634 }
635 Ok(if stdout.is_empty() { stderr } else { stdout })
636}
637
638pub fn trailing_lines(s: &str, n: usize) -> String {
639 let lines: Vec<&str> = s.lines().collect();
640 let start = lines.len().saturating_sub(n);
641 lines[start..].join("\n")
642}
643
644pub fn copy_no_follow(src: &Path, dst: &Path) -> std::io::Result<()> {
680 let mut buf = Vec::new();
681 std::fs::File::open(src)?.read_to_end(&mut buf)?;
682 #[cfg(unix)]
683 let src_perms = std::fs::metadata(src)?.permissions();
684 write_no_follow(dst, &buf)?;
685 #[cfg(unix)]
686 std::fs::set_permissions(dst, src_perms)?;
687 Ok(())
688}
689
690fn ensure_within(base: &Path, path: &Path) -> std::io::Result<()> {
715 let base_canon = base.canonicalize()?;
716 let mut anc: &Path = path;
717 let canon_anc = loop {
718 if let Ok(c) = anc.canonicalize() {
719 break c;
720 }
721 match anc.parent() {
722 Some(p) if !p.as_os_str().is_empty() => anc = p,
723 _ => {
724 return Err(std::io::Error::new(
725 std::io::ErrorKind::InvalidInput,
726 format!("cannot resolve any ancestor of {:?}", path),
727 ));
728 }
729 }
730 };
731 if !canon_anc.starts_with(&base_canon) {
732 return Err(std::io::Error::new(
733 std::io::ErrorKind::InvalidInput,
734 format!(
735 "{:?} resolves outside {:?} — '..' traversal, absolute path, or symlinked intermediate component rejected (issue #94)",
736 path, base_canon
737 ),
738 ));
739 }
740 Ok(())
741}
742
743pub fn write_no_follow(dst: &Path, bytes: &[u8]) -> std::io::Result<()> {
748 let mut opts = std::fs::OpenOptions::new();
749 opts.write(true).create_new(true);
750 #[cfg(unix)]
751 {
752 use std::os::unix::fs::OpenOptionsExt;
753 opts.custom_flags(libc::O_NOFOLLOW);
754 }
755 let mut f = opts.open(dst)?;
756 f.write_all(bytes)?;
757 Ok(())
758}