Skip to main content

gwm/
bootstrap.rs

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  /// `Ok` with an empty `detail` — the most common shape (the step
22  /// label alone says everything the user needs).
23  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  /// `Ok` with an explanatory `detail` line (e.g. "copied from
32  /// <src>"). Kept as a distinct constructor rather than overloading
33  /// `ok(label, detail)` so the "no detail by default" semantics of
34  /// `ok` stay unambiguous at the call sites.
35  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  /// `Skipped` with the reason the step was bypassed (e.g.
44  /// "destination already exists", "when condition false").
45  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  /// `Warning` with the user-visible message. Used by guards
54  /// substituting from `.env.example` and the no-symlink remediation
55  /// path — the step proceeded but the user should know what changed.
56  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  /// `Failed` with the user-visible error detail. The detail SHOULD
65  /// include enough context for the user to fix the problem without
66  /// re-running with extra verbosity (filename, errno, guard name).
67  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  /// Canonical single-character glyph for each variant. Used by both
86  /// `cli::print_report` (plain stdout) and `tui::ui::render_bootstrap`
87  /// (styled `Span`); centralising the mapping here keeps the two
88  /// renderers in lock-step (issue #106).
89  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  // Order matters (issue #93): `run_no_symlinks` strips any declared
126  // symlinked targets BEFORE `run_copies` opens them for writing.
127  // Reversed, an attacker-planted symlink at a copy destination
128  // redirects the `fs::copy` write outside the worktree — a write-
129  // anywhere primitive triggered by `gwm bootstrap` alone.
130  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    // Runtime defence-in-depth (issue #94): `Config::load_for_repo`
141    // rejects `..` / absolute paths in `step.to` at load time, but
142    // callers can hand `bootstrap::run` a `Config` value built by
143    // hand (test harnesses, future programmatic embeds). Re-check
144    // here that `dst` resolves under the worktree before any write.
145    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    // Single stat on `dst` (issue #93): `symlink_metadata` does NOT
154    // follow symlinks (unlike `Path::exists`), and reusing one result
155    // for every branch below avoids the TOCTOU window of a second stat.
156    //
157    //   Ok(symlink)     → Failed (defence in depth — symlinks at a
158    //                     declared copy dst are suspicious enough to
159    //                     surface, even when [[bootstrap.no_symlink]]
160    //                     didn't list them)
161    //   Ok(other)       → Skipped (regular file or directory already
162    //                     populated — leave the user's edits alone)
163    //   Err(NotFound)   → fall through to the copy / fallback chain
164    //   Err(other)      → Failed (permission / IO error masking the
165    //                     filesystem state — never silently swallow)
166    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    // Run guards before copying.
213    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      // Find a fallback content keyed by the `to` file basename or step.fallback alias.
242      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  // ".env.testing" → "env_testing"
259  to.trim_start_matches('.').replace(['.', '-'], "_")
260}
261
262/// Evaluate the configured guards against `src`'s contents.
263///
264/// Returns:
265///   - `Ok(Some(guard))` — a guard tripped on a denied pattern; the
266///     caller routes to `handle_guard_match` to apply `on_match`.
267///   - `Ok(None)` — no guard tripped; copy proceeds.
268///   - `Err(detail)` — a `deny_patterns` entry failed to compile.
269///     `Config::load_for_repo` is supposed to have caught this at
270///     load time (issue #96), so reaching this branch means the
271///     `Config` value came from a code path that bypassed the
272///     loader (test fixture, programmatic constructor, future API).
273///     Fail-closed: the caller reports a `Failed` step and the copy
274///     is refused, mirroring the abort path for a true match. A
275///     refusal mechanism whose pattern set is partially broken must
276///     never silently pass — see issue #96.
277fn 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      // Runtime defence-in-depth (issue #94): refuse to read an
321      // example_file that resolves outside `ctx.main_repo`. Mirrors
322      // the dst-side check in `run_copies`; the `Config` loader
323      // rejects this at load time, this branch covers hand-built
324      // configs.
325      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      // abort
365      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  // Also enforce common defaults if not declared explicitly.
380  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
434/// Evaluate a `[[bootstrap.command]].when` expression against the given
435/// worktree. Supports the keyword predicates `file_exists:`, `cmd_exists:`,
436/// `env_set:`, `env_eq:`, `glob_exists:`, plus the boolean operators `!`,
437/// `&&`, `||` with conventional precedence (`!` > `&&` > `||`). Unknown
438/// keyword predicates default to `true` so older configs keep running while
439/// the doctor surfaces them as warnings.
440pub 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
450/// Return every atom string contained in a `when` expression, dropping
451/// the boolean operators. Callers (e.g. `doctor::check_when_predicates`)
452/// can then validate each atom independently — `w.starts_with(prefix)`
453/// on the raw expression misses negated atoms (`!env_set:CI`) and
454/// unsupported keywords sitting on the RHS of `&&` / `||`.
455pub 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      // Empty expression or a dangling operator: fall back to true to
563      // match the "unknown predicate" contract — a config we can't
564      // understand should not silently skip every command.
565      _ => true,
566    }
567  }
568}
569
570fn eval_when_atom(atom: &str, cwd: &Path) -> bool {
571  // Each atom-argument is trimmed to absorb any Unicode whitespace that
572  // the ASCII-only tokenizer left glued to the value. Preserves the
573  // legacy `file_exists:` tolerance from the pre-tokenizer evaluator.
574  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  // Unknown keyword: default to true so we don't silently neutralise a
593  // command the user clearly wanted to run.
594  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  // The `bootstrap step '…'` prefix used to live in the variant's
615  // Display impl; it moved into the data string when the variant was
616  // generalised in #65 so other subcommands (gwm tmux / gwm zellij)
617  // don't inherit a misleading "bootstrap" prefix on their own
618  // spawn failures.
619  // Record on the Command Logs transcript (issue #226): a bootstrap step
620  // is an external command gwm ran. The logged line is the user-authored
621  // shell script, not the `sh -c` wrapper, so the transcript reads like the
622  // command the user wrote.
623  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
644// --------------------------------------------------------------------------
645// TOCTOU-safe write primitives (issue #93 follow-up)
646// --------------------------------------------------------------------------
647//
648// The `symlink_metadata` guard at the top of `run_copies` closes the
649// "symlink already exists at copy time" attack vector, but a small
650// race window remained between the stat and the subsequent `fs::copy`
651// (or `fs::write` in the inline-fallback path): an attacker with
652// concurrent write access to the worktree could plant a symlink in
653// the µs after the stat, redirecting the write through `O_CREAT |
654// O_TRUNC` (both of which follow symlinks). These helpers close that
655// window by opening `dst` with `O_NOFOLLOW | O_CREAT | O_EXCL` so
656// that:
657//
658//   - A symlink at `dst` causes `open()` to fail with `ELOOP`.
659//   - Any other entry (regular file, dir, FIFO) causes `EEXIST` —
660//     `create_new(true)` maps to `O_EXCL` on unix and `CREATE_NEW`
661//     on Windows.
662//   - On a fresh `dst`, the file is created and truncated atomically
663//     under the same fd handed to `write_all`.
664//
665// On non-unix platforms `O_NOFOLLOW` is unavailable in `std`; the
666// `create_new(true)` half still holds, and the bug class flagged on
667// #93 is unix-only anyway (Windows symlinks require admin and aren't
668// the realistic attack surface for `gwm bootstrap`).
669
670/// Copy the contents of `src` into `dst` as a fresh regular file,
671/// refusing to follow any symlink at `dst`. `src` permissions are
672/// preserved on unix.
673///
674/// Returns the standard `io::Result` so callers can format the errno
675/// into their step report without losing the error kind. The `dst`
676/// is opened with `O_NOFOLLOW | O_CREAT | O_EXCL` on unix; a symlink
677/// (broken or live) at `dst` triggers `ELOOP`, anything else
678/// pre-existing triggers `EEXIST`.
679pub 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
690/// Verify that `path` resolves to a location inside `base` (issue
691/// #94). Both `path` and `base` may contain symlinks; both are
692/// canonicalized so the check operates on real on-disk identities.
693///
694/// `path` typically does NOT exist yet (it's a freshly-computed copy
695/// destination), so we canonicalize the deepest existing ancestor
696/// and check that the canonical ancestor still falls under
697/// `base.canonicalize()`. This catches `..` traversal, absolute
698/// paths, and symlinks in intermediate components that redirect
699/// outside `base`.
700///
701/// Returns `Err` with `ErrorKind::InvalidInput` when the path
702/// escapes `base`; surrounding code surfaces the error verbatim
703/// in the step report so the user knows which field went wrong.
704///
705/// **TOCTOU note**: a residual window exists between this ancestor
706/// canonicalization and the final write — an attacker who can plant
707/// a symlink at an intermediate component between the two would
708/// re-route the resolved path. That gap is closed by the
709/// `O_NOFOLLOW`-based writers from issue #93 (`copy_no_follow` /
710/// `write_no_follow`): even if the path mutates post-check, the
711/// final `open` returns `ELOOP` / `EEXIST` rather than writing
712/// through. `ensure_within` and the no-follow writers are
713/// complementary; neither alone is sufficient.
714fn 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
743/// Companion to [`copy_no_follow`] for callers that already hold the
744/// payload in memory (e.g. the inline-fallback branch in
745/// [`resolve_missing`]). Same TOCTOU-closing semantics: `dst` must
746/// not exist and must not be a symlink, or `open()` fails.
747pub 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}